Compare commits
4 commits
392e582084
...
06757d6f69
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06757d6f69 | ||
|
|
40db869717 | ||
|
|
89fe5fd2ae | ||
|
|
3b73e15376 |
12 changed files with 1560 additions and 1237 deletions
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -57,11 +57,12 @@ ui/
|
||||||
|
|
||||||
### Key components
|
### Key components
|
||||||
|
|
||||||
- **KaizenApi** (`net/KaizenApi.kt`) — singleton `object`, all HTTP calls via OkHttp. Streaming chat returns `Flow<String>` with incremental sentinel-stripping.
|
- **KaizenApi** (`net/KaizenApi.kt`) — singleton `object`, all HTTP calls via OkHttp. Three clients sharing one connection pool: `baseClient` (30s timeout, also used by LiveClient), `streamClient` (infinite timeout, chat only), `imageClient` (same as baseClient). Streaming chat returns `Flow<StreamState>` via `streamClient`. `uploadStream()` accepts InputStream for zero-copy file uploads.
|
||||||
- **StreamConsumer** (`net/StreamConsumer.kt`) — O(chunk-size) incremental parser, each character visited exactly once across the entire stream.
|
- **StreamConsumer** (`net/StreamConsumer.kt`) — O(chunk-size) incremental parser, each character visited exactly once across the entire stream.
|
||||||
- **SessionViewModel** (`net/SessionViewModel.kt`) — Compose snapshot state for auth session. Holds `ServerConfig` (baseUrl, token, model, email, speed). Persisted in `EncryptedSharedPreferences` via `SecureStore`.
|
- **SessionViewModel** (`net/SessionViewModel.kt`) — Compose snapshot state for auth session. Holds `ServerConfig` (baseUrl, token, model, email, speed). Persisted in `EncryptedSharedPreferences` via `SecureStore`.
|
||||||
- **ChatViewModel** (`chat/ChatViewModel.kt`) — owns Room database instance + repositories. Created in `MainActivity`, passed to `ChatScreen`.
|
- **ChatViewModel** (`chat/ChatViewModel.kt`) — owns Room database instance + repositories. Created in `MainActivity`, passed to `ChatScreenViewModel`.
|
||||||
- **ChatScreen** (`chat/ChatScreen.kt`) — main composable, orchestrates sidebar, messages, streaming, file uploads.
|
- **ChatScreenViewModel** (`chat/ChatScreenViewModel.kt`) — owns all chat state and business logic: messages, streaming, conversation management, sidebar actions, file upload, voice recording, TTS, live call, biometric unlock. Extends `ViewModel` with `viewModelScope`. Dependencies: `Application?`, `SessionViewModel`, `ChatViewModel?`, `SettingsViewModel`. Nullable deps for JVM test construction.
|
||||||
|
- **ChatScreen** (`chat/ChatScreen.kt`) — thin rendering layer (~430 lines). Reads state from `ChatScreenViewModel`, wires UI events to VM methods. Owns Android-framework-only concerns: `ActivityResultLauncher` (camera/gallery/file), `DrawerState`, `LazyListState`, keyboard insets, `LifecycleResumeEffect`.
|
||||||
|
|
||||||
### Design System v2 (`ui/`) — added 2026-06-21
|
### Design System v2 (`ui/`) — added 2026-06-21
|
||||||
|
|
||||||
|
|
@ -293,6 +294,8 @@ All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKo
|
||||||
| `1c9491e` | **Markdown spacing** — heading spacing 18→24/20dp, list items 8dp gap + muted bullets, blockquote 12dp inset |
|
| `1c9491e` | **Markdown spacing** — heading spacing 18→24/20dp, list items 8dp gap + muted bullets, blockquote 12dp inset |
|
||||||
| `430d5b6` | **Flat sidebar user row** — removed heavy card (background, border, shadow), replaced with flat row + thin separator |
|
| `430d5b6` | **Flat sidebar user row** — removed heavy card (background, border, shadow), replaced with flat row + thin separator |
|
||||||
| — | **Branch navigation (tree support)** — `ChatTree` data structure mirrors web frontend's tree. Edit creates a branch (sibling node with same parentId) instead of deleting messages. Regenerate creates a new assistant branch. `BranchNavigator` UI (chevron arrows + "1/3" counter) on messages with siblings. `activeRootId`/`activeChild` synced to server via PATCH. `StoredMessage.parentId` parsed from API. `patchConversation` handles JSON object values for `activeChild` |
|
| — | **Branch navigation (tree support)** — `ChatTree` data structure mirrors web frontend's tree. Edit creates a branch (sibling node with same parentId) instead of deleting messages. Regenerate creates a new assistant branch. `BranchNavigator` UI (chevron arrows + "1/3" counter) on messages with siblings. `activeRootId`/`activeChild` synced to server via PATCH. `StoredMessage.parentId` parsed from API. `patchConversation` handles JSON object values for `activeChild` |
|
||||||
|
| `3b73e15` | **Security hardening + performance pass** — JSON injection fix (deleteMessage), separate stream OkHttpClient (30s timeout on normal requests), disable backup, block cleartext, strip URLs from logs. Shared connection pool (LiveClient reuses KaizenApi.baseClient), eliminate duplicate fetchMe, parallel prefetchMissing, streaming file uploads (no readBytes), O(1) message index during streaming, send() race condition fix, voice recording file read on IO thread |
|
||||||
|
| `40db869` | **ChatScreenViewModel extraction** — moved ~35 state variables + ~15 business-logic functions from the 1540-line God Composable into `ChatScreenViewModel`. ChatScreen is now ~430 lines (pure UI). `createHaptics()` factory for non-Composable use. `SessionViewModel.store` nullable for JVM test construction. 12 new unit tests |
|
||||||
|
|
||||||
**Earlier UI/feel work (Phase 0 prototype → feel-first):**
|
**Earlier UI/feel work (Phase 0 prototype → feel-first):**
|
||||||
- Liquid glass styling, floating panels, glassmorphic sidebar
|
- Liquid glass styling, floating panels, glassmorphic sidebar
|
||||||
|
|
@ -407,7 +410,13 @@ All done:
|
||||||
- [x] **Security settings** — biometric toggle, signed-in devices with remote revoke, password change
|
- [x] **Security settings** — biometric toggle, signed-in devices with remote revoke, password change
|
||||||
- [x] **Password change** — `POST /api/v1/auth/change-password` (Argon2id, rate-limited)
|
- [x] **Password change** — `POST /api/v1/auth/change-password` (Argon2id, rate-limited)
|
||||||
- [x] **Device management** — `GET/DELETE /api/v1/auth/tokens` (list + revoke, can't revoke self)
|
- [x] **Device management** — `GET/DELETE /api/v1/auth/tokens` (list + revoke, can't revoke self)
|
||||||
|
- [x] **JSON injection fix** — `deleteMessage` was using raw string interpolation for JSON body, now uses `kotlinx.serialization`
|
||||||
|
- [x] **Separate stream client** — main `OkHttpClient` has 30s `readTimeout` (was infinite for ALL requests). Streaming-only `streamClient` keeps `readTimeout(0)`. Prevents normal API calls from hanging forever
|
||||||
|
- [x] **Disable backup** — `android:allowBackup="false"`. EncryptedSharedPreferences without KeyStore on restore → crash
|
||||||
|
- [x] **Block cleartext traffic** — `android:usesCleartextTraffic="false"` explicit in manifest
|
||||||
|
- [x] **Strip server URLs from logs** — `Log.w` calls no longer include `$baseUrl`, prevents leaking instance URLs in release logcat
|
||||||
- [ ] **`FLAG_SECURE`** on sensitive screens — see `LATER.md` §1B
|
- [ ] **`FLAG_SECURE`** on sensitive screens — see `LATER.md` §1B
|
||||||
|
- [ ] **Certificate pinning** for default server (`ask.kryptomrx.de`)
|
||||||
|
|
||||||
### 4. Features for parity with the web
|
### 4. Features for parity with the web
|
||||||
|
|
||||||
|
|
@ -480,6 +489,7 @@ composeIcons = "1.0.0" # com.composables:icons-lucide-android (Lucide
|
||||||
|
|
||||||
**Unit tests** (`app/src/test/`): JVM-only, no emulator needed.
|
**Unit tests** (`app/src/test/`): JVM-only, no emulator needed.
|
||||||
|
|
||||||
|
- `ChatScreenViewModelTest` — 12 tests: initial state, newChat reset, send guards (no config, empty input, while streaming), input binding, mode/reasoning/sampling toggles, screen navigation, password dialog lifecycle, edit message, delete message, file removal, attach menu, TTS voice
|
||||||
- `SettingsViewModelTest` — state transitions, all 4 themes, appearance modes, name updates with store-less default
|
- `SettingsViewModelTest` — state transitions, all 4 themes, appearance modes, name updates with store-less default
|
||||||
- `StreamConsumerTest` — batch + incremental parser, sentinel stripping, reasoning/sources/tools/usage parsing, char-by-char edge cases
|
- `StreamConsumerTest` — batch + incremental parser, sentinel stripping, reasoning/sources/tools/usage parsing, char-by-char edge cases
|
||||||
- `OklabTest` — round-trip conversion, interpolation midpoint, gradient generation (all green since Double-precision fix)
|
- `OklabTest` — round-trip conversion, interpolation midpoint, gradient generation (all green since Double-precision fix)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="false"
|
||||||
|
android:usesCleartextTraffic="false"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import androidx.compose.runtime.collectAsState
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import dev.kaizen.app.auth.LoginScreen
|
import dev.kaizen.app.auth.LoginScreen
|
||||||
import dev.kaizen.app.chat.ChatScreen
|
import dev.kaizen.app.chat.ChatScreen
|
||||||
|
import dev.kaizen.app.chat.ChatScreenViewModel
|
||||||
import dev.kaizen.app.chat.ChatViewModel
|
import dev.kaizen.app.chat.ChatViewModel
|
||||||
import dev.kaizen.app.net.SecureStore
|
import dev.kaizen.app.net.SecureStore
|
||||||
import dev.kaizen.app.net.SessionViewModel
|
import dev.kaizen.app.net.SessionViewModel
|
||||||
|
|
@ -21,10 +22,7 @@ import dev.kaizen.app.ui.theme.KaizenTheme
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
// Enable High Dynamic Range (HDR) window color mode on Android 14+ (API 34+)
|
|
||||||
// and Wide Color Gamut (Display P3) on Android 8.0+ (API 26+)
|
|
||||||
// This leverages the hardware capabilities of high-end AMOLED/OLED displays (S25 Ultra, Pixel 10).
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
window.colorMode = ActivityInfo.COLOR_MODE_HDR
|
window.colorMode = ActivityInfo.COLOR_MODE_HDR
|
||||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
|
@ -35,8 +33,13 @@ class MainActivity : ComponentActivity() {
|
||||||
val settingsViewModel = SettingsViewModel(secureStore)
|
val settingsViewModel = SettingsViewModel(secureStore)
|
||||||
val sessionViewModel = SessionViewModel(secureStore)
|
val sessionViewModel = SessionViewModel(secureStore)
|
||||||
val chatViewModel = ChatViewModel(applicationContext)
|
val chatViewModel = ChatViewModel(applicationContext)
|
||||||
|
val chatScreenViewModel = ChatScreenViewModel(
|
||||||
|
app = application,
|
||||||
|
session = sessionViewModel,
|
||||||
|
chat = chatViewModel,
|
||||||
|
settings = settingsViewModel,
|
||||||
|
)
|
||||||
|
|
||||||
// auto() picks light/dark system-bar icons based on the system theme
|
|
||||||
enableEdgeToEdge(
|
enableEdgeToEdge(
|
||||||
statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
|
statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
|
||||||
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
|
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
|
||||||
|
|
@ -50,10 +53,8 @@ class MainActivity : ComponentActivity() {
|
||||||
AppAppearance.System -> androidx.compose.foundation.isSystemInDarkTheme()
|
AppAppearance.System -> androidx.compose.foundation.isSystemInDarkTheme()
|
||||||
}
|
}
|
||||||
KaizenTheme(themeId = themeId.value, darkTheme = darkOverride) {
|
KaizenTheme(themeId = themeId.value, darkTheme = darkOverride) {
|
||||||
// Snapshot-state routing: logging in/out flips session.config and
|
|
||||||
// recomposes this branch — no NavHost needed for two top-level states.
|
|
||||||
if (sessionViewModel.isLoggedIn) {
|
if (sessionViewModel.isLoggedIn) {
|
||||||
ChatScreen(settingsViewModel = settingsViewModel, session = sessionViewModel, chat = chatViewModel)
|
ChatScreen(vm = chatScreenViewModel, settingsViewModel = settingsViewModel)
|
||||||
} else {
|
} else {
|
||||||
LoginScreen(session = sessionViewModel)
|
LoginScreen(session = sessionViewModel)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -774,7 +774,7 @@ data class PendingFile(
|
||||||
val id: String = java.util.UUID.randomUUID().toString(),
|
val id: String = java.util.UUID.randomUUID().toString(),
|
||||||
val name: String,
|
val name: String,
|
||||||
val mimeType: String,
|
val mimeType: String,
|
||||||
val bytes: ByteArray,
|
val bytes: ByteArray = ByteArray(0),
|
||||||
val uploaded: Attachment? = null,
|
val uploaded: Attachment? = null,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val uploading: Boolean = true,
|
val uploading: Boolean = true,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
1046
app/src/main/java/dev/kaizen/app/chat/ChatScreenViewModel.kt
Normal file
1046
app/src/main/java/dev/kaizen/app/chat/ChatScreenViewModel.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,9 @@
|
||||||
package dev.kaizen.app.db
|
package dev.kaizen.app.db
|
||||||
|
|
||||||
import dev.kaizen.app.net.KaizenApi
|
import dev.kaizen.app.net.KaizenApi
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class MessageRepository(private val dao: MessageDao) {
|
class MessageRepository(private val dao: MessageDao) {
|
||||||
|
|
||||||
|
|
@ -24,11 +26,14 @@ class MessageRepository(private val dao: MessageDao) {
|
||||||
|
|
||||||
suspend fun prefetchMissing(baseUrl: String, token: String, conversationIds: List<String>) {
|
suspend fun prefetchMissing(baseUrl: String, token: String, conversationIds: List<String>) {
|
||||||
val cached = dao.cachedConversationIds().toSet()
|
val cached = dao.cachedConversationIds().toSet()
|
||||||
for (id in conversationIds) {
|
val missing = conversationIds.filter { it !in cached }
|
||||||
if (id in cached) continue
|
if (missing.isEmpty()) return
|
||||||
try {
|
coroutineScope {
|
||||||
fetchAndCache(baseUrl, token, id)
|
for (id in missing) {
|
||||||
} catch (_: Exception) { }
|
launch {
|
||||||
|
try { fetchAndCache(baseUrl, token, id) } catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -116,17 +116,18 @@ class Haptics(private val vibrator: Vibrator?) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Builds the right Vibrator for the API level and keeps it across recompositions. */
|
fun createHaptics(context: Context): Haptics {
|
||||||
|
val vib: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
(context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||||
|
}
|
||||||
|
return Haptics(vib)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberHaptics(): Haptics {
|
fun rememberHaptics(): Haptics {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
return remember {
|
return remember { createHaptics(context) }
|
||||||
val vib: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
(context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
|
||||||
}
|
|
||||||
Haptics(vib)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.WebSocket
|
import okhttp3.WebSocket
|
||||||
import okhttp3.WebSocketListener
|
import okhttp3.WebSocketListener
|
||||||
|
import dev.kaizen.app.net.KaizenApi
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
import java.util.concurrent.atomic.AtomicReference
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
@ -57,10 +58,9 @@ class LiveClient(
|
||||||
val conversationId: String?,
|
val conversationId: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val client = OkHttpClient.Builder()
|
private val client = KaizenApi.baseClient.newBuilder()
|
||||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||||
.pingInterval(15, TimeUnit.SECONDS)
|
.pingInterval(15, TimeUnit.SECONDS)
|
||||||
.connectTimeout(10, TimeUnit.SECONDS)
|
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
// ─── Connect ──────────────────────────────────────────────────────────
|
// ─── Connect ──────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,13 @@ import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.MultipartBody
|
import okhttp3.MultipartBody
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okio.BufferedSink
|
||||||
|
import okio.source
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
|
@ -80,6 +84,7 @@ data class ConversationSummary(
|
||||||
val isCurrent: Boolean = false,
|
val isCurrent: Boolean = false,
|
||||||
)
|
)
|
||||||
@Serializable private data class AppDevicesResponse(val tokens: List<AppDevice> = emptyList())
|
@Serializable private data class AppDevicesResponse(val tokens: List<AppDevice> = emptyList())
|
||||||
|
@Serializable private data class DeleteMessageRequest(val messageId: String)
|
||||||
@Serializable private data class ChangePasswordRequest(val currentPassword: String, val newPassword: String)
|
@Serializable private data class ChangePasswordRequest(val currentPassword: String, val newPassword: String)
|
||||||
|
|
||||||
/** Attachment on a persisted message (image, audio, video, pdf, document, code). */
|
/** Attachment on a persisted message (image, audio, video, pdf, document, code). */
|
||||||
|
|
@ -176,15 +181,19 @@ object KaizenApi {
|
||||||
|
|
||||||
private const val TAG = "KaizenApi"
|
private const val TAG = "KaizenApi"
|
||||||
|
|
||||||
private val client = OkHttpClient.Builder()
|
val baseClient: OkHttpClient = OkHttpClient.Builder()
|
||||||
.connectTimeout(15, TimeUnit.SECONDS)
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
.readTimeout(0, TimeUnit.SECONDS) // streaming chat — never time out a healthy stream
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val imageClient: OkHttpClient = client.newBuilder()
|
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
private val client = baseClient
|
||||||
|
|
||||||
|
private val streamClient: OkHttpClient = client.newBuilder()
|
||||||
|
.readTimeout(0, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val imageClient: OkHttpClient = client
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||||
private val jsonMedia = "application/json".toMediaType()
|
private val jsonMedia = "application/json".toMediaType()
|
||||||
|
|
||||||
|
|
@ -227,22 +236,6 @@ object KaizenApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchDefaultModel(baseUrl: String, token: String): String? =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val req = Request.Builder()
|
|
||||||
.url("$baseUrl/api/v1/me")
|
|
||||||
.header("Authorization", "Bearer $token")
|
|
||||||
.build()
|
|
||||||
try {
|
|
||||||
client.newCall(req).execute().use { resp ->
|
|
||||||
if (!resp.isSuccessful) return@use null
|
|
||||||
json.decodeFromString<MeResponse>(resp.body!!.string()).defaultModel
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /api/v1/models — the full model catalog for the picker. */
|
/** GET /api/v1/models — the full model catalog for the picker. */
|
||||||
suspend fun fetchModels(baseUrl: String, token: String): FetchResult<List<KaizenModel>> =
|
suspend fun fetchModels(baseUrl: String, token: String): FetchResult<List<KaizenModel>> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
|
@ -258,7 +251,7 @@ object KaizenApi {
|
||||||
403 -> " (Zugriff verweigert)"
|
403 -> " (Zugriff verweigert)"
|
||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
Log.w(TAG, "fetchModels: HTTP ${resp.code} from $baseUrl")
|
Log.w(TAG, "fetchModels: HTTP ${resp.code}")
|
||||||
return@use FetchResult.Fail(reason)
|
return@use FetchResult.Fail(reason)
|
||||||
}
|
}
|
||||||
FetchResult.Ok(json.decodeFromString<ModelsResponse>(resp.body!!.string()).models)
|
FetchResult.Ok(json.decodeFromString<ModelsResponse>(resp.body!!.string()).models)
|
||||||
|
|
@ -284,7 +277,7 @@ object KaizenApi {
|
||||||
403 -> " (Zugriff verweigert)"
|
403 -> " (Zugriff verweigert)"
|
||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
Log.w(TAG, "fetchConversations: HTTP ${resp.code} from $baseUrl")
|
Log.w(TAG, "fetchConversations: HTTP ${resp.code}")
|
||||||
return@use FetchResult.Fail(reason)
|
return@use FetchResult.Fail(reason)
|
||||||
}
|
}
|
||||||
FetchResult.Ok(json.decodeFromString<ConversationsResponse>(resp.body!!.string()).conversations)
|
FetchResult.Ok(json.decodeFromString<ConversationsResponse>(resp.body!!.string()).conversations)
|
||||||
|
|
@ -459,7 +452,7 @@ object KaizenApi {
|
||||||
/** DELETE /api/v1/conversations/[id]/messages — delete a single message. */
|
/** DELETE /api/v1/conversations/[id]/messages — delete a single message. */
|
||||||
suspend fun deleteMessage(baseUrl: String, token: String, conversationId: String, messageId: String): Boolean =
|
suspend fun deleteMessage(baseUrl: String, token: String, conversationId: String, messageId: String): Boolean =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val body = """{"messageId":"$messageId"}""".toRequestBody(jsonMedia)
|
val body = json.encodeToString(DeleteMessageRequest(messageId)).toRequestBody(jsonMedia)
|
||||||
val req = Request.Builder()
|
val req = Request.Builder()
|
||||||
.url("$baseUrl/api/v1/conversations/$conversationId/messages")
|
.url("$baseUrl/api/v1/conversations/$conversationId/messages")
|
||||||
.delete(body)
|
.delete(body)
|
||||||
|
|
@ -539,7 +532,7 @@ object KaizenApi {
|
||||||
.header("Authorization", "Bearer $token")
|
.header("Authorization", "Bearer $token")
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val resp = client.newCall(req).execute()
|
val resp = streamClient.newCall(req).execute()
|
||||||
if (!resp.isSuccessful) {
|
if (!resp.isSuccessful) {
|
||||||
val code = resp.code
|
val code = resp.code
|
||||||
resp.close()
|
resp.close()
|
||||||
|
|
@ -604,6 +597,50 @@ object KaizenApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun uploadStream(
|
||||||
|
baseUrl: String,
|
||||||
|
token: String,
|
||||||
|
fileName: String,
|
||||||
|
mimeType: String,
|
||||||
|
inputStream: InputStream,
|
||||||
|
contentLength: Long = -1L,
|
||||||
|
): FetchResult<Attachment> = withContext(Dispatchers.IO) {
|
||||||
|
val mediaType = mimeType.toMediaType()
|
||||||
|
val streamBody = object : RequestBody() {
|
||||||
|
override fun contentType() = mediaType
|
||||||
|
override fun contentLength() = contentLength
|
||||||
|
override fun writeTo(sink: BufferedSink) {
|
||||||
|
inputStream.source().use { source -> sink.writeAll(source) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val body = MultipartBody.Builder()
|
||||||
|
.setType(MultipartBody.FORM)
|
||||||
|
.addFormDataPart("file", fileName, streamBody)
|
||||||
|
.build()
|
||||||
|
val req = Request.Builder()
|
||||||
|
.url("$baseUrl/api/v1/upload")
|
||||||
|
.post(body)
|
||||||
|
.header("Authorization", "Bearer $token")
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
client.newCall(req).execute().use { resp ->
|
||||||
|
if (!resp.isSuccessful) {
|
||||||
|
val reason = "upload: HTTP ${resp.code}" + when (resp.code) {
|
||||||
|
401 -> " (Token ungültig)"
|
||||||
|
413 -> " (Datei zu groß)"
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
Log.w(TAG, "uploadFile: HTTP ${resp.code}")
|
||||||
|
return@use FetchResult.Fail(reason)
|
||||||
|
}
|
||||||
|
FetchResult.Ok(json.decodeFromString<Attachment>(resp.body!!.string()))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "uploadStream: ${e.javaClass.simpleName}: ${e.message}")
|
||||||
|
FetchResult.Fail(diagnoseFetchError("upload", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable private data class SpeechRequest(val prompt: String, val kind: String = "speech", val voice: String? = null)
|
@Serializable private data class SpeechRequest(val prompt: String, val kind: String = "speech", val voice: String? = null)
|
||||||
@Serializable private data class SpeechResponse(val url: String = "", val cost: Double? = null)
|
@Serializable private data class SpeechResponse(val url: String = "", val cost: Double? = null)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,12 @@ import androidx.compose.runtime.setValue
|
||||||
*/
|
*/
|
||||||
data class VersionSkew(val serverVersion: Int, val expectedVersion: Int)
|
data class VersionSkew(val serverVersion: Int, val expectedVersion: Int)
|
||||||
|
|
||||||
class SessionViewModel(val store: SecureStore) {
|
class SessionViewModel(val store: SecureStore? = null) {
|
||||||
|
|
||||||
var config by mutableStateOf(store.load())
|
var config by mutableStateOf(store?.load())
|
||||||
private set
|
private set
|
||||||
|
|
||||||
/** Favorite model ids (pinned to the top of the picker), as Compose snapshot state. */
|
var favorites by mutableStateOf(store?.loadFavorites() ?: emptySet())
|
||||||
var favorites by mutableStateOf(store.loadFavorites())
|
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var versionSkew by mutableStateOf<VersionSkew?>(null)
|
var versionSkew by mutableStateOf<VersionSkew?>(null)
|
||||||
|
|
@ -39,9 +38,10 @@ class SessionViewModel(val store: SecureStore) {
|
||||||
val baseUrl = normalizeBaseUrl(baseUrlInput)
|
val baseUrl = normalizeBaseUrl(baseUrlInput)
|
||||||
return when (val result = KaizenApi.login(baseUrl, email, password, deviceName)) {
|
return when (val result = KaizenApi.login(baseUrl, email, password, deviceName)) {
|
||||||
is LoginResult.Success -> {
|
is LoginResult.Success -> {
|
||||||
val model = KaizenApi.fetchDefaultModel(baseUrl, result.token) ?: DEFAULT_MODEL
|
val me = KaizenApi.fetchMe(baseUrl, result.token)
|
||||||
|
val model = me?.defaultModel ?: DEFAULT_MODEL
|
||||||
val cfg = ServerConfig(baseUrl = baseUrl, token = result.token, model = model, email = email.trim())
|
val cfg = ServerConfig(baseUrl = baseUrl, token = result.token, model = model, email = email.trim())
|
||||||
store.save(cfg)
|
store?.save(cfg)
|
||||||
config = cfg
|
config = cfg
|
||||||
checkServerVersion(baseUrl)
|
checkServerVersion(baseUrl)
|
||||||
result
|
result
|
||||||
|
|
@ -64,7 +64,7 @@ class SessionViewModel(val store: SecureStore) {
|
||||||
val cfg = config ?: return
|
val cfg = config ?: return
|
||||||
if (cfg.model == modelId) return
|
if (cfg.model == modelId) return
|
||||||
val updated = cfg.copy(model = modelId)
|
val updated = cfg.copy(model = modelId)
|
||||||
store.save(updated)
|
store?.save(updated)
|
||||||
config = updated
|
config = updated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,19 +73,19 @@ class SessionViewModel(val store: SecureStore) {
|
||||||
val cfg = config ?: return
|
val cfg = config ?: return
|
||||||
if (cfg.speed == speed) return
|
if (cfg.speed == speed) return
|
||||||
val updated = cfg.copy(speed = speed)
|
val updated = cfg.copy(speed = speed)
|
||||||
store.save(updated)
|
store?.save(updated)
|
||||||
config = updated
|
config = updated
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Toggle a model id in/out of favorites (persisted). */
|
/** Toggle a model id in/out of favorites (persisted). */
|
||||||
fun toggleFavorite(modelId: String) {
|
fun toggleFavorite(modelId: String) {
|
||||||
val next = favorites.toMutableSet().apply { if (!add(modelId)) remove(modelId) }
|
val next = favorites.toMutableSet().apply { if (!add(modelId)) remove(modelId) }
|
||||||
store.saveFavorites(next)
|
store?.saveFavorites(next)
|
||||||
favorites = next
|
favorites = next
|
||||||
}
|
}
|
||||||
|
|
||||||
fun logout() {
|
fun logout() {
|
||||||
store.clear()
|
store?.clear()
|
||||||
config = null
|
config = null
|
||||||
favorites = emptySet()
|
favorites = emptySet()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
217
app/src/test/java/dev/kaizen/app/ChatScreenViewModelTest.kt
Normal file
217
app/src/test/java/dev/kaizen/app/ChatScreenViewModelTest.kt
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
package dev.kaizen.app
|
||||||
|
|
||||||
|
import dev.kaizen.app.chat.AppScreen
|
||||||
|
import dev.kaizen.app.chat.ChatScreenViewModel
|
||||||
|
import dev.kaizen.app.chat.Message
|
||||||
|
import dev.kaizen.app.chat.PendingFile
|
||||||
|
import dev.kaizen.app.chat.ReasoningPreset
|
||||||
|
import dev.kaizen.app.chat.Role
|
||||||
|
import dev.kaizen.app.chat.SamplingParam
|
||||||
|
import dev.kaizen.app.chat.SamplingPrefs
|
||||||
|
import dev.kaizen.app.net.ConversationSummary
|
||||||
|
import dev.kaizen.app.net.SessionViewModel
|
||||||
|
import dev.kaizen.app.settings.SettingsViewModel
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ChatScreenViewModelTest {
|
||||||
|
|
||||||
|
private fun createVm(): ChatScreenViewModel = ChatScreenViewModel(
|
||||||
|
session = SessionViewModel(),
|
||||||
|
settings = SettingsViewModel(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun initialState() {
|
||||||
|
val vm = createVm()
|
||||||
|
assertTrue(vm.messages.isEmpty())
|
||||||
|
assertFalse(vm.isStreaming)
|
||||||
|
assertEquals("", vm.input)
|
||||||
|
assertNull(vm.conversationId)
|
||||||
|
assertFalse(vm.viewingLockedChat)
|
||||||
|
assertNull(vm.chatModel)
|
||||||
|
assertEquals(0, vm.usedTokens)
|
||||||
|
assertNull(vm.activeMode)
|
||||||
|
assertEquals(ReasoningPreset.Standard, vm.reasoningPreset)
|
||||||
|
assertEquals("google", vm.webSearchProvider)
|
||||||
|
assertFalse(vm.isRecording)
|
||||||
|
assertEquals(0f, vm.recordingAmplitude)
|
||||||
|
assertNull(vm.playingTtsForId)
|
||||||
|
assertEquals("Kore", vm.ttsVoice)
|
||||||
|
assertTrue(vm.pendingFiles.isEmpty())
|
||||||
|
assertFalse(vm.showAttachMenu)
|
||||||
|
assertEquals(AppScreen.Chat, vm.currentScreen)
|
||||||
|
assertFalse(vm.liveCallActive)
|
||||||
|
assertNull(vm.loadError)
|
||||||
|
assertNull(vm.passwordUnlockTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun newChatResetsState() {
|
||||||
|
val vm = createVm()
|
||||||
|
vm.messages.add(Message(1, Role.User, "hello"))
|
||||||
|
vm.messages.add(Message(2, Role.Assistant, "hi"))
|
||||||
|
vm.usedTokens = 500
|
||||||
|
vm.chatModel = "vertex:gemini-2.5-flash"
|
||||||
|
|
||||||
|
vm.newChat()
|
||||||
|
|
||||||
|
assertTrue(vm.messages.isEmpty())
|
||||||
|
assertNull(vm.conversationId)
|
||||||
|
assertFalse(vm.viewingLockedChat)
|
||||||
|
assertEquals(0, vm.usedTokens)
|
||||||
|
assertNull(vm.chatModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sendWithoutConfigIsNoOp() {
|
||||||
|
val vm = createVm()
|
||||||
|
val sizeBefore = vm.messages.size
|
||||||
|
vm.send("hello")
|
||||||
|
assertEquals(sizeBefore, vm.messages.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sendEmptyIsNoOp() {
|
||||||
|
val vm = createVm()
|
||||||
|
vm.send("")
|
||||||
|
assertTrue(vm.messages.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun inputBindsTwoWay() {
|
||||||
|
val vm = createVm()
|
||||||
|
assertEquals("", vm.input)
|
||||||
|
vm.input = "test prompt"
|
||||||
|
assertEquals("test prompt", vm.input)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun modeToggle() {
|
||||||
|
val vm = createVm()
|
||||||
|
assertNull(vm.activeMode)
|
||||||
|
vm.activeMode = R.string.mode_search
|
||||||
|
assertEquals(R.string.mode_search, vm.activeMode)
|
||||||
|
vm.activeMode = null
|
||||||
|
assertNull(vm.activeMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reasoningPresetCycle() {
|
||||||
|
val vm = createVm()
|
||||||
|
assertEquals(ReasoningPreset.Standard, vm.reasoningPreset)
|
||||||
|
vm.reasoningPreset = ReasoningPreset.High
|
||||||
|
assertEquals(ReasoningPreset.High, vm.reasoningPreset)
|
||||||
|
assertEquals("high", vm.reasoningPreset.effort)
|
||||||
|
assertFalse(vm.reasoningPreset.throughput)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun samplingPrefsUpdate() {
|
||||||
|
val vm = createVm()
|
||||||
|
assertFalse(vm.samplingPrefs.temperature.enabled)
|
||||||
|
vm.samplingPrefs = SamplingPrefs(
|
||||||
|
temperature = SamplingParam(true, 0.7f),
|
||||||
|
topP = SamplingParam(false, 1f),
|
||||||
|
topK = SamplingParam(true, 20f),
|
||||||
|
)
|
||||||
|
assertTrue(vm.samplingPrefs.temperature.enabled)
|
||||||
|
assertEquals(0.7f, vm.samplingPrefs.temperature.value)
|
||||||
|
assertTrue(vm.samplingPrefs.topK.enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun screenNavigation() {
|
||||||
|
val vm = createVm()
|
||||||
|
assertEquals(AppScreen.Chat, vm.currentScreen)
|
||||||
|
vm.currentScreen = AppScreen.Settings
|
||||||
|
assertEquals(AppScreen.Settings, vm.currentScreen)
|
||||||
|
vm.currentScreen = AppScreen.Chat
|
||||||
|
assertEquals(AppScreen.Chat, vm.currentScreen)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun webSearchProviderChange() {
|
||||||
|
val vm = createVm()
|
||||||
|
assertEquals("google", vm.webSearchProvider)
|
||||||
|
vm.webSearchProvider = "enterprise"
|
||||||
|
assertEquals("enterprise", vm.webSearchProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun passwordDialogLifecycle() {
|
||||||
|
val vm = createVm()
|
||||||
|
val fakeSummary = ConversationSummary(id = "test-123", title = "Test")
|
||||||
|
vm.showPasswordFallback(fakeSummary, forToggle = true)
|
||||||
|
assertEquals(fakeSummary, vm.passwordUnlockTarget)
|
||||||
|
assertTrue(vm.passwordUnlockForToggle)
|
||||||
|
assertNull(vm.passwordUnlockError)
|
||||||
|
|
||||||
|
vm.dismissPasswordDialog()
|
||||||
|
assertNull(vm.passwordUnlockTarget)
|
||||||
|
assertNull(vm.passwordUnlockError)
|
||||||
|
assertFalse(vm.passwordUnlockForToggle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun editMessageSetsInputAndClearsMessages() {
|
||||||
|
val vm = createVm()
|
||||||
|
vm.messages.add(Message(1, Role.User, "original text", wireId = "wire-1"))
|
||||||
|
vm.messages.add(Message(2, Role.Assistant, "response", wireId = "wire-2"))
|
||||||
|
|
||||||
|
vm.editMessage("wire-1", "original text")
|
||||||
|
assertEquals("original text", vm.input)
|
||||||
|
assertTrue(vm.messages.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clearLockedOnPauseWhenNotViewing() {
|
||||||
|
val vm = createVm()
|
||||||
|
vm.messages.add(Message(1, Role.User, "secret"))
|
||||||
|
vm.clearLockedOnPause()
|
||||||
|
assertEquals(1, vm.messages.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removeFile() {
|
||||||
|
val vm = createVm()
|
||||||
|
val pf = PendingFile(name = "test.jpg", mimeType = "image/jpeg")
|
||||||
|
vm.pendingFiles.add(pf)
|
||||||
|
assertEquals(1, vm.pendingFiles.size)
|
||||||
|
vm.removeFile(pf.id)
|
||||||
|
assertTrue(vm.pendingFiles.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun attachMenuToggle() {
|
||||||
|
val vm = createVm()
|
||||||
|
assertFalse(vm.showAttachMenu)
|
||||||
|
vm.showAttachMenu = true
|
||||||
|
assertTrue(vm.showAttachMenu)
|
||||||
|
vm.showAttachMenu = false
|
||||||
|
assertFalse(vm.showAttachMenu)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ttsVoiceDefault() {
|
||||||
|
val vm = createVm()
|
||||||
|
assertEquals("Kore", vm.ttsVoice)
|
||||||
|
vm.ttsVoice = "Aoede"
|
||||||
|
assertEquals("Aoede", vm.ttsVoice)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deleteMessageRemovesFromList() {
|
||||||
|
val vm = createVm()
|
||||||
|
vm.messages.add(Message(1, Role.User, "hello", wireId = "w1"))
|
||||||
|
vm.messages.add(Message(2, Role.Assistant, "hi", wireId = "w2"))
|
||||||
|
assertEquals(2, vm.messages.size)
|
||||||
|
|
||||||
|
vm.deleteMessage("w1")
|
||||||
|
assertEquals(1, vm.messages.size)
|
||||||
|
assertEquals("w2", vm.messages.first().wireId)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue