Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c26c836d6 | ||
|
|
592550e71c | ||
|
|
baacaabf24 | ||
|
|
f9bfd76e00 | ||
|
|
af84d8ebc4 | ||
|
|
0861ed38fc | ||
|
|
371bd55e18 | ||
|
|
08b6a20ff1 | ||
|
|
598053e94a | ||
|
|
48cf8eb347 | ||
|
|
8171930805 | ||
|
|
2f829ef2ae | ||
|
|
e2fb7bf1da | ||
|
|
b0147405c0 |
@@ -0,0 +1,51 @@
|
||||
name: Bug Report
|
||||
description: Report a bug with the Pixel10 AI Server
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug.
|
||||
- type: input
|
||||
id: device
|
||||
attributes:
|
||||
label: Device
|
||||
description: Which device are you using?
|
||||
placeholder: "e.g. Pixel 10 Pro"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: android-version
|
||||
attributes:
|
||||
label: Android Version
|
||||
placeholder: "e.g. Android 16 (API 36)"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: backend
|
||||
attributes:
|
||||
label: AI Backend
|
||||
options:
|
||||
- Gemini Nano (ML Kit)
|
||||
- MediaPipe (custom model)
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Describe the bug clearly.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: What did you expect to happen?
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: Paste any relevant logs from the app or logcat.
|
||||
render: shell
|
||||
@@ -0,0 +1,16 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Describe the feature
|
||||
description: What would you like to see added or changed?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: use-case
|
||||
attributes:
|
||||
label: Use case
|
||||
description: How would you use this feature?
|
||||
@@ -0,0 +1,17 @@
|
||||
## Summary
|
||||
|
||||
<!-- What does this PR do? -->
|
||||
|
||||
## Changes
|
||||
|
||||
-
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] Builds successfully (`./gradlew assembleDebug`)
|
||||
- [ ] Tested on device
|
||||
- [ ] API endpoints verified with curl
|
||||
|
||||
## Notes
|
||||
|
||||
<!-- Any additional context or screenshots -->
|
||||
@@ -0,0 +1,116 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build APK
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-
|
||||
|
||||
- name: Build debug APK
|
||||
run: ./gradlew assembleDebug --no-daemon
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: app-debug
|
||||
path: app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
# NOTE: Full inference tests (tool calling, generation) require a real Pixel
|
||||
# device with the Tensor chip and Gemini Nano available via AICore.
|
||||
# The emulator has no Tensor chip — run on-device tests manually with:
|
||||
# adb install app-debug.apk
|
||||
# adb shell am startservice -n com.pixel10.ai/.server.ApiServerService \
|
||||
# -a com.pixel10.ai.START_SERVER --ei port 8080
|
||||
# adb forward tcp:8080 tcp:8080
|
||||
# curl http://localhost:8080/health
|
||||
install-smoke:
|
||||
name: Install + Smoke Test (emulator)
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Enable KVM
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
|
||||
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Download APK artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: app-debug
|
||||
path: apk/
|
||||
|
||||
- name: Install Android SDK components
|
||||
run: |
|
||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null
|
||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
|
||||
"platform-tools" \
|
||||
"emulator" \
|
||||
"system-images;android-31;google_apis;x86_64"
|
||||
|
||||
- name: Create AVD
|
||||
run: |
|
||||
echo no | $ANDROID_HOME/cmdline-tools/latest/bin/avdmanager create avd \
|
||||
--name pixel10_test \
|
||||
--package "system-images;android-31;google_apis;x86_64" \
|
||||
--device "pixel_6"
|
||||
|
||||
- name: Start emulator
|
||||
run: |
|
||||
$ANDROID_HOME/emulator/emulator \
|
||||
-avd pixel10_test \
|
||||
-no-window -no-audio -no-snapshot \
|
||||
-gpu swiftshader_indirect &
|
||||
$ANDROID_HOME/platform-tools/adb wait-for-device
|
||||
|
||||
- name: Wait for emulator boot
|
||||
run: |
|
||||
timeout 300 bash -c '
|
||||
until $ANDROID_HOME/platform-tools/adb shell \
|
||||
getprop sys.boot_completed 2>/dev/null | grep -q "1"; do
|
||||
sleep 3
|
||||
done
|
||||
'
|
||||
$ANDROID_HOME/platform-tools/adb shell input keyevent 82
|
||||
|
||||
- name: Install APK
|
||||
run: $ANDROID_HOME/platform-tools/adb install apk/app-debug.apk
|
||||
|
||||
- name: Verify APK installed
|
||||
run: |
|
||||
$ANDROID_HOME/platform-tools/adb shell pm list packages | grep com.pixel10.ai \
|
||||
|| (echo "FAIL: APK not installed" && exit 1)
|
||||
echo "PASS: APK installed successfully"
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build and Release APK
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-
|
||||
|
||||
- name: Build debug APK
|
||||
run: ./gradlew assembleDebug --no-daemon
|
||||
|
||||
- name: Rename APK with version tag
|
||||
run: |
|
||||
TAG="${{ github.ref_name }}"
|
||||
cp app/build/outputs/apk/debug/app-debug.apk "pixel10-ai-${TAG}.apk"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: "pixel10-ai-${{ github.ref_name }}.apk"
|
||||
generate_release_notes: true
|
||||
@@ -0,0 +1,31 @@
|
||||
# Contributing to Pixel10 AI Server
|
||||
|
||||
Contributions are welcome! Here's how to get started.
|
||||
|
||||
## Development Setup
|
||||
|
||||
1. Clone the repo
|
||||
2. Open in Android Studio (Ladybug or newer) or build from CLI
|
||||
3. Ensure you have JDK 17+ and Android SDK 35 installed
|
||||
4. Build: `./gradlew assembleDebug`
|
||||
|
||||
## Making Changes
|
||||
|
||||
1. Fork the repo and create a branch from `main`
|
||||
2. Make your changes
|
||||
3. Test on a physical device (emulators don't have Tensor TPU or AICore)
|
||||
4. Submit a pull request
|
||||
|
||||
## Code Style
|
||||
|
||||
- Follow existing Kotlin conventions in the project
|
||||
- Use coroutines for async work (no callbacks)
|
||||
- Keep the OpenAI API compatibility — don't break existing endpoints
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Use the GitHub issue templates for bug reports and feature requests. Include your device model and Android version.
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the Apache License 2.0.
|
||||
@@ -0,0 +1,190 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2025 alexpolo1
|
||||
|
||||
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
|
||||
|
||||
http://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.
|
||||
@@ -1,142 +1,207 @@
|
||||
# Pixel10 AI Server
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Android-12%2B-3DDC84?logo=android&logoColor=white" alt="Android 12+">
|
||||
<img src="https://img.shields.io/badge/Kotlin-2.1-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin">
|
||||
<img src="https://img.shields.io/badge/API-OpenAI_Compatible-412991?logo=openai&logoColor=white" alt="OpenAI Compatible">
|
||||
<img src="https://img.shields.io/github/license/alexpolo1/Pixel10-ai" alt="License">
|
||||
<img src="https://img.shields.io/github/v/release/alexpolo1/Pixel10-ai" alt="Release">
|
||||
</p>
|
||||
|
||||
Turn your Pixel 10 into a free AI API server. This Android app exposes the Tensor G5's on-device AI chip via a REST API, letting any device on your network make AI inference requests — no cloud, no API keys, no costs.
|
||||
<h1 align="center">Pixel10 AI Server</h1>
|
||||
|
||||
## How It Works
|
||||
<p align="center">
|
||||
<strong>Turn your Pixel into a free, private AI API server.</strong><br>
|
||||
Run Gemini Nano on the Tensor G5 chip and expose it as a local REST API — no cloud, no API keys, no cost.
|
||||
</p>
|
||||
|
||||
The app runs an HTTP server directly on your phone that accepts OpenAI-compatible API requests. Under the hood, it uses Google's on-device AI stack:
|
||||
---
|
||||
|
||||
1. **Gemini Nano** (preferred) — The system-provided model via ML Kit Prompt API, hardware-accelerated on the Tensor G5 TPU with a 32K token context window
|
||||
2. **MediaPipe LLM** (fallback) — For custom open-weight models like Gemma 3n or Gemma 2B that you supply yourself
|
||||
## What is this?
|
||||
|
||||
An Android app that turns your Pixel phone into a self-hosted AI inference server. It runs an HTTP server directly on the device, accepting **OpenAI-compatible** API requests over your local network.
|
||||
|
||||
Under the hood it uses Google's on-device AI stack:
|
||||
|
||||
- **Gemini Nano** via ML Kit Prompt API — hardware-accelerated on the Tensor G5 TPU
|
||||
- **MediaPipe LLM** as fallback — for custom open-weight models like Gemma 3n
|
||||
|
||||
All inference runs entirely on-device. Your data never leaves the phone.
|
||||
|
||||
## API Endpoints
|
||||
## Features
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/v1/chat/completions` | Chat completion (OpenAI-compatible) |
|
||||
| `POST` | `/v1/completions` | Text completion |
|
||||
| `GET` | `/v1/models` | List available models |
|
||||
| `GET` | `/health` | Server status and device info |
|
||||
- **OpenAI-compatible API** — drop-in replacement for `openai.ChatCompletion.create()`
|
||||
- **Streaming support** — real-time Server-Sent Events (SSE) token streaming
|
||||
- **Zero configuration** — install, tap Start, done
|
||||
- **Fully offline** — no internet required after install
|
||||
- **Private by design** — prompts and responses stay on your device
|
||||
- **Background service** — keeps serving even when the app is minimized
|
||||
- **Custom model support** — bring your own Gemma, LLaMA, or other compatible models
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install and Launch
|
||||
### 1. Install
|
||||
|
||||
Build the APK in Android Studio and install on your Pixel 10 (or Pixel 9/8 series).
|
||||
Download the APK from [Releases](https://github.com/alexpolo1/Pixel10-ai/releases) and install:
|
||||
|
||||
```bash
|
||||
adb install app-debug.apk
|
||||
```
|
||||
|
||||
Or build from source (see [Building](#building) below).
|
||||
|
||||
### 2. Start the Server
|
||||
|
||||
Open the app and tap **Start Server**. The app will:
|
||||
- Load the AI model (Gemini Nano or your custom model)
|
||||
- Start the HTTP server on the configured port (default: 8080)
|
||||
- Display the local IP address to connect to
|
||||
Open **Pixel10 AI Server**, tap **Start Server**. The app will load the model and display your device's IP address.
|
||||
|
||||
### 3. Make Requests
|
||||
### 3. Send Requests
|
||||
|
||||
From any device on the same WiFi network:
|
||||
From any device on the same network:
|
||||
|
||||
```bash
|
||||
# Chat completion
|
||||
curl http://<phone-ip>:8080/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "What is the Tensor G5 chip?"}
|
||||
]
|
||||
"messages": [{"role": "user", "content": "What is the Tensor G5 chip?"}]
|
||||
}'
|
||||
|
||||
# Simple completion
|
||||
curl http://<phone-ip>:8080/v1/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"prompt": "Explain quantum computing in simple terms"}'
|
||||
|
||||
# Health check
|
||||
curl http://<phone-ip>:8080/health
|
||||
|
||||
# List models
|
||||
curl http://<phone-ip>:8080/v1/models
|
||||
```
|
||||
|
||||
### Use with Python OpenAI Library
|
||||
## API Reference
|
||||
|
||||
All endpoints follow the [OpenAI API](https://platform.openai.com/docs/api-reference) format.
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/v1/chat/completions` | Chat completion (supports streaming) |
|
||||
| `POST` | `/v1/completions` | Text completion |
|
||||
| `GET` | `/v1/models` | List available models |
|
||||
| `GET` | `/health` | Server status, device info, uptime |
|
||||
|
||||
### Chat Completion
|
||||
|
||||
```bash
|
||||
curl http://<phone-ip>:8080/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "pixel10-on-device",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Explain quantum computing briefly."}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 1024,
|
||||
"stream": false
|
||||
}'
|
||||
```
|
||||
|
||||
### Streaming
|
||||
|
||||
```bash
|
||||
curl -N http://<phone-ip>:8080/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "pixel10-on-device",
|
||||
"messages": [{"role": "user", "content": "Write a haiku about the ocean."}],
|
||||
"stream": true
|
||||
}'
|
||||
```
|
||||
|
||||
### Python (OpenAI SDK)
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="http://<phone-ip>:8080/v1",
|
||||
api_key="not-needed" # no auth required
|
||||
api_key="not-needed"
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="pixel10-on-device",
|
||||
messages=[
|
||||
{"role": "user", "content": "Hello from my laptop!"}
|
||||
]
|
||||
messages=[{"role": "user", "content": "Hello from my laptop!"}]
|
||||
)
|
||||
print(response.choices[0].message.content)
|
||||
```
|
||||
|
||||
## Using Custom Models (MediaPipe)
|
||||
## Custom Models (MediaPipe)
|
||||
|
||||
If Gemini Nano isn't available on your device, you can use custom models:
|
||||
If Gemini Nano isn't available on your device, you can use custom open-weight models:
|
||||
|
||||
1. Download a compatible model (e.g., [Gemma 3n E2B](https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android))
|
||||
1. Download a compatible model (e.g. [Gemma 3n E2B](https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android))
|
||||
2. Push to the device:
|
||||
```bash
|
||||
adb push gemma-3n-E2B.task /data/data/com.pixel10.ai/files/
|
||||
```
|
||||
3. Restart the app — it will auto-detect the model file
|
||||
3. Restart the app — it auto-detects model files
|
||||
|
||||
Supported model formats: `.task`, `.bin`, `.tflite`
|
||||
**Supported formats:** `.task`, `.bin`, `.tflite`
|
||||
|
||||
## Supported Devices
|
||||
|
||||
- **Pixel 10 / 10 Pro / 10 Pro XL** — Full Tensor G5 TPU acceleration
|
||||
- **Pixel 9 series** — Tensor G4 TPU
|
||||
- **Pixel 8 series** — Tensor G3 TPU
|
||||
- Other Android 12+ devices — MediaPipe backend with custom models
|
||||
|
||||
## Requirements
|
||||
|
||||
- Android 12 (API 31) or higher
|
||||
- WiFi connection (for network access to the API)
|
||||
- For Gemini Nano: Pixel device with AICore support
|
||||
- For custom models: Compatible model file placed in app directory
|
||||
| Device | Chip | Backend |
|
||||
|--------|------|---------|
|
||||
| Pixel 10 / Pro / Pro XL | Tensor G5 | Gemini Nano (TPU-accelerated) |
|
||||
| Pixel 9 series | Tensor G4 | Gemini Nano (TPU-accelerated) |
|
||||
| Pixel 8 series | Tensor G3 | Gemini Nano (TPU-accelerated) |
|
||||
| Other Android 12+ | Various | MediaPipe with custom models |
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone <repo-url>
|
||||
git clone https://github.com/alexpolo1/Pixel10-ai.git
|
||||
cd Pixel10-ai
|
||||
|
||||
# Open in Android Studio and build, or:
|
||||
./gradlew assembleDebug
|
||||
|
||||
# Install on connected device
|
||||
adb install app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
**Requirements:** JDK 17+, Android SDK 35
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
com.pixel10.ai/
|
||||
├── Pixel10AIApp.kt # Application init
|
||||
├── inference/
|
||||
│ ├── OnDeviceModel.kt # Unified model interface
|
||||
│ ├── GeminiNanoModel.kt # Gemini Nano via ML Kit Prompt API
|
||||
│ └── MediaPipeModel.kt # Custom models via MediaPipe LLM
|
||||
│ ├── OnDeviceModel.kt # Unified inference interface
|
||||
│ ├── GeminiNanoModel.kt # ML Kit Prompt API backend
|
||||
│ └── MediaPipeModel.kt # MediaPipe LLM backend
|
||||
├── server/
|
||||
│ ├── AIApiServer.kt # NanoHTTPD-based REST API server
|
||||
│ ├── ApiModels.kt # Request/response data classes
|
||||
│ └── ApiServerService.kt # Foreground service for background operation
|
||||
├── ui/
|
||||
│ └── MainActivity.kt # Server controls and status dashboard
|
||||
└── Pixel10AIApp.kt # Application class
|
||||
│ ├── AIApiServer.kt # NanoHTTPD REST server
|
||||
│ ├── ApiModels.kt # Request/response models
|
||||
│ └── ApiServerService.kt # Foreground service
|
||||
└── ui/
|
||||
└── MainActivity.kt # Server controls & dashboard
|
||||
```
|
||||
|
||||
## Disclaimer
|
||||
|
||||
> This project is provided for **educational and experimental purposes only**.
|
||||
>
|
||||
> The Gemini Nano model is accessed through the ML Kit GenAI API, which is subject to [Google's ML Kit Terms of Service](https://developers.google.com/ml-kit/terms) and the [GenAI API Additional Terms](https://developers.google.com/ml-kit/genai-terms). Exposing on-device models as a network API may not be a documented use case under those terms. Users are responsible for reviewing and complying with all applicable terms of service.
|
||||
>
|
||||
> This project is not affiliated with, endorsed by, or sponsored by Google.
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Library | License |
|
||||
|---------|---------|
|
||||
| [ML Kit GenAI](https://developers.google.com/ml-kit) | Google ToS |
|
||||
| [MediaPipe](https://github.com/google-ai-edge/mediapipe) | Apache 2.0 |
|
||||
| [NanoHTTPD](https://github.com/NanoHttpd/nanohttpd) | BSD 3-Clause |
|
||||
| [Gson](https://github.com/google/gson) | Apache 2.0 |
|
||||
| [AndroidX](https://developer.android.com/jetpack/androidx) | Apache 2.0 |
|
||||
| [Kotlin Coroutines](https://github.com/Kotlin/kotlinx.coroutines) | Apache 2.0 |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
```
|
||||
Copyright 2025 alexpolo1
|
||||
|
||||
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
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
```
|
||||
|
||||
See [LICENSE](LICENSE) for the full text.
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# Security
|
||||
|
||||
## Important
|
||||
|
||||
This app runs an **unauthenticated HTTP server** on your local network. By design, anyone on the same network can send requests to the API. Do not expose this server to the public internet.
|
||||
|
||||
## Reporting Vulnerabilities
|
||||
|
||||
If you discover a security issue, please open a GitHub issue or contact the maintainer directly. Since this is a local-network tool, most security concerns relate to network exposure rather than data handling.
|
||||
|
||||
## Scope
|
||||
|
||||
- The server binds to all network interfaces on the configured port
|
||||
- No authentication or API keys are required
|
||||
- All inference is local — no data is sent to external servers
|
||||
- CORS is permissive (`*`) for local development convenience
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.pixel10.ai"
|
||||
minSdk = 31
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
versionCode = 7
|
||||
versionName = "1.7.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -51,9 +51,12 @@ dependencies {
|
||||
// ML Kit GenAI — Gemini Nano via AICore (recommended for Pixel 10)
|
||||
implementation("com.google.mlkit:genai-prompt:1.0.0-beta1")
|
||||
|
||||
// MediaPipe LLM Inference — for custom models (Gemma, etc.)
|
||||
// MediaPipe LLM Inference — legacy fallback for .task/.bin models
|
||||
implementation("com.google.mediapipe:tasks-genai:0.10.24")
|
||||
|
||||
// LiteRT-LM — primary backend for Gemma 3n .litertlm models
|
||||
implementation("com.google.ai.edge.litertlm:litertlm-android:0.9.0-alpha05")
|
||||
|
||||
// Embedded HTTP server
|
||||
implementation("org.nanohttpd:nanohttpd:2.3.1")
|
||||
|
||||
|
||||
@@ -21,11 +21,17 @@
|
||||
android:name=".Pixel10AIApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.pixel10.ai.inference
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.google.ai.edge.litertlm.Backend
|
||||
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
|
||||
|
||||
/**
|
||||
* LiteRT-LM backend for Gemma 3n models (.litertlm format).
|
||||
*
|
||||
* This replaces MediaPipe for the newer Gemma 3n E4B/E2B models which use
|
||||
* the LiteRT-LM runtime. Runs fully on-device using the Tensor G5 GPU.
|
||||
*
|
||||
* Model files must be placed in the app's files directory (see [ModelDownloader]).
|
||||
*/
|
||||
class LiteRTModel private constructor(
|
||||
private val engine: Engine,
|
||||
private val modelName: String
|
||||
) : OnDeviceModel {
|
||||
|
||||
override val backendName = "LiteRT-LM ($modelName)"
|
||||
|
||||
@Volatile
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
isReady = false
|
||||
engine.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LiteRTModel"
|
||||
|
||||
private val MODEL_EXTENSIONS = listOf("litertlm")
|
||||
|
||||
suspend fun create(context: Context): LiteRTModel = withContext(Dispatchers.IO) {
|
||||
val modelPath = findModelPath(context)
|
||||
?: throw OnDeviceModel.InferenceException(
|
||||
"No LiteRT-LM model file found.\n" +
|
||||
"Download a .litertlm model via the app or place one in:\n" +
|
||||
" ${context.filesDir.absolutePath}/"
|
||||
)
|
||||
|
||||
val modelName = File(modelPath).name
|
||||
Log.i(TAG, "Loading LiteRT-LM model: $modelPath")
|
||||
|
||||
try {
|
||||
val config = EngineConfig(
|
||||
modelPath = modelPath,
|
||||
backend = Backend.GPU
|
||||
)
|
||||
val engine = Engine(config)
|
||||
withContext(Dispatchers.Default) {
|
||||
engine.initialize()
|
||||
}
|
||||
Log.i(TAG, "LiteRT-LM model loaded: $modelName")
|
||||
LiteRTModel(engine, modelName)
|
||||
} catch (gpuError: Exception) {
|
||||
Log.w(TAG, "GPU backend failed, trying CPU: ${gpuError.message}")
|
||||
try {
|
||||
val config = EngineConfig(
|
||||
modelPath = modelPath,
|
||||
backend = Backend.CPU
|
||||
)
|
||||
val engine = Engine(config)
|
||||
withContext(Dispatchers.Default) {
|
||||
engine.initialize()
|
||||
}
|
||||
Log.i(TAG, "LiteRT-LM model loaded on CPU: $modelName")
|
||||
LiteRTModel(engine, modelName)
|
||||
} catch (e: Exception) {
|
||||
throw OnDeviceModel.InferenceException(
|
||||
"Failed to load LiteRT-LM model from $modelPath: ${e.message}", e
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findModelPath(context: Context): String? {
|
||||
val searchDirs = listOfNotNull(
|
||||
context.filesDir,
|
||||
File(context.filesDir, "models"),
|
||||
context.getExternalFilesDir(null)
|
||||
)
|
||||
for (dir in searchDirs) {
|
||||
if (!dir.exists()) continue
|
||||
dir.listFiles()?.firstOrNull { it.extension in MODEL_EXTENSIONS }
|
||||
?.let { return it.absolutePath }
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.pixel10.ai.inference
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* Downloads a MediaPipe-compatible model for background-safe inference.
|
||||
*
|
||||
* All models are hosted on HuggingFace and require a free API token.
|
||||
* Get one at: https://huggingface.co/settings/tokens
|
||||
*
|
||||
* Models use the MediaPipe `.task` format, compatible with [MediaPipeModel].
|
||||
* Gemma 3n E4B/E2B (`.litertlm` format) requires a runtime upgrade — coming later.
|
||||
*/
|
||||
object ModelDownloader {
|
||||
|
||||
private const val TAG = "ModelDownloader"
|
||||
private const val HF_BASE = "https://huggingface.co"
|
||||
|
||||
/** Available model specs downloadable from HuggingFace (requires token + license acceptance). */
|
||||
enum class ModelSpec(
|
||||
val displayName: String,
|
||||
val filename: String,
|
||||
val repo: String,
|
||||
val sizeMb: Int,
|
||||
val description: String
|
||||
) {
|
||||
/**
|
||||
* Gemma 3n E4B INT4 — best quality, Tensor G5 optimised, background-safe.
|
||||
* Accept license at: https://huggingface.co/google/gemma-3n-E4B-it-litert-lm
|
||||
*/
|
||||
GEMMA_3N_E4B(
|
||||
displayName = "Gemma 3n E4B",
|
||||
filename = "gemma-3n-E4B-it-int4.litertlm",
|
||||
repo = "google/gemma-3n-E4B-it-litert-lm",
|
||||
sizeMb = 4920,
|
||||
description = "Best quality — Tensor G5 optimised (~4.9 GB)"
|
||||
),
|
||||
/**
|
||||
* Gemma 3n E4B Web INT4 — smaller variant, slightly lower quality.
|
||||
* Same license as above.
|
||||
*/
|
||||
GEMMA_3N_E4B_WEB(
|
||||
displayName = "Gemma 3n E4B (Web)",
|
||||
filename = "gemma-3n-E4B-it-int4-Web.litertlm",
|
||||
repo = "google/gemma-3n-E4B-it-litert-lm",
|
||||
sizeMb = 4280,
|
||||
description = "Slightly smaller variant (~4.3 GB)"
|
||||
)
|
||||
}
|
||||
|
||||
data class Progress(
|
||||
val downloadedBytes: Long,
|
||||
val totalBytes: Long,
|
||||
val percent: Int = if (totalBytes > 0) (downloadedBytes * 100 / totalBytes).toInt() else 0
|
||||
)
|
||||
|
||||
/** Returns true if any supported model is present in the app's files directory. */
|
||||
fun isModelPresent(context: Context): Boolean =
|
||||
ModelSpec.values().any { modelFile(context, it).let { f -> f.exists() && f.length() > 1_000_000L } }
|
||||
|
||||
/** Returns the installed [ModelSpec], or null if no model is present. */
|
||||
fun installedSpec(context: Context): ModelSpec? =
|
||||
ModelSpec.values().firstOrNull { modelFile(context, it).let { f -> f.exists() && f.length() > 1_000_000L } }
|
||||
|
||||
fun modelFile(context: Context, spec: ModelSpec): File =
|
||||
File(context.filesDir, spec.filename)
|
||||
|
||||
/** Returns the file of the installed model, or E4B path as default. */
|
||||
fun modelFile(context: Context): File =
|
||||
installedSpec(context)?.let { modelFile(context, it) }
|
||||
?: modelFile(context, ModelSpec.GEMMA_3N_E4B)
|
||||
|
||||
/**
|
||||
* Download [spec] from HuggingFace, using [hfToken] for authentication.
|
||||
* Supports resume — if a partial file exists, continues from where it left off.
|
||||
*
|
||||
* Get a free token at https://huggingface.co/settings/tokens
|
||||
*/
|
||||
suspend fun download(
|
||||
context: Context,
|
||||
spec: ModelSpec = ModelSpec.GEMMA_3N_E4B,
|
||||
hfToken: String,
|
||||
onProgress: (Progress) -> Unit
|
||||
) = withContext(Dispatchers.IO) {
|
||||
if (hfToken.isBlank()) throw OnDeviceModel.InferenceException(
|
||||
"HuggingFace token required.\nGet a free token at huggingface.co/settings/tokens"
|
||||
)
|
||||
|
||||
val dest = modelFile(context, spec)
|
||||
val alreadyDownloaded = if (dest.exists()) dest.length() else 0L
|
||||
val downloadUrl = "$HF_BASE/${spec.repo}/resolve/main/${spec.filename}"
|
||||
|
||||
Log.i(TAG, "Download starting ${spec.displayName} from $downloadUrl (already have $alreadyDownloaded bytes)")
|
||||
|
||||
val conn = URL(downloadUrl).openConnection() as HttpURLConnection
|
||||
try {
|
||||
conn.connectTimeout = 30_000
|
||||
conn.readTimeout = 60_000
|
||||
conn.setRequestProperty("Authorization", "Bearer $hfToken")
|
||||
if (alreadyDownloaded > 0) {
|
||||
conn.setRequestProperty("Range", "bytes=$alreadyDownloaded-")
|
||||
}
|
||||
conn.connect()
|
||||
|
||||
val code = conn.responseCode
|
||||
if (code == 401 || code == 403) throw OnDeviceModel.InferenceException(
|
||||
"Authentication failed (HTTP $code).\nCheck your HuggingFace token."
|
||||
)
|
||||
val resuming = code == HttpURLConnection.HTTP_PARTIAL // 206
|
||||
if (code != HttpURLConnection.HTTP_OK && !resuming) {
|
||||
throw OnDeviceModel.InferenceException("Download failed: HTTP $code")
|
||||
}
|
||||
|
||||
val serverBytes = conn.contentLengthLong.coerceAtLeast(0L)
|
||||
val totalBytes = if (resuming) alreadyDownloaded + serverBytes else serverBytes
|
||||
|
||||
conn.inputStream.use { input ->
|
||||
FileOutputStream(dest, /* append= */ resuming).use { out ->
|
||||
val buf = ByteArray(128 * 1024)
|
||||
var written = alreadyDownloaded
|
||||
var read: Int
|
||||
while (input.read(buf).also { read = it } != -1) {
|
||||
out.write(buf, 0, read)
|
||||
written += read
|
||||
onProgress(Progress(written, totalBytes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "Download complete — ${dest.length()} bytes")
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteModel(context: Context) {
|
||||
ModelSpec.values().forEach { modelFile(context, it).delete() }
|
||||
Log.i(TAG, "All models deleted")
|
||||
}
|
||||
}
|
||||
@@ -6,18 +6,15 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Unified interface for on-device AI inference on the Pixel 10.
|
||||
* Unified interface for on-device AI inference on the Pixel 10's Tensor chip.
|
||||
*
|
||||
* Supports two backends:
|
||||
* 1. **Gemini Nano** via ML Kit Prompt API — uses the system-managed model
|
||||
* through AICore, accelerated by the Tensor G5 TPU. Zero setup needed
|
||||
* on supported Pixel devices.
|
||||
* Backends (tried in order):
|
||||
* 1. **Gemini Nano** via ML Kit Prompt API — system-managed model accelerated
|
||||
* by the Tensor G5 TPU through AICore. Zero setup on supported Pixel devices.
|
||||
* 2. **MediaPipe LLM** — for custom open-weight models (Gemma 2B/3n, etc.)
|
||||
* that you supply yourself. Place the .bin/.task file in the app's
|
||||
* files directory.
|
||||
* placed in the app's files directory.
|
||||
*
|
||||
* The factory method tries Gemini Nano first (preferred), then falls back
|
||||
* to MediaPipe if a local model file is found.
|
||||
* All inference is fully on-device. No data leaves the phone.
|
||||
*/
|
||||
interface OnDeviceModel {
|
||||
val backendName: String
|
||||
@@ -25,7 +22,7 @@ interface OnDeviceModel {
|
||||
|
||||
suspend fun generate(
|
||||
prompt: String,
|
||||
maxTokens: Int = 1024,
|
||||
maxTokens: Int = 8192,
|
||||
temperature: Float = 0.7f
|
||||
): String
|
||||
|
||||
@@ -34,45 +31,123 @@ interface OnDeviceModel {
|
||||
onToken: (String) -> Unit
|
||||
): String
|
||||
|
||||
/**
|
||||
* Multi-turn conversation with optional tool/function calling.
|
||||
*
|
||||
* Accepts the full message history and an optional list of tools.
|
||||
* Returns a [ChatResult] which is either a text reply or a tool invocation.
|
||||
*
|
||||
* Default implementation flattens the conversation to a prompt and calls
|
||||
* [generate], so all backends work transparently.
|
||||
*/
|
||||
suspend fun chat(
|
||||
messages: List<ConvMessage>,
|
||||
tools: List<ToolDef> = emptyList(),
|
||||
maxTokens: Int = 8192,
|
||||
temperature: Float = 0.7f
|
||||
): ChatResult {
|
||||
val prompt = messages.joinToString("\n") { msg ->
|
||||
when (msg.role) {
|
||||
"system" -> "System: ${msg.content.orEmpty()}"
|
||||
"user" -> "User: ${msg.content.orEmpty()}"
|
||||
"assistant" -> "Assistant: ${msg.content.orEmpty()}"
|
||||
"tool" -> "Tool result: ${msg.content.orEmpty()}"
|
||||
else -> "${msg.role}: ${msg.content.orEmpty()}"
|
||||
}
|
||||
} + "\nAssistant:"
|
||||
return ChatResult(content = generate(prompt, maxTokens, temperature))
|
||||
}
|
||||
|
||||
fun close()
|
||||
|
||||
// ── Supporting types ──────────────────────────────────────────────────────
|
||||
|
||||
/** A single message in a multi-turn conversation passed to [chat]. */
|
||||
data class ConvMessage(
|
||||
val role: String,
|
||||
/** Text content — null when role=assistant and tool_calls is set. */
|
||||
val content: String?,
|
||||
val toolCalls: List<ToolCallData>? = null,
|
||||
/** For role=tool messages: the tool_call id being responded to. */
|
||||
val toolCallId: String? = null,
|
||||
/** For role=tool messages: the function name. */
|
||||
val toolName: String? = null
|
||||
)
|
||||
|
||||
/** A tool/function definition passed to [chat]. */
|
||||
data class ToolDef(
|
||||
val name: String,
|
||||
val description: String,
|
||||
/** JSON Schema for the function parameters, as a raw JSON string. */
|
||||
val parametersJson: String?
|
||||
)
|
||||
|
||||
/** A tool call the model wants to make. */
|
||||
data class ToolCallData(
|
||||
val id: String,
|
||||
val name: String,
|
||||
/** Arguments as a JSON-encoded string. */
|
||||
val argsJson: String
|
||||
)
|
||||
|
||||
/** Result from [chat]. Exactly one of content/toolCalls will be non-null. */
|
||||
data class ChatResult(
|
||||
val content: String? = null,
|
||||
val toolCalls: List<ToolCallData>? = null,
|
||||
val finishReason: String = if (toolCalls != null) "tool_calls" else "stop"
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "OnDeviceModel"
|
||||
|
||||
/**
|
||||
* Create the best available on-device model.
|
||||
* Tries Gemini Nano (AICore) first, falls back to MediaPipe.
|
||||
*
|
||||
* Priority order:
|
||||
* 1. MediaPipe (local model file) — runs in background, uses Tensor G5 GPU.
|
||||
* This is the preferred backend: no foreground restriction, no AICore dep.
|
||||
* 2. Gemini Nano (ML Kit) — foreground only (ErrorCode 30 in background).
|
||||
* Used as fallback when no MediaPipe model file is present.
|
||||
*
|
||||
* Tap "Download Model" in the app UI to get the MediaPipe model automatically.
|
||||
*/
|
||||
suspend fun create(context: Context): OnDeviceModel = withContext(Dispatchers.IO) {
|
||||
// Try Gemini Nano via ML Kit Prompt API first
|
||||
// LiteRT-LM first — Gemma 3n .litertlm format, GPU-accelerated, background-safe
|
||||
try {
|
||||
Log.i(TAG, "Attempting Gemini Nano via ML Kit Prompt API...")
|
||||
Log.i(TAG, "Attempting LiteRT-LM with local .litertlm model...")
|
||||
val litert = LiteRTModel.create(context)
|
||||
Log.i(TAG, "LiteRT-LM model ready: ${litert.backendName}")
|
||||
return@withContext litert
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "LiteRT-LM not available: ${e.message}")
|
||||
}
|
||||
|
||||
// MediaPipe fallback — .task/.bin format, background-safe
|
||||
try {
|
||||
Log.i(TAG, "Attempting MediaPipe LLM with local .task model...")
|
||||
val mediapipe = MediaPipeModel.create(context)
|
||||
Log.i(TAG, "MediaPipe model ready: ${mediapipe.backendName}")
|
||||
return@withContext mediapipe
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "MediaPipe not available: ${e.message}")
|
||||
}
|
||||
|
||||
// Gemini Nano last resort — foreground only
|
||||
try {
|
||||
Log.i(TAG, "Attempting Gemini Nano via ML Kit (foreground only)...")
|
||||
val nano = GeminiNanoModel.create(context)
|
||||
Log.i(TAG, "Gemini Nano ready!")
|
||||
Log.i(TAG, "Gemini Nano ready (foreground only)")
|
||||
return@withContext nano
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Gemini Nano not available: ${e.message}")
|
||||
}
|
||||
|
||||
// Fall back to MediaPipe with a local model file
|
||||
try {
|
||||
Log.i(TAG, "Attempting MediaPipe LLM with local model...")
|
||||
val mediapipe = MediaPipeModel.create(context)
|
||||
Log.i(TAG, "MediaPipe model ready!")
|
||||
return@withContext mediapipe
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "MediaPipe model not available: ${e.message}")
|
||||
}
|
||||
|
||||
throw InferenceException(
|
||||
"No AI model available.\n\n" +
|
||||
"Option 1: Use a Pixel device with Gemini Nano support " +
|
||||
"(Pixel 10/9/8 series)\n\n" +
|
||||
"Option 2: Place a MediaPipe-compatible model (.bin or .task) in:\n" +
|
||||
" ${context.filesDir.absolutePath}/\n" +
|
||||
" Supported: gemma-3n-E2B.task, gemma-2b-it-gpu-int4.bin, etc.\n\n" +
|
||||
"Download models from:\n" +
|
||||
" https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android"
|
||||
"No model loaded yet.\n\n" +
|
||||
"Tap 'Download Model' in the app to download Gemma 3n E4B.\n" +
|
||||
"Once downloaded the server works fully in the background.\n\n" +
|
||||
"Or place a .litertlm file in:\n" +
|
||||
" ${context.filesDir.absolutePath}/"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,16 +13,30 @@ import java.util.concurrent.atomic.AtomicLong
|
||||
* Embedded HTTP server that exposes the on-device AI model as a REST API.
|
||||
*
|
||||
* Provides OpenAI-compatible endpoints so existing tools (curl, Python openai
|
||||
* library, etc.) can talk to this phone as if it were a cloud AI endpoint.
|
||||
* library, OpenClaw, Open WebUI, etc.) can talk to this phone as if it were a
|
||||
* cloud AI endpoint.
|
||||
*
|
||||
* Supported features:
|
||||
* - Multi-turn chat (full message history forwarded to the model)
|
||||
* - Tool / function calling (agents can invoke tools and receive results)
|
||||
* - Streaming (SSE)
|
||||
* - Thinking mode (extended reasoning via Gemini 2.5 Flash)
|
||||
*
|
||||
* Usage from any device on the same network:
|
||||
* curl http://<phone-ip>:8080/v1/chat/completions \
|
||||
* -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 model: OnDeviceModel,
|
||||
private val config: ServerConfig = ServerConfig()
|
||||
) : NanoHTTPD(port) {
|
||||
|
||||
private val gson = Gson()
|
||||
@@ -30,6 +44,7 @@ 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
|
||||
@@ -39,11 +54,11 @@ class AIApiServer(
|
||||
log("[$count] ${method.name} $uri")
|
||||
|
||||
return try {
|
||||
// Add CORS headers to all responses
|
||||
when {
|
||||
method == Method.OPTIONS -> corsPreflightResponse()
|
||||
uri == "/" || uri == "/health" -> handleHealth()
|
||||
uri == "/v1/models" && method == Method.GET -> handleModels()
|
||||
uri == "/v1/agent" && method == Method.GET -> handleAgentConfig()
|
||||
uri == "/v1/chat/completions" && method == Method.POST -> handleChatCompletions(session)
|
||||
uri == "/v1/completions" && method == Method.POST -> handleCompletions(session)
|
||||
else -> errorResponse(404, "Not found: $uri")
|
||||
@@ -56,7 +71,7 @@ class AIApiServer(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Endpoint Handlers ──────────────────────────────────────────────
|
||||
// ── Endpoint Handlers ──────────────────────────────────────────────────────
|
||||
|
||||
private fun handleHealth(): Response {
|
||||
val status = ServerStatus(
|
||||
@@ -73,45 +88,131 @@ class AIApiServer(
|
||||
return jsonResponse(200, gson.toJson(ModelList()))
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /v1/agent
|
||||
*
|
||||
* Returns the recommended system prompt and tool definitions for using this
|
||||
* server as a coding agent brain. Clients (OpenClaw, Open WebUI, etc.) can
|
||||
* fetch this once and inject it into every conversation automatically.
|
||||
*
|
||||
* Example:
|
||||
* curl http://phone:8080/v1/agent | jq .system_prompt
|
||||
*/
|
||||
private fun handleAgentConfig(): Response {
|
||||
val config = mapOf(
|
||||
"system_prompt" to AgentConfig.SYSTEM_PROMPT,
|
||||
"tools" to AgentConfig.DEFAULT_TOOLS,
|
||||
"model" to "pixel10",
|
||||
"notes" to mapOf(
|
||||
"context_window" to "~32K tokens input",
|
||||
"max_output_tokens" to 1024,
|
||||
"tip" to "Keep each task small and focused. One file change per tool call. " +
|
||||
"Use patch_file for edits, write_file for new files only."
|
||||
)
|
||||
)
|
||||
return jsonResponse(200, gson.toJson(config))
|
||||
}
|
||||
|
||||
private fun handleChatCompletions(session: IHTTPSession): Response {
|
||||
val body = readBody(session)
|
||||
val request = gson.fromJson(body, ChatRequest::class.java)
|
||||
val raw = gson.fromJson(body, ChatRequest::class.java)
|
||||
|
||||
if (request.messages.isEmpty()) {
|
||||
if (raw.messages.isEmpty()) {
|
||||
return errorResponse(400, "messages array is required and must not be empty")
|
||||
}
|
||||
|
||||
// Build a prompt from the chat messages
|
||||
val prompt = buildChatPrompt(request.messages)
|
||||
// Auto-inject agent system prompt if enabled and no system message present
|
||||
val messages = if (config.autoSystemPrompt && 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
|
||||
)
|
||||
|
||||
log("Chat prompt (${request.messages.size} messages, ${prompt.length} chars)")
|
||||
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) {
|
||||
return handleStreamingResponse(prompt, request)
|
||||
val prompt = buildFlatPrompt(request.messages)
|
||||
onActiveRequest?.invoke(true)
|
||||
return try {
|
||||
handleStreamingResponse(id, prompt, request)
|
||||
} finally {
|
||||
onActiveRequest?.invoke(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
}
|
||||
|
||||
if (result.toolCalls != null) {
|
||||
// Model wants to call tools — return tool_calls in the assistant message
|
||||
log("Tool calls: ${result.toolCalls.joinToString { it.name }}")
|
||||
val assistantMsg = Message(
|
||||
role = "assistant",
|
||||
content = null,
|
||||
tool_calls = result.toolCalls.map { tc ->
|
||||
ToolCall(
|
||||
id = tc.id,
|
||||
function = FunctionCallDetail(name = tc.name, arguments = tc.argsJson)
|
||||
)
|
||||
}
|
||||
)
|
||||
return jsonResponse(200, gson.toJson(ChatResponse(
|
||||
id = id, model = request.model,
|
||||
choices = listOf(Choice(
|
||||
message = assistantMsg,
|
||||
finish_reason = "tool_calls"
|
||||
)),
|
||||
usage = buildUsage("", "")
|
||||
)))
|
||||
}
|
||||
|
||||
val responseText = result.content ?: ""
|
||||
log("Response: ${responseText.take(80)}...")
|
||||
return jsonResponse(200, gson.toJson(ChatResponse(
|
||||
id = id, model = request.model,
|
||||
choices = listOf(Choice(message = Message(role = "assistant", content = responseText))),
|
||||
usage = buildUsage(buildFlatPrompt(request.messages), responseText)
|
||||
)))
|
||||
}
|
||||
|
||||
// ── Simple single-turn (fast path) ─────────────────────────────────────
|
||||
val prompt = buildFlatPrompt(request.messages)
|
||||
|
||||
if (request.stream) {
|
||||
return handleStreamingResponse(id, prompt, request)
|
||||
}
|
||||
|
||||
// Synchronous generation
|
||||
val responseText = runBlocking {
|
||||
model.generate(prompt, request.max_tokens, request.temperature)
|
||||
}
|
||||
|
||||
log("Response: ${responseText.take(80)}...")
|
||||
|
||||
val chatResponse = ChatResponse(
|
||||
id = "chatcmpl-${UUID.randomUUID().toString().take(8)}",
|
||||
choices = listOf(
|
||||
Choice(
|
||||
message = Message(role = "assistant", content = responseText)
|
||||
)
|
||||
),
|
||||
usage = Usage(
|
||||
prompt_tokens = estimateTokens(prompt),
|
||||
completion_tokens = estimateTokens(responseText),
|
||||
total_tokens = estimateTokens(prompt) + estimateTokens(responseText)
|
||||
)
|
||||
)
|
||||
|
||||
return jsonResponse(200, gson.toJson(chatResponse))
|
||||
return jsonResponse(200, gson.toJson(ChatResponse(
|
||||
id = id, model = request.model,
|
||||
choices = listOf(Choice(message = Message(role = "assistant", content = responseText))),
|
||||
usage = buildUsage(prompt, responseText)
|
||||
)))
|
||||
}
|
||||
|
||||
private fun handleCompletions(session: IHTTPSession): Response {
|
||||
@@ -127,37 +228,21 @@ class AIApiServer(
|
||||
val responseText = runBlocking {
|
||||
model.generate(prompt, request.max_tokens, request.temperature)
|
||||
}
|
||||
|
||||
log("Response: ${responseText.take(80)}...")
|
||||
|
||||
val chatResponse = ChatResponse(
|
||||
return jsonResponse(200, gson.toJson(ChatResponse(
|
||||
id = "cmpl-${UUID.randomUUID().toString().take(8)}",
|
||||
choices = listOf(
|
||||
Choice(
|
||||
message = Message(role = "assistant", content = responseText)
|
||||
)
|
||||
),
|
||||
usage = Usage(
|
||||
prompt_tokens = estimateTokens(prompt),
|
||||
completion_tokens = estimateTokens(responseText),
|
||||
total_tokens = estimateTokens(prompt) + estimateTokens(responseText)
|
||||
)
|
||||
)
|
||||
|
||||
return jsonResponse(200, gson.toJson(chatResponse))
|
||||
model = request.model,
|
||||
choices = listOf(Choice(message = Message(role = "assistant", content = responseText))),
|
||||
usage = buildUsage(prompt, responseText)
|
||||
)))
|
||||
}
|
||||
|
||||
private fun handleStreamingResponse(prompt: String, request: ChatRequest): Response {
|
||||
val id = "chatcmpl-${UUID.randomUUID().toString().take(8)}"
|
||||
|
||||
// For streaming, collect all tokens then return as SSE-formatted response.
|
||||
// NanoHTTPD doesn't natively support chunked streaming in a clean way,
|
||||
// so we buffer and return the full SSE payload.
|
||||
private fun handleStreamingResponse(id: String, prompt: String, request: ChatRequest): Response {
|
||||
val sseBuilder = StringBuilder()
|
||||
|
||||
// Initial role chunk
|
||||
val roleChunk = StreamChunk(
|
||||
id = id,
|
||||
id = id, model = request.model,
|
||||
choices = listOf(StreamChoice(delta = Delta(role = "assistant")))
|
||||
)
|
||||
sseBuilder.append("data: ${gson.toJson(roleChunk)}\n\n")
|
||||
@@ -165,16 +250,15 @@ class AIApiServer(
|
||||
val fullResponse = runBlocking {
|
||||
model.generateStreaming(prompt) { token ->
|
||||
val chunk = StreamChunk(
|
||||
id = id,
|
||||
id = id, model = request.model,
|
||||
choices = listOf(StreamChoice(delta = Delta(content = token)))
|
||||
)
|
||||
sseBuilder.append("data: ${gson.toJson(chunk)}\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Final done chunk
|
||||
val doneChunk = StreamChunk(
|
||||
id = id,
|
||||
id = id, model = request.model,
|
||||
choices = listOf(StreamChoice(delta = Delta(), finish_reason = "stop"))
|
||||
)
|
||||
sseBuilder.append("data: ${gson.toJson(doneChunk)}\n\n")
|
||||
@@ -189,21 +273,51 @@ class AIApiServer(
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
// ── Conversion helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private fun buildChatPrompt(messages: List<Message>): String {
|
||||
/** Flat prompt for simple / streaming calls (no tool use). */
|
||||
private fun buildFlatPrompt(messages: List<Message>): String {
|
||||
val sb = StringBuilder()
|
||||
for (msg in messages) {
|
||||
when (msg.role) {
|
||||
"system" -> sb.append("System: ${msg.content}\n\n")
|
||||
"user" -> sb.append("User: ${msg.content}\n\n")
|
||||
"assistant" -> sb.append("Assistant: ${msg.content}\n\n")
|
||||
"system" -> sb.append("System: ${msg.content.orEmpty()}\n\n")
|
||||
"user" -> sb.append("User: ${msg.content.orEmpty()}\n\n")
|
||||
"assistant" -> sb.append("Assistant: ${msg.content.orEmpty()}\n\n")
|
||||
}
|
||||
}
|
||||
sb.append("Assistant: ")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun Message.toConvMessage(): OnDeviceModel.ConvMessage =
|
||||
OnDeviceModel.ConvMessage(
|
||||
role = role,
|
||||
content = content,
|
||||
toolCalls = tool_calls?.map { tc ->
|
||||
OnDeviceModel.ToolCallData(
|
||||
id = tc.id,
|
||||
name = tc.function.name,
|
||||
argsJson = tc.function.arguments
|
||||
)
|
||||
},
|
||||
toolCallId = tool_call_id
|
||||
)
|
||||
|
||||
private fun Tool.toToolDef(): OnDeviceModel.ToolDef =
|
||||
OnDeviceModel.ToolDef(
|
||||
name = function.name,
|
||||
description = function.description,
|
||||
parametersJson = function.parameters?.toString()
|
||||
)
|
||||
|
||||
private fun buildUsage(prompt: String, response: String) = Usage(
|
||||
prompt_tokens = estimateTokens(prompt),
|
||||
completion_tokens = estimateTokens(response),
|
||||
total_tokens = estimateTokens(prompt) + estimateTokens(response)
|
||||
)
|
||||
|
||||
// ── Utilities ──────────────────────────────────────────────────────────────
|
||||
|
||||
private fun readBody(session: IHTTPSession): String {
|
||||
val contentLength = session.headers["content-length"]?.toIntOrNull() ?: 0
|
||||
val buffer = ByteArray(contentLength)
|
||||
@@ -211,10 +325,7 @@ class AIApiServer(
|
||||
return String(buffer)
|
||||
}
|
||||
|
||||
private fun estimateTokens(text: String): Int {
|
||||
// Rough estimate: ~4 characters per token
|
||||
return (text.length / 4).coerceAtLeast(1)
|
||||
}
|
||||
private fun estimateTokens(text: String): Int = (text.length / 4).coerceAtLeast(1)
|
||||
|
||||
private fun jsonResponse(statusCode: Int, json: String): Response {
|
||||
val status = when (statusCode) {
|
||||
@@ -226,16 +337,11 @@ class AIApiServer(
|
||||
return newFixedLengthResponse(status, "application/json", json)
|
||||
}
|
||||
|
||||
private fun errorResponse(statusCode: Int, message: String): Response {
|
||||
val error = ErrorResponse(
|
||||
ErrorDetail(message = message, code = statusCode)
|
||||
)
|
||||
return jsonResponse(statusCode, gson.toJson(error))
|
||||
}
|
||||
private fun errorResponse(statusCode: Int, message: String): Response =
|
||||
jsonResponse(statusCode, gson.toJson(ErrorResponse(ErrorDetail(message = message, code = statusCode))))
|
||||
|
||||
private fun corsPreflightResponse(): Response {
|
||||
return newFixedLengthResponse(Response.Status.OK, MIME_PLAINTEXT, "")
|
||||
}
|
||||
private fun corsPreflightResponse(): Response =
|
||||
newFixedLengthResponse(Response.Status.OK, MIME_PLAINTEXT, "")
|
||||
|
||||
private fun addCorsHeaders(response: Response) {
|
||||
response.addHeader("Access-Control-Allow-Origin", "*")
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.pixel10.ai.server
|
||||
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
|
||||
/**
|
||||
* Default agent configuration for the Pixel10 AI coding agent.
|
||||
*
|
||||
* Designed for Gemini Nano on the Tensor G5 chip — a small, fast,
|
||||
* fully on-device model. The system prompt and tool set are tuned to
|
||||
* work within the model's context window by keeping every turn focused
|
||||
* and minimal. No token is wasted.
|
||||
*
|
||||
* Tool use flow (OpenAI-compatible):
|
||||
* 1. Client sends messages (+ tools from this config)
|
||||
* 2. Server returns finish_reason=tool_calls with the tool to invoke
|
||||
* 3. Client executes the tool locally, appends result as role=tool message
|
||||
* 4. Client sends updated conversation back → repeat until task_done
|
||||
*/
|
||||
object AgentConfig {
|
||||
|
||||
// ── System Prompt ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Target: ≤ 700 tokens. Every token here costs context on every turn.
|
||||
// Written to get the most out of a small on-device model:
|
||||
// - Numbered rules (easy to follow for small models)
|
||||
// - Explicit workflow stages (reduces hallucination / aimless tool calls)
|
||||
// - Hard size limits on reads/writes (prevents context overflow)
|
||||
|
||||
const val SYSTEM_PROMPT = """You are a precise coding agent running on a Pixel 10's Tensor G5 chip.
|
||||
|
||||
## Constraints
|
||||
- One tool call per turn. Wait for the result before calling another.
|
||||
- Your text reply must be 1-3 sentences max. Let tools do the work.
|
||||
- Never output file contents in text — use read_file and write_file.
|
||||
- Never change more than 3 files per task. If more are needed, stop and ask.
|
||||
|
||||
## Workflow — follow in order every time
|
||||
1. EXPLORE : list_dir to map the structure. read_file with start_line/end_line to read only what is relevant (max 120 lines per read). search_code to locate symbols.
|
||||
2. PLAN : State in one sentence what you will change and why.
|
||||
3. CHANGE : Use patch_file to replace exact text (preferred). Use write_file only for new files or full rewrites under 80 lines.
|
||||
4. VERIFY : run_command to build or test after every change. Fix errors before continuing.
|
||||
5. DONE : Call task_done with a one-paragraph summary of every file changed.
|
||||
|
||||
## Rules
|
||||
- Always read a file before modifying it.
|
||||
- When reading large files, use start_line/end_line — never load the whole file.
|
||||
- Use search_code before reading to find the exact lines you need.
|
||||
- patch_file is preferred over write_file: specify the exact text to replace.
|
||||
- If a command fails, read the error, fix the cause, retry once. If it fails again, call task_done with the error and what you tried."""
|
||||
|
||||
// ── Tool Definitions ───────────────────────────────────────────────────────
|
||||
//
|
||||
// 7 tools covering the full coding agent surface.
|
||||
// Descriptions are kept short — they repeat on every request turn.
|
||||
|
||||
val DEFAULT_TOOLS: List<Tool> = listOf(
|
||||
|
||||
tool(
|
||||
name = "read_file",
|
||||
description = "Read a file. Use start_line/end_line to read a section (max 120 lines). Always prefer sections over full files.",
|
||||
properties = mapOf(
|
||||
"path" to strProp("Absolute or workspace-relative file path"),
|
||||
"start_line" to intProp("First line to read, 1-indexed (optional)"),
|
||||
"end_line" to intProp("Last line to read, 1-indexed (optional)")
|
||||
),
|
||||
required = listOf("path")
|
||||
),
|
||||
|
||||
tool(
|
||||
name = "write_file",
|
||||
description = "Create a new file or fully overwrite an existing one. Use only for new files or complete rewrites under 80 lines. Prefer patch_file for edits.",
|
||||
properties = mapOf(
|
||||
"path" to strProp("File path to write"),
|
||||
"content" to strProp("Full file content to write")
|
||||
),
|
||||
required = listOf("path", "content")
|
||||
),
|
||||
|
||||
tool(
|
||||
name = "patch_file",
|
||||
description = "Replace an exact string inside a file. Preferred for edits — avoids rewriting the whole file. old_str must match exactly including whitespace.",
|
||||
properties = mapOf(
|
||||
"path" to strProp("File path to patch"),
|
||||
"old_str" to strProp("Exact text to find and replace (must match exactly)"),
|
||||
"new_str" to strProp("Replacement text")
|
||||
),
|
||||
required = listOf("path", "old_str", "new_str")
|
||||
),
|
||||
|
||||
tool(
|
||||
name = "list_dir",
|
||||
description = "List files and directories at a path. Use depth=1 for a flat listing, depth=2 to include one level of subdirectories.",
|
||||
properties = mapOf(
|
||||
"path" to strProp("Directory path to list"),
|
||||
"depth" to intProp("Max depth: 1 (flat) or 2 (with subdirs). Default 1.")
|
||||
),
|
||||
required = listOf("path")
|
||||
),
|
||||
|
||||
tool(
|
||||
name = "search_code",
|
||||
description = "Search for a regex pattern in files. Returns matching lines with file path and line number. Use this before read_file to find exactly which lines to read.",
|
||||
properties = mapOf(
|
||||
"pattern" to strProp("Regex pattern to search for"),
|
||||
"path" to strProp("Directory or file to search in (default: workspace root)"),
|
||||
"include" to strProp("Glob filter, e.g. '*.kt' or '*.py' (optional)")
|
||||
),
|
||||
required = listOf("pattern")
|
||||
),
|
||||
|
||||
tool(
|
||||
name = "run_command",
|
||||
description = "Run a shell command and return stdout+stderr. Use for build, test, lint, install. Keep commands short and targeted.",
|
||||
properties = mapOf(
|
||||
"command" to strProp("Shell command to execute"),
|
||||
"cwd" to strProp("Working directory (optional, defaults to workspace root)")
|
||||
),
|
||||
required = listOf("command")
|
||||
),
|
||||
|
||||
tool(
|
||||
name = "task_done",
|
||||
description = "Signal that the task is fully complete. Call this as the final action — never leave a task without calling it.",
|
||||
properties = mapOf(
|
||||
"summary" to strProp("One paragraph describing what was changed and why"),
|
||||
"files_changed" to strProp("Comma-separated list of files that were modified or created")
|
||||
),
|
||||
required = listOf("summary")
|
||||
)
|
||||
)
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
private fun tool(
|
||||
name: String,
|
||||
description: String,
|
||||
properties: Map<String, JsonObject>,
|
||||
required: List<String> = emptyList()
|
||||
): Tool {
|
||||
val params = JsonObject().apply {
|
||||
addProperty("type", "object")
|
||||
add("properties", JsonObject().apply {
|
||||
properties.forEach { (k, v) -> add(k, v) }
|
||||
})
|
||||
if (required.isNotEmpty()) {
|
||||
add("required", JsonArray().apply { required.forEach { add(it) } })
|
||||
}
|
||||
}
|
||||
return Tool(function = ToolFunction(name = name, description = description, parameters = params))
|
||||
}
|
||||
|
||||
private fun strProp(description: String) = JsonObject().apply {
|
||||
addProperty("type", "string")
|
||||
addProperty("description", description)
|
||||
}
|
||||
|
||||
private fun intProp(description: String) = JsonObject().apply {
|
||||
addProperty("type", "integer")
|
||||
addProperty("description", description)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,76 @@
|
||||
package com.pixel10.ai.server
|
||||
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* Request/response models for the AI API.
|
||||
* Follows an OpenAI-compatible schema for easy integration.
|
||||
* Follows the OpenAI Chat Completions schema for easy integration with
|
||||
* any OpenAI-compatible client (OpenClaw, LM Studio, Open WebUI, etc.).
|
||||
*
|
||||
* Tool/function calling is fully supported so coding agents can invoke
|
||||
* tools (read_file, run_shell, etc.) through the standard OpenAI tool-use flow.
|
||||
*/
|
||||
|
||||
// ── Requests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
data class ChatRequest(
|
||||
val model: String = "pixel10",
|
||||
val messages: List<Message> = emptyList(),
|
||||
val prompt: String? = null,
|
||||
val max_tokens: Int = 1024,
|
||||
val max_tokens: Int = 8192,
|
||||
val temperature: Float = 0.7f,
|
||||
val stream: Boolean = false
|
||||
val stream: Boolean = false,
|
||||
/** Tool/function definitions available to the model. */
|
||||
val tools: List<Tool>? = null,
|
||||
/** "auto" | "none" | "required" — defaults to "auto" when tools are provided. */
|
||||
val tool_choice: String? = null
|
||||
)
|
||||
|
||||
data class Message(
|
||||
val role: String = "user",
|
||||
val content: String = ""
|
||||
/** Text content. Null when role=assistant and the model is calling a tool. */
|
||||
val content: String? = null,
|
||||
/** Set by the model when it wants to call one or more tools. */
|
||||
val tool_calls: List<ToolCall>? = null,
|
||||
/** Set on role=tool messages — references the tool_call.id being responded to. */
|
||||
val tool_call_id: String? = null
|
||||
)
|
||||
|
||||
// ── Tool / Function Calling ───────────────────────────────────────────────────
|
||||
|
||||
data class Tool(
|
||||
val type: String = "function",
|
||||
val function: ToolFunction
|
||||
)
|
||||
|
||||
data class ToolFunction(
|
||||
val name: String,
|
||||
val description: String = "",
|
||||
/** JSON Schema object describing the function parameters. */
|
||||
val parameters: JsonObject? = null
|
||||
)
|
||||
|
||||
data class ToolCall(
|
||||
val id: String,
|
||||
val type: String = "function",
|
||||
val function: FunctionCallDetail
|
||||
)
|
||||
|
||||
data class FunctionCallDetail(
|
||||
val name: String,
|
||||
/** Arguments as a JSON-encoded string (matches OpenAI spec). */
|
||||
val arguments: String
|
||||
)
|
||||
|
||||
// ── Responses ─────────────────────────────────────────────────────────────────
|
||||
|
||||
data class ChatResponse(
|
||||
val id: String,
|
||||
@SerializedName("object")
|
||||
val objectType: String = "chat.completion",
|
||||
val created: Long = System.currentTimeMillis() / 1000,
|
||||
val model: String = "pixel10-on-device",
|
||||
val model: String = "pixel10",
|
||||
val choices: List<Choice>,
|
||||
val usage: Usage
|
||||
)
|
||||
@@ -33,6 +78,7 @@ data class ChatResponse(
|
||||
data class Choice(
|
||||
val index: Int = 0,
|
||||
val message: Message,
|
||||
/** "stop" | "tool_calls" | "length" */
|
||||
val finish_reason: String = "stop"
|
||||
)
|
||||
|
||||
@@ -42,12 +88,14 @@ data class Usage(
|
||||
val total_tokens: Int
|
||||
)
|
||||
|
||||
// ── Streaming ─────────────────────────────────────────────────────────────────
|
||||
|
||||
data class StreamChunk(
|
||||
val id: String,
|
||||
@SerializedName("object")
|
||||
val objectType: String = "chat.completion.chunk",
|
||||
val created: Long = System.currentTimeMillis() / 1000,
|
||||
val model: String = "pixel10-on-device",
|
||||
val model: String = "pixel10-fast",
|
||||
val choices: List<StreamChoice>
|
||||
)
|
||||
|
||||
@@ -62,20 +110,35 @@ data class Delta(
|
||||
val content: String? = null
|
||||
)
|
||||
|
||||
// ── Models List ───────────────────────────────────────────────────────────────
|
||||
|
||||
data class ModelInfo(
|
||||
val id: String = "pixel10-on-device",
|
||||
val id: String,
|
||||
@SerializedName("object")
|
||||
val objectType: String = "model",
|
||||
val owned_by: String = "local-device",
|
||||
val description: String = "On-device AI model running on Pixel 10 Tensor G5 chip"
|
||||
val description: String = "",
|
||||
/** Input context window in tokens. */
|
||||
val context_length: Int = 1_000_000,
|
||||
/** Maximum output tokens. */
|
||||
val max_output_tokens: Int = 8192
|
||||
)
|
||||
|
||||
data class ModelList(
|
||||
@SerializedName("object")
|
||||
val objectType: String = "list",
|
||||
val data: List<ModelInfo> = listOf(ModelInfo())
|
||||
val data: List<ModelInfo> = listOf(
|
||||
ModelInfo(
|
||||
id = "pixel10",
|
||||
description = "Gemini Nano on Tensor G5 — fully on-device, private, tool calling supported.",
|
||||
context_length = 4096,
|
||||
max_output_tokens = 1024
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// ── Health / Errors ───────────────────────────────────────────────────────────
|
||||
|
||||
data class ErrorResponse(
|
||||
val error: ErrorDetail
|
||||
)
|
||||
@@ -93,9 +156,10 @@ data class ServerStatus(
|
||||
val uptime_seconds: Long,
|
||||
val requests_served: Long,
|
||||
val endpoints: List<String> = listOf(
|
||||
"POST /v1/chat/completions",
|
||||
"POST /v1/chat/completions (tool calling + streaming)",
|
||||
"POST /v1/completions",
|
||||
"GET /v1/models",
|
||||
"GET /v1/agent (system prompt + tool definitions)",
|
||||
"GET /health",
|
||||
"GET /"
|
||||
)
|
||||
|
||||
@@ -34,9 +34,11 @@ 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
|
||||
@@ -64,15 +66,21 @@ class ApiServerService : Service() {
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
// Load the on-device AI model
|
||||
notifyLog("Loading AI model...")
|
||||
notifyLog("Loading on-device AI model...")
|
||||
model = OnDeviceModel.create(applicationContext)
|
||||
notifyLog("Model ready: ${model!!.backendName}")
|
||||
|
||||
// Start the HTTP server
|
||||
notifyLog("Starting API server on port $port...")
|
||||
val apiServer = AIApiServer(port, model!!)
|
||||
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)
|
||||
apiServer.onRequestLogged = { msg -> notifyLog(msg) }
|
||||
apiServer.onActiveRequest = { active -> onActiveRequest?.invoke(active) }
|
||||
apiServer.start()
|
||||
server = apiServer
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.pixel10.ai.ui
|
||||
|
||||
data class ChatMessage(val role: String, var content: String)
|
||||
@@ -5,6 +5,7 @@ import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.net.wifi.WifiManager
|
||||
@@ -12,12 +13,17 @@ 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
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.pixel10.ai.R
|
||||
import com.pixel10.ai.databinding.ActivityMainBinding
|
||||
import com.pixel10.ai.inference.ModelDownloader
|
||||
import com.pixel10.ai.inference.ModelDownloader.ModelSpec
|
||||
import com.pixel10.ai.server.ApiServerService
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
@@ -25,8 +31,10 @@ import java.util.Locale
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private lateinit var prefs: SharedPreferences
|
||||
private var service: ApiServerService? = null
|
||||
private var bound = false
|
||||
private var downloading = false
|
||||
|
||||
private val logBuffer = StringBuilder()
|
||||
|
||||
@@ -46,6 +54,16 @@ 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)
|
||||
@@ -63,23 +81,56 @@ class MainActivity : AppCompatActivity() {
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
prefs = getSharedPreferences("pixel10_prefs", MODE_PRIVATE)
|
||||
requestNotificationPermission()
|
||||
|
||||
binding.btnToggle.setOnClickListener {
|
||||
if (service?.isRunning == true) {
|
||||
stopServer()
|
||||
} else {
|
||||
startServer()
|
||||
// Restore saved settings
|
||||
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)
|
||||
}
|
||||
binding.btnDownloadGemma3Q8.setOnClickListener {
|
||||
saveHfToken()
|
||||
startModelDownload(ModelSpec.GEMMA_3N_E4B_WEB)
|
||||
}
|
||||
|
||||
updateModelCard()
|
||||
updateStatus(ApiServerService.ServerState.STOPPED)
|
||||
appendLog("Pixel10 AI Server ready")
|
||||
appendLog("Device: ${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
appendLog("SoC: ${Build.SOC_MODEL}")
|
||||
appendLog("Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
|
||||
appendLog("")
|
||||
appendLog("Tap 'Start Server' to begin serving AI inference")
|
||||
if (ModelDownloader.isModelPresent(this)) {
|
||||
appendLog("Model ready — server works in background")
|
||||
} else {
|
||||
appendLog("No local model found")
|
||||
appendLog("Tap 'Download Model' to enable background inference")
|
||||
appendLog("(Without it, Gemini Nano only works in foreground)")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
@@ -110,16 +161,90 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun startServer() {
|
||||
val port = binding.etPort.text.toString().toIntOrNull() ?: 8080
|
||||
private fun saveHfToken() {
|
||||
val token = binding.etHfToken.text.toString().trim()
|
||||
prefs.edit().putString("hf_token", token).apply()
|
||||
}
|
||||
|
||||
private fun startModelDownload(spec: ModelSpec) {
|
||||
if (downloading) return
|
||||
val token = binding.etHfToken.text.toString().trim()
|
||||
if (token.isBlank()) {
|
||||
binding.tvModelDownloadStatus.text = "Enter your HuggingFace token first"
|
||||
return
|
||||
}
|
||||
downloading = true
|
||||
setDownloadButtonsEnabled(false)
|
||||
binding.progressDownload.visibility = View.VISIBLE
|
||||
binding.tvModelDownloadStatus.text = "Starting download: ${spec.displayName}…"
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
ModelDownloader.download(this@MainActivity, spec, token) { progress ->
|
||||
runOnUiThread {
|
||||
binding.progressDownload.progress = progress.percent
|
||||
val mb = progress.downloadedBytes / 1_048_576
|
||||
val total = progress.totalBytes / 1_048_576
|
||||
binding.tvModelDownloadStatus.text =
|
||||
"${spec.displayName}: ${mb}MB / ${total}MB (${progress.percent}%)"
|
||||
}
|
||||
}
|
||||
runOnUiThread {
|
||||
downloading = false
|
||||
updateModelCard()
|
||||
appendLog("${spec.displayName} downloaded — background inference enabled")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
runOnUiThread {
|
||||
downloading = false
|
||||
setDownloadButtonsEnabled(true)
|
||||
binding.progressDownload.visibility = View.GONE
|
||||
binding.tvModelDownloadStatus.text = "Download failed: ${e.message}"
|
||||
appendLog("Download error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setDownloadButtonsEnabled(enabled: Boolean) {
|
||||
binding.btnDownloadModel.isEnabled = enabled
|
||||
binding.btnDownloadGemma3Q8.isEnabled = enabled
|
||||
}
|
||||
|
||||
private fun updateModelCard() {
|
||||
val spec = ModelDownloader.installedSpec(this)
|
||||
if (spec != null) {
|
||||
binding.tvModelDownloadStatus.text = getString(R.string.model_downloaded, spec.displayName)
|
||||
binding.etHfToken.visibility = View.GONE
|
||||
binding.btnDownloadModel.visibility = View.GONE
|
||||
binding.btnDownloadGemma3Q8.visibility = View.GONE
|
||||
binding.progressDownload.visibility = View.GONE
|
||||
} else {
|
||||
binding.tvModelDownloadStatus.text = getString(R.string.model_not_downloaded)
|
||||
binding.etHfToken.visibility = View.VISIBLE
|
||||
binding.btnDownloadModel.visibility = View.VISIBLE
|
||||
binding.btnDownloadGemma3Q8.visibility = View.VISIBLE
|
||||
setDownloadButtonsEnabled(true)
|
||||
binding.progressDownload.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
putExtra(ApiServerService.EXTRA_PORT, port)
|
||||
}
|
||||
startForegroundService(intent)
|
||||
|
||||
// Bind if not already bound
|
||||
if (!bound) {
|
||||
bindService(
|
||||
Intent(this, ApiServerService::class.java),
|
||||
@@ -127,7 +252,6 @@ class MainActivity : AppCompatActivity() {
|
||||
Context.BIND_AUTO_CREATE
|
||||
)
|
||||
}
|
||||
|
||||
updateStatus(ApiServerService.ServerState.LOADING_MODEL)
|
||||
}
|
||||
|
||||
@@ -137,6 +261,13 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun updateStatus(state: ApiServerService.ServerState) {
|
||||
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 -> {
|
||||
binding.tvServerStatus.text = getString(R.string.server_status_stopped)
|
||||
@@ -145,14 +276,13 @@ class MainActivity : AppCompatActivity() {
|
||||
binding.tvModelStatus.text = "Model: not loaded"
|
||||
binding.btnToggle.text = getString(R.string.btn_start)
|
||||
binding.btnToggle.isEnabled = true
|
||||
binding.etPort.isEnabled = true
|
||||
binding.btnChat.isEnabled = false
|
||||
}
|
||||
ApiServerService.ServerState.LOADING_MODEL -> {
|
||||
binding.tvServerStatus.text = getString(R.string.server_status_starting)
|
||||
(binding.viewStatusDot.background as? GradientDrawable)?.setColor(getColor(R.color.primary))
|
||||
binding.tvModelStatus.text = getString(R.string.model_loading)
|
||||
binding.btnToggle.isEnabled = false
|
||||
binding.etPort.isEnabled = false
|
||||
}
|
||||
ApiServerService.ServerState.RUNNING -> {
|
||||
val port = binding.etPort.text.toString()
|
||||
@@ -163,7 +293,7 @@ class MainActivity : AppCompatActivity() {
|
||||
binding.tvModelStatus.text = getString(R.string.model_ready)
|
||||
binding.btnToggle.text = getString(R.string.btn_stop)
|
||||
binding.btnToggle.isEnabled = true
|
||||
binding.etPort.isEnabled = false
|
||||
binding.btnChat.isEnabled = true
|
||||
}
|
||||
ApiServerService.ServerState.ERROR -> {
|
||||
binding.tvServerStatus.text = getString(R.string.server_status_error)
|
||||
@@ -171,7 +301,6 @@ class MainActivity : AppCompatActivity() {
|
||||
binding.tvModelStatus.text = getString(R.string.model_error)
|
||||
binding.btnToggle.text = getString(R.string.btn_start)
|
||||
binding.btnToggle.isEnabled = true
|
||||
binding.etPort.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,16 +309,8 @@ class MainActivity : AppCompatActivity() {
|
||||
val timestamp = SimpleDateFormat("HH:mm:ss", Locale.US).format(Date())
|
||||
logBuffer.append("[$timestamp] $message\n")
|
||||
binding.tvLog.text = logBuffer.toString()
|
||||
|
||||
// Auto-scroll to bottom
|
||||
binding.scrollLog.post {
|
||||
binding.scrollLog.fullScroll(View.FOCUS_DOWN)
|
||||
}
|
||||
|
||||
// Update request count
|
||||
service?.let {
|
||||
binding.tvRequestCount.text = "Requests served: ${it.requestCount}"
|
||||
}
|
||||
binding.scrollLog.post { binding.scrollLog.fullScroll(View.FOCUS_DOWN) }
|
||||
service?.let { binding.tvRequestCount.text = "Requests served: ${it.requestCount}" }
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -202,7 +323,6 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
|
||||
// Fallback: iterate network interfaces
|
||||
try {
|
||||
val interfaces = java.net.NetworkInterface.getNetworkInterfaces()
|
||||
while (interfaces.hasMoreElements()) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- Gradient-style background: deep blue -->
|
||||
<path
|
||||
android:fillColor="#0D47A1"
|
||||
android:pathData="M0,0h108v108H0z" />
|
||||
|
||||
<!-- Subtle grid pattern for tech feel -->
|
||||
<path
|
||||
android:strokeColor="#1565C0"
|
||||
android:strokeWidth="0.3"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,18h108M0,36h108M0,54h108M0,72h108M0,90h108M18,0v108M36,0v108M54,0v108M72,0v108M90,0v108" />
|
||||
</vector>
|
||||
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- AI chip body — rounded square centered in safe zone -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M38,38h32c2.2,0 4,1.8 4,4v24c0,2.2 -1.8,4 -4,4H38c-2.2,0 -4,-1.8 -4,-4V42c0,-2.2 1.8,-4 4,-4z" />
|
||||
|
||||
<!-- Chip pins — top -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="2"
|
||||
android:pathData="M44,34v4M50,34v4M56,34v4M62,34v4" />
|
||||
|
||||
<!-- Chip pins — bottom -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="2"
|
||||
android:pathData="M44,70v4M50,70v4M56,70v4M62,70v4" />
|
||||
|
||||
<!-- Chip pins — left -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="2"
|
||||
android:pathData="M30,44h4M30,50h4M30,56h4M30,62h4" />
|
||||
|
||||
<!-- Chip pins — right -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="2"
|
||||
android:pathData="M74,44h4M74,50h4M74,56h4M74,62h4" />
|
||||
|
||||
<!-- Inner circuit — brain/AI pattern -->
|
||||
<path
|
||||
android:fillColor="#1A73E8"
|
||||
android:pathData="M46,46h16c1.1,0 2,0.9 2,2v12c0,1.1 -0.9,2 -2,2H46c-1.1,0 -2,-0.9 -2,-2V48c0,-1.1 0.9,-2 2,-2z" />
|
||||
|
||||
<!-- Neural network nodes -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M50,52a1.5,1.5 0,1 1,0 -0.01zM58,52a1.5,1.5 0,1 1,0 -0.01zM54,56a1.5,1.5 0,1 1,0 -0.01z" />
|
||||
|
||||
<!-- Neural connections -->
|
||||
<path
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="0.8"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M50,52L54,56M58,52L54,56M50,52L58,52" />
|
||||
|
||||
<!-- Signal waves — right side of chip (API/broadcast) -->
|
||||
<path
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="1.5"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M80,50a6,6 0,0 1,0 8" />
|
||||
<path
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="1.5"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M84,47a10,10 0,0 1,0 14" />
|
||||
</vector>
|
||||
@@ -0,0 +1,76 @@
|
||||
<?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>
|
||||
@@ -12,7 +12,7 @@
|
||||
android:id="@+id/tvTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Pixel10 AI Server"
|
||||
android:text="@string/app_title"
|
||||
android:textColor="@color/on_surface"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold"
|
||||
@@ -24,7 +24,7 @@
|
||||
android:id="@+id/tvSubtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="On-device AI inference API"
|
||||
android:text="@string/app_subtitle"
|
||||
android:textColor="@color/log_text"
|
||||
android:textSize="14sp"
|
||||
android:layout_marginTop="4dp"
|
||||
@@ -78,7 +78,7 @@
|
||||
android:id="@+id/tvServerUrl"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="http://—"
|
||||
android:text="@string/server_url_placeholder"
|
||||
android:textColor="@color/log_text"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace"
|
||||
@@ -89,7 +89,7 @@
|
||||
android:id="@+id/tvModelStatus"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Model: not loaded"
|
||||
android:text="@string/model_not_loaded"
|
||||
android:textColor="@color/log_text"
|
||||
android:textSize="13sp"
|
||||
android:layout_marginTop="4dp" />
|
||||
@@ -102,66 +102,272 @@
|
||||
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>
|
||||
|
||||
<!-- Port Config -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutPort"
|
||||
<!-- Model Download Card -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardModel"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginTop="12dp"
|
||||
app:cardBackgroundColor="@color/surface_variant"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="0dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/cardStatus"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Port:"
|
||||
android:textColor="@color/on_surface"
|
||||
android:textSize="16sp" />
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etPort"
|
||||
android:layout_width="100dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginStart="12dp"
|
||||
android:text="8080"
|
||||
android:inputType="number"
|
||||
android:textColor="@color/on_surface"
|
||||
android:backgroundTint="@color/primary"
|
||||
android:fontFamily="monospace"
|
||||
android:textSize="16sp"
|
||||
android:gravity="center" />
|
||||
</LinearLayout>
|
||||
<TextView
|
||||
android:id="@+id/tvModelDownloadStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/log_text"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<!-- Start/Stop Button -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnToggle"
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etHfToken"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:hint="@string/hf_token_hint"
|
||||
android:inputType="textPassword"
|
||||
android:textColor="@color/on_surface"
|
||||
android:textColorHint="@color/log_text"
|
||||
android:textSize="13sp"
|
||||
android:fontFamily="monospace"
|
||||
android:backgroundTint="@color/primary" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressDownload"
|
||||
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:max="100"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnDownloadModel"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/btn_download_gemma3_q4"
|
||||
android:textSize="13sp"
|
||||
app:cornerRadius="8dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnDownloadGemma3Q8"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/btn_download_gemma3_q8"
|
||||
android:textSize="13sp"
|
||||
app:cornerRadius="8dp" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Settings Card -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/layoutPort"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
app:cardBackgroundColor="@color/surface_variant"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="0dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/cardModel"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<!-- Port row -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<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"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
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" />
|
||||
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>
|
||||
|
||||
<!-- Log Output -->
|
||||
<TextView
|
||||
android:id="@+id/tvLogLabel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Request Log"
|
||||
android:text="@string/request_log_label"
|
||||
android:textColor="@color/on_surface"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginTop="20dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/btnToggle"
|
||||
app:layout_constraintTop_toBottomOf="@id/layoutButtons"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<ScrollView
|
||||
@@ -172,6 +378,7 @@
|
||||
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">
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/primary" />
|
||||
<foreground android:drawable="@color/on_primary" />
|
||||
</adaptive-icon>
|
||||
@@ -1,17 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Pixel10 AI Server</string>
|
||||
<string name="app_name">Pixel10 AI</string>
|
||||
<string name="app_title">Pixel10 AI Server</string>
|
||||
<string name="app_subtitle">On-device AI inference API</string>
|
||||
|
||||
<!-- Server status -->
|
||||
<string name="server_status_stopped">Server Stopped</string>
|
||||
<string name="server_status_starting">Starting Server…</string>
|
||||
<string name="server_status_starting">Starting Server\u2026</string>
|
||||
<string name="server_status_running">Server Running</string>
|
||||
<string name="server_status_error">Server Error</string>
|
||||
<string name="btn_start">Start Server</string>
|
||||
<string name="btn_stop">Stop Server</string>
|
||||
<string name="model_loading">Loading AI model…</string>
|
||||
<string name="server_url_placeholder">http://\u2014</string>
|
||||
|
||||
<!-- Model status -->
|
||||
<string name="model_loading">Loading AI model\u2026</string>
|
||||
<string name="model_ready">AI model ready</string>
|
||||
<string name="model_error">AI model failed to load</string>
|
||||
<string name="model_not_loaded">Model: not loaded</string>
|
||||
|
||||
<!-- Controls -->
|
||||
<string name="hf_token_hint">HuggingFace token (huggingface.co/settings/tokens)</string>
|
||||
<string name="btn_download_gemma3_q4">⭐ Gemma 3n E4B — Best quality (~4.9 GB)</string>
|
||||
<string name="btn_download_gemma3_q8">Gemma 3n E4B Web — Smaller (~4.3 GB)</string>
|
||||
<string name="model_downloaded">✓ %s ready — background inference enabled</string>
|
||||
<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_default">8080</string>
|
||||
<string name="requests_served">Requests served: %d</string>
|
||||
<string name="request_log_label">Request Log</string>
|
||||
|
||||
<!-- Notification -->
|
||||
<string name="notification_channel_name">AI Server</string>
|
||||
<string name="notification_channel_desc">Pixel10 AI API Server status</string>
|
||||
<string name="notification_channel_desc">Shows when the AI inference server is running</string>
|
||||
<string name="notification_title">Pixel10 AI Server</string>
|
||||
<string name="notification_text">Serving AI inference on port %d</string>
|
||||
<string name="notification_text">Serving on port %d \u2022 Tap to open</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user