From a36707c8aa66625102af496078423690078c9685 Mon Sep 17 00:00:00 2001 From: kdusek Date: Tue, 27 Jan 2026 02:34:21 +0100 Subject: [PATCH] Add admin docs and Android admin scaffold --- .gitignore | 5 + CHECKLIST.md | 12 +- README.md | 5 + android-admin/.gitignore | 10 ++ android-admin/README.md | 18 ++ android-admin/app/build.gradle.kts | 60 +++++++ .../app/src/main/AndroidManifest.xml | 21 +++ .../src/main/java/local/admin/MainActivity.kt | 21 +++ .../src/main/java/local/admin/ui/AdminApp.kt | 19 +++ .../java/local/admin/ui/CoroutineUtils.kt | 11 ++ .../main/java/local/admin/ui/DetailScreen.kt | 67 ++++++++ .../main/java/local/admin/ui/HomeScreen.kt | 154 ++++++++++++++++++ .../java/local/admin/ui/SettingsScreen.kt | 61 +++++++ android-admin/build.gradle.kts | 5 + android-admin/gradle.properties | 4 + .../gradle/wrapper/gradle-wrapper.properties | 5 + android-admin/gradlew | 25 +++ android-admin/gradlew.bat | 21 +++ android-admin/settings.gradle.kts | 18 ++ docs/ADMIN.md | 94 +++++++++++ docs/USER.md | 33 ++++ 21 files changed, 664 insertions(+), 5 deletions(-) create mode 100644 android-admin/.gitignore create mode 100644 android-admin/README.md create mode 100644 android-admin/app/build.gradle.kts create mode 100644 android-admin/app/src/main/AndroidManifest.xml create mode 100644 android-admin/app/src/main/java/local/admin/MainActivity.kt create mode 100644 android-admin/app/src/main/java/local/admin/ui/AdminApp.kt create mode 100644 android-admin/app/src/main/java/local/admin/ui/CoroutineUtils.kt create mode 100644 android-admin/app/src/main/java/local/admin/ui/DetailScreen.kt create mode 100644 android-admin/app/src/main/java/local/admin/ui/HomeScreen.kt create mode 100644 android-admin/app/src/main/java/local/admin/ui/SettingsScreen.kt create mode 100644 android-admin/build.gradle.kts create mode 100644 android-admin/gradle.properties create mode 100644 android-admin/gradle/wrapper/gradle-wrapper.properties create mode 100755 android-admin/gradlew create mode 100644 android-admin/gradlew.bat create mode 100644 android-admin/settings.gradle.kts create mode 100644 docs/ADMIN.md create mode 100644 docs/USER.md diff --git a/.gitignore b/.gitignore index 0209520..55a32dd 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,8 @@ data/ *.sqlite *.sqlite-wal *.sqlite-shm + +# Android +android-admin/.gradle/ +android-admin/**/build/ +android-admin/local.properties diff --git a/CHECKLIST.md b/CHECKLIST.md index ab525cf..9993ab9 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -6,8 +6,10 @@ ## Android admin app -- [ ] Kotlin + Compose app -- [ ] Store `ADMIN_TOKEN` securely (EncryptedSharedPreferences/Keystore) -- [ ] Logs list/search/detail using `/api/logs` + `/api/logs/:request_id` -- [ ] Stats views using `/api/stats/*` -- [ ] Base URL config for local network host (e.g. `http://192.168.88.2:8787`) +- [x] Kotlin + Compose app (project scaffold) +- [ ] Add gradle wrapper jar (or generate via Android Studio) +- [x] Store `ADMIN_TOKEN` securely (EncryptedSharedPreferences) +- [x] Logs list/search/detail using `/api/logs` + `/api/logs/:request_id` +- [x] Stats summary view using `/api/stats/summary` +- [x] Base URL config for local network host (e.g. `http://192.168.88.2:8787`) +- [ ] Timeseries + model stats screens diff --git a/README.md b/README.md index 120af35..9be6628 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,11 @@ export FIRMWARE_API_KEY=... Note: `.env` is loaded by `./run.sh` for non-secret config (like `ADMIN_TOKEN`, `PORT`). +## Docs + +- `docs/USER.md` +- `docs/ADMIN.md` + ## Run (dev) ```bash diff --git a/android-admin/.gitignore b/android-admin/.gitignore new file mode 100644 index 0000000..dd749d3 --- /dev/null +++ b/android-admin/.gitignore @@ -0,0 +1,10 @@ +.idea/ +.gradle/ +local.properties +*.iml +build/ +**/build/ +captures/ +.externalNativeBuild/ +.cxx/ +keystore.jks diff --git a/android-admin/README.md b/android-admin/README.md new file mode 100644 index 0000000..f7e447d --- /dev/null +++ b/android-admin/README.md @@ -0,0 +1,18 @@ +# Admin Android App + +Android (Kotlin + Jetpack Compose) admin client for the local-network Firmware Chat server. + +## Features + +- Configure `Base URL` (e.g. `http://192.168.88.2:8787`) +- Store `ADMIN_TOKEN` securely (EncryptedSharedPreferences) +- View/search logs, open log detail +- View stats summary + +## Build / Run + +Open `android-admin/` in Android Studio and run the `app` configuration. + +## Note + +This repo does not include `gradle-wrapper.jar` (Android Studio will generate/sync it). diff --git a/android-admin/app/build.gradle.kts b/android-admin/app/build.gradle.kts new file mode 100644 index 0000000..a52f2fb --- /dev/null +++ b/android-admin/app/build.gradle.kts @@ -0,0 +1,60 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.serialization") +} + +android { + namespace = "local.admin" + compileSdk = 34 + + defaultConfig { + applicationId = "local.admin" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.14" + } + + kotlinOptions { + jvmTarget = "17" + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.06.00") + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3") + implementation("androidx.activity:activity-compose:1.9.1") + + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + implementation("androidx.compose.material3:material3") + + implementation("androidx.navigation:navigation-compose:2.7.7") + + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + + implementation("androidx.security:security-crypto:1.1.0-alpha06") +} diff --git a/android-admin/app/src/main/AndroidManifest.xml b/android-admin/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5ce5102 --- /dev/null +++ b/android-admin/app/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + diff --git a/android-admin/app/src/main/java/local/admin/MainActivity.kt b/android-admin/app/src/main/java/local/admin/MainActivity.kt new file mode 100644 index 0000000..5f57c38 --- /dev/null +++ b/android-admin/app/src/main/java/local/admin/MainActivity.kt @@ -0,0 +1,21 @@ +package local.admin + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import local.admin.ui.AdminApp + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MaterialTheme { + Surface { + AdminApp() + } + } + } + } +} diff --git a/android-admin/app/src/main/java/local/admin/ui/AdminApp.kt b/android-admin/app/src/main/java/local/admin/ui/AdminApp.kt new file mode 100644 index 0000000..ac03aa6 --- /dev/null +++ b/android-admin/app/src/main/java/local/admin/ui/AdminApp.kt @@ -0,0 +1,19 @@ +package local.admin.ui + +import androidx.compose.runtime.Composable +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController + +@Composable +fun AdminApp() { + val nav = rememberNavController() + NavHost(navController = nav, startDestination = "home") { + composable("home") { HomeScreen(nav) } + composable("detail/{id}") { backStack -> + val id = backStack.arguments?.getString("id") ?: "" + DetailScreen(nav, id) + } + composable("settings") { SettingsScreen(nav) } + } +} diff --git a/android-admin/app/src/main/java/local/admin/ui/CoroutineUtils.kt b/android-admin/app/src/main/java/local/admin/ui/CoroutineUtils.kt new file mode 100644 index 0000000..2f9b909 --- /dev/null +++ b/android-admin/app/src/main/java/local/admin/ui/CoroutineUtils.kt @@ -0,0 +1,11 @@ +package local.admin.ui + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +fun launchIo(block: suspend () -> Unit) { + CoroutineScope(Dispatchers.IO).launch { + block() + } +} diff --git a/android-admin/app/src/main/java/local/admin/ui/DetailScreen.kt b/android-admin/app/src/main/java/local/admin/ui/DetailScreen.kt new file mode 100644 index 0000000..1373aaa --- /dev/null +++ b/android-admin/app/src/main/java/local/admin/ui/DetailScreen.kt @@ -0,0 +1,67 @@ +package local.admin.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import kotlinx.serialization.json.Json +import local.admin.data.Api +import local.admin.data.LogDetail +import local.admin.data.SecurePrefs +import okhttp3.OkHttpClient + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DetailScreen(nav: NavController, id: String) { + val ctx = LocalContext.current + val prefs = remember { SecurePrefs(ctx) } + val detail = remember { mutableStateOf(null) } + val err = remember { mutableStateOf(null) } + + LaunchedEffect(id) { + err.value = null + try { + val api = Api(OkHttpClient(), Json { ignoreUnknownKeys = true }, prefs.getBaseUrl(), prefs.getAdminToken()) + detail.value = api.getLogDetail(id) + } catch (e: Exception) { + err.value = e.message + } + } + + Scaffold( + topBar = { TopAppBar(title = { Text("Detail") }) } + ) { pad -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(pad) + .padding(12.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + err.value?.let { Text("Error: $it") } + val d = detail.value + if (d != null) { + Text("id=${d.request_id}", fontFamily = FontFamily.Monospace) + Text("model=${d.model} status=${d.status}", fontFamily = FontFamily.Monospace) + Text("messages_json:\n${d.messages_json}") + Text("assistant_text:\n${d.assistant_text ?: ""}") + } + } + } +} diff --git a/android-admin/app/src/main/java/local/admin/ui/HomeScreen.kt b/android-admin/app/src/main/java/local/admin/ui/HomeScreen.kt new file mode 100644 index 0000000..2e3c479 --- /dev/null +++ b/android-admin/app/src/main/java/local/admin/ui/HomeScreen.kt @@ -0,0 +1,154 @@ +package local.admin.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import local.admin.data.Api +import local.admin.data.LogRow +import local.admin.data.SecurePrefs +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen(nav: NavController) { + val ctx = LocalContext.current + val prefs = remember { SecurePrefs(ctx) } + + val q = remember { mutableStateOf("") } + val model = remember { mutableStateOf("") } + val status = remember { mutableStateOf("") } + val rows = remember { mutableStateOf>(emptyList()) } + val err = remember { mutableStateOf(null) } + val stats = remember { mutableStateOf("") } + + fun api(): Api { + val http = OkHttpClient() + val json = Json { ignoreUnknownKeys = true } + return Api(http, json, prefs.getBaseUrl(), prefs.getAdminToken()) + } + + suspend fun refresh() { + err.value = null + try { + val a = api() + val s = a.getStatsSummary() + stats.value = "total=${s.total} ok=${s.ok} error=${s.error} aborted=${s.aborted} p95=${s.p95LatencyMs ?: "-"}ms" + rows.value = a.getLogs(q.value.ifBlank { null }, status.value.ifBlank { null }, model.value.ifBlank { null }, 50, 0).rows + } catch (e: Exception) { + err.value = e.message + } + } + + LaunchedEffect(Unit) { + refresh() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Admin") }, + actions = { + IconButton(onClick = { nav.navigate("settings") }) { + Icon(Icons.Default.Settings, contentDescription = "Settings") + } + } + ) + } + ) { pad -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(pad) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text(stats.value, fontFamily = FontFamily.Monospace) + err.value?.let { Text("Error: $it") } + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = q.value, + onValueChange = { q.value = it }, + modifier = Modifier.weight(1f), + label = { Text("Search") }, + singleLine = true, + ) + Button(onClick = { launchIo { refresh() } }) { + Text("Go") + } + } + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = status.value, + onValueChange = { status.value = it }, + modifier = Modifier.weight(1f), + label = { Text("Status") }, + singleLine = true, + ) + OutlinedTextField( + value = model.value, + onValueChange = { model.value = it }, + modifier = Modifier.weight(1f), + label = { Text("Model") }, + singleLine = true, + ) + } + + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(rows.value) { r -> + LogCard(r) { nav.navigate("detail/${r.requestId}") } + Spacer(Modifier.height(10.dp)) + } + } + } + } +} + +@Composable +private fun LogCard(r: LogRow, onOpen: () -> Unit) { + Card( + onClick = onOpen, + colors = CardDefaults.cardColors(), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(r.status) + Text(r.model, fontFamily = FontFamily.Monospace, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Text("Prompt: ${r.userPreview}", maxLines = 2, overflow = TextOverflow.Ellipsis) + Text("Answer: ${r.assistantPreview}", maxLines = 3, overflow = TextOverflow.Ellipsis) + } + } +} diff --git a/android-admin/app/src/main/java/local/admin/ui/SettingsScreen.kt b/android-admin/app/src/main/java/local/admin/ui/SettingsScreen.kt new file mode 100644 index 0000000..5ae9276 --- /dev/null +++ b/android-admin/app/src/main/java/local/admin/ui/SettingsScreen.kt @@ -0,0 +1,61 @@ +package local.admin.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import local.admin.data.SecurePrefs + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(nav: NavController) { + val ctx = LocalContext.current + val prefs = remember { SecurePrefs(ctx) } + val baseUrl = remember { mutableStateOf(prefs.getBaseUrl()) } + val token = remember { mutableStateOf(prefs.getAdminToken()) } + + Scaffold( + topBar = { TopAppBar(title = { Text("Settings") }) } + ) { pad -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(pad) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField( + value = baseUrl.value, + onValueChange = { baseUrl.value = it }, + label = { Text("Base URL") }, + singleLine = true, + ) + OutlinedTextField( + value = token.value, + onValueChange = { token.value = it }, + label = { Text("ADMIN_TOKEN") }, + singleLine = true, + ) + Button(onClick = { + prefs.setBaseUrl(baseUrl.value.trim()) + prefs.setAdminToken(token.value) + nav.popBackStack() + }) { + Text("Save") + } + } + } +} diff --git a/android-admin/build.gradle.kts b/android-admin/build.gradle.kts new file mode 100644 index 0000000..2cb31d0 --- /dev/null +++ b/android-admin/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "8.5.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.24" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false +} diff --git a/android-admin/gradle.properties b/android-admin/gradle.properties new file mode 100644 index 0000000..8f2e28c --- /dev/null +++ b/android-admin/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/android-admin/gradle/wrapper/gradle-wrapper.properties b/android-admin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..48c0a02 --- /dev/null +++ b/android-admin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android-admin/gradlew b/android-admin/gradlew new file mode 100755 index 0000000..92ae2a7 --- /dev/null +++ b/android-admin/gradlew @@ -0,0 +1,25 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for POSIX generated by Gradle. +## +############################################################################## + +app_path=$0 + +APP_HOME=${app_path%"${app_path##*/}"} +APP_HOME=$(cd "${APP_HOME:-./}" && pwd -P) || exit + +DEFAULT_JVM_OPTS='-Xmx64m -Xms64m' + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +if [ -n "${JAVA_HOME:-}" ] ; then + JAVACMD="$JAVA_HOME/bin/java" +else + JAVACMD=java +fi + +exec "$JAVACMD" $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS \ + -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/android-admin/gradlew.bat b/android-admin/gradlew.bat new file mode 100644 index 0000000..3b66fa0 --- /dev/null +++ b/android-admin/gradlew.bat @@ -0,0 +1,21 @@ +@rem +@rem Gradle startup script for Windows +@rem + +@if "%DEBUG%"=="" @echo off +setlocal + +set APP_HOME=%~dp0 + +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +if defined JAVA_HOME ( + set JAVACMD=%JAVA_HOME%\bin\java.exe +) else ( + set JAVACMD=java.exe +) + +%JAVACMD% %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +endlocal diff --git a/android-admin/settings.gradle.kts b/android-admin/settings.gradle.kts new file mode 100644 index 0000000..48e8da8 --- /dev/null +++ b/android-admin/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Admin" +include(":app") diff --git a/docs/ADMIN.md b/docs/ADMIN.md new file mode 100644 index 0000000..02383d8 --- /dev/null +++ b/docs/ADMIN.md @@ -0,0 +1,94 @@ +# Admin + +There are two admin surfaces: + +1) Browser admin UI: `/admin` +2) Admin APIs: `/api/logs` and `/api/stats/*` + +Admin is intended for local-network use. + +## Browser admin UI + +1. Start the server (serves both chat UI and admin UI): + +```bash +export FIRMWARE_API_KEY=... +./run.sh build +./run.sh server +``` + +2. Open the admin UI: + +- `http://:/admin` (example: `http://192.168.88.2:8787/admin`) + +3. Login: + +- Enter `ADMIN_TOKEN` once. +- The server sets an `admin_token` httpOnly cookie. +- The UI then calls admin APIs without needing custom headers. + +Logout clears the cookie. + +## Admin APIs + +Admin APIs are read-only and require authentication. + +Auth methods: + +- Header: `x-admin-token: $ADMIN_TOKEN` +- Cookie session: `admin_token` (set by `POST /api/admin/session`) + +### List/search logs + +`GET /api/logs` + +Query params: + +- `q`: full-text search across prompt+answer (SQLite FTS5) +- `status`: `ok|error|aborted|started` +- `model`: model id +- `from`, `to`: timestamps (ms epoch) +- `limit` (1..200), `offset` + +Example: + +```bash +curl -H "x-admin-token: $ADMIN_TOKEN" \ + "http://192.168.88.2:8787/api/logs?q=xin%20chao&limit=50" +``` + +### Log detail + +`GET /api/logs/:request_id` + +Returns full record including: + +- `messages_json` (full conversation context) +- `assistant_text` (final/partial answer) +- timestamps and status + +### Stats + +- `GET /api/stats/summary` +- `GET /api/stats/models` +- `GET /api/stats/timeseries?bucket=hour|day` + +Example: + +```bash +curl -H "x-admin-token: $ADMIN_TOKEN" \ + "http://192.168.88.2:8787/api/stats/summary" +``` + +## Data logging + +SQLite file (default): `./data/chatlog.sqlite` + +Each `/api/chat` request inserts a log row at start and updates it when finished. + +Stored fields include: + +- timestamps: `ts_request`, `ts_first_token`, `ts_done` +- full context: `messages_json` +- quick fields: `user_text`, `assistant_text` +- `model`, `status`, optional `usage` tokens diff --git a/docs/USER.md b/docs/USER.md new file mode 100644 index 0000000..b474815 --- /dev/null +++ b/docs/USER.md @@ -0,0 +1,33 @@ +# User + +## Start + +```bash +export FIRMWARE_API_KEY=... +cp .env.example .env +``` + +Edit `.env`: + +```bash +ADMIN_TOKEN=... +HOST=0.0.0.0 +PORT=8787 +``` + +Then: + +```bash +./run.sh build +./run.sh server +``` + +Open: + +- `http://192.168.88.2:8787/` + +## Chat UI + +- Works in mobile Safari/iPhone. +- Streams responses. +- Renders assistant responses as Markdown (sanitized).