Compare commits

..

No commits in common. "f22838e99a1d40184e12e9e20e5bd81018a4aec0" and "e56e9a4c837f32f4447713d57a4a49b8ce3fa48c" have entirely different histories.

21 changed files with 252 additions and 1406 deletions

View file

@ -35,7 +35,7 @@ Make the **native Android app** (`kaizen-app`) a valid daily-driver that talks t
### Package structure (`app/src/main/java/dev/kaizen/app/`) ### Package structure (`app/src/main/java/dev/kaizen/app/`)
``` ```
auth/ LoginScreen, BiometricUnlock (KeyStore + CryptoObject) auth/ LoginScreen
chat/ ChatScreen, ChatViewModel, Sidebar, ChatComponents, ChatModels, chat/ ChatScreen, ChatViewModel, Sidebar, ChatComponents, ChatModels,
ModelSheet, Markdown, Background ModelSheet, Markdown, Background
db/ Room offline cache: KaizenDatabase, Entities, DAOs, db/ Room offline cache: KaizenDatabase, Entities, DAOs,
@ -174,7 +174,7 @@ Send message → in-memory during streaming → Room flush after stream ends
- `db/KaizenDatabase.kt` — singleton via `companion object` (double-checked locking), thread-safe - `db/KaizenDatabase.kt` — singleton via `companion object` (double-checked locking), thread-safe
- `db/ConversationRepository.kt``observeAll()` (Flow of ConversationSummary), `refresh()` (server → Room), `allUnlockedIds()` - `db/ConversationRepository.kt``observeAll()` (Flow of ConversationSummary), `refresh()` (server → Room), `allUnlockedIds()`
- `db/MessageRepository.kt``observeMessages()`, `getCached()`, `fetchAndCache()`, `cacheMessages()`, `prefetchMissing()` (background prefetch for offline) - `db/MessageRepository.kt``observeMessages()`, `getCached()`, `fetchAndCache()`, `cacheMessages()`, `prefetchMissing()` (background prefetch for offline)
- `db/Converters.kt``List<Attachment>` + `List<SearchSource>` ↔ JSON string via kotlinx.serialization - `db/Converters.kt``List<Attachment>` ↔ JSON string via kotlinx.serialization
- `db/Mappers.kt``ConversationSummary``ConversationEntity`, `StoredMessage``MessageEntity` - `db/Mappers.kt``ConversationSummary``ConversationEntity`, `StoredMessage``MessageEntity`
**AGP 9.x compat:** `android.disallowKotlinSourceSets=false` in `gradle.properties` (KSP issue [#2729](https://github.com/google/ksp/issues/2729)). **AGP 9.x compat:** `android.disallowKotlinSourceSets=false` in `gradle.properties` (KSP issue [#2729](https://github.com/google/ksp/issues/2729)).
@ -191,8 +191,6 @@ All backend work for the app is done. `pnpm test:run` green (774+), `tsc` + `lin
- **`GET /api/v1/me`** — identity + `defaultModel` for native clients. - **`GET /api/v1/me`** — identity + `defaultModel` for native clients.
- **`GET /api/v1/models`** — Bearer-authed, returns filtered model catalog. - **`GET /api/v1/models`** — Bearer-authed, returns filtered model catalog.
- **`/api/v1/conversations`** — thin re-exports of canonical handlers, Bearer-authed. - **`/api/v1/conversations`** — thin re-exports of canonical handlers, Bearer-authed.
- **`POST /api/v1/auth/unlock`** — Bearer-authed, returns unlock token in response body (not cookie) for native clients. Rate-limited 10/5min per user.
- **`GET /api/v1/conversations/[id]`** — accepts `X-Unlock-Token` header alongside the existing `kaizen_unlock` cookie for locked conversations.
- **`GET /api/v1/meta`** — capability handshake. - **`GET /api/v1/meta`** — capability handshake.
- **App-devices card** (`components/settings/app-tokens-card.tsx`) — see/revoke signed-in devices on `/settings/security`. - **App-devices card** (`components/settings/app-tokens-card.tsx`) — see/revoke signed-in devices on `/settings/security`.
- **API versioning**`/api/v1/` for outward contract, additive-only within v1. Web-internal endpoints stay unversioned. - **API versioning**`/api/v1/` for outward contract, additive-only within v1. Web-internal endpoints stay unversioned.
@ -223,7 +221,7 @@ All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKo
| `d468ec8` | **Theme switching wired** — all 7 files migrated from hardcoded Amber to `LocalKaizenAccent.current` | | `d468ec8` | **Theme switching wired** — all 7 files migrated from hardcoded Amber to `LocalKaizenAccent.current` |
| `cad445f` | **i18n cleanup** — replaced all remaining hardcoded German strings with `stringResource()` | | `cad445f` | **i18n cleanup** — replaced all remaining hardcoded German strings with `stringResource()` |
| `5c54b9b` | **ToolEventsBlock rewrite** — state machine matching web frontend (thinking/analyzing/start/end/error) | | `5c54b9b` | **ToolEventsBlock rewrite** — state machine matching web frontend (thinking/analyzing/start/end/error) |
| `da01b57` | **Attachment picker menu** — three floating GlassSurface pills (camera/gallery/file) with colored round icons, slide animation, dismiss-on-tap-outside. Positioned bottom-left above the + button. Camera uses `TakePicturePreview`, gallery filters `image/*`, file allows `*/*` | | `0948931` | **Attachment picker menu** — camera capture, gallery (image/*), and file picker (*/*) via dropdown |
**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
@ -256,13 +254,13 @@ These are what separate "prototype" from "daily-driver":
- [ ] **Conversation rename/delete** from the sidebar (currently read-only) - [ ] **Conversation rename/delete** from the sidebar (currently read-only)
- [ ] **Conversation pin/unpin** from the sidebar - [ ] **Conversation pin/unpin** from the sidebar
- [x] **In-app unlock for locked conversations** — BiometricPrompt with CryptoObject (AES key in TEE/StrongBox), hardware-enforced biometric auth → server unlock token via `POST /api/v1/auth/unlock` → fetch messages with `X-Unlock-Token` header - [ ] **In-app unlock for locked conversations** (currently skipped with "Gesperrter Chat" label)
### 3. Security hardening ### 3. Security hardening
- [ ] **Auto-logout on 401** — if the token is rejected, route to login screen instead of showing error banner - [ ] **Auto-logout on 401** — if the token is rejected, route to login screen instead of showing error banner
- [ ] **Server version check** — call `/api/v1/meta` after login to detect server skew and warn cleanly - [ ] **Server version check** — call `/api/v1/meta` after login to detect server skew and warn cleanly
- [x] **Biometric lock** (`BiometricPrompt` + KeyStore/CryptoObject) — implemented for conversation unlock; `setUserAuthenticationRequired(true)` + `setInvalidatedByBiometricEnrollment(true)` ensures hardware-enforced biometric, not bypassable even on rooted devices - [ ] **Biometric lock** (`BiometricPrompt` + KeyStore binding) — see `LATER.md` §1C
- [ ] **`FLAG_SECURE`** on sensitive screens — see `LATER.md` §1B - [ ] **`FLAG_SECURE`** on sensitive screens — see `LATER.md` §1B
### 4. Features for parity with the web ### 4. Features for parity with the web

View file

@ -60,8 +60,6 @@ dependencies {
// Room offline cache (local SQLite read-cache for conversations + messages) // Room offline cache (local SQLite read-cache for conversations + messages)
implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
// Biometric auth (fingerprint/face unlock) for locked conversations — hardware-enforced via TEE/StrongBox
implementation(libs.androidx.biometric)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.junit4)

View file

@ -1,158 +0,0 @@
package dev.kaizen.app.auth
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
import android.util.Log
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
private const val TAG = "BiometricUnlock"
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
private const val KEY_ALIAS = "kaizen_biometric_unlock"
/**
* Hardware-enforced biometric unlock using Android KeyStore + CryptoObject.
*
* A symmetric AES key is generated in the TEE/StrongBox with
* [KeyGenParameterSpec.Builder.setUserAuthenticationRequired] the key
* material never leaves the secure hardware and can only be used after
* successful biometric authentication. [BiometricPrompt] receives a
* [Cipher] backed by this key as its [BiometricPrompt.CryptoObject];
* the Cipher is unlocked by the TEE only after the biometric check
* passes. Even on rooted devices the key cannot be extracted or used
* without a real biometric event.
*
* If biometric enrollment changes (new fingerprint added), the key is
* invalidated ([setInvalidatedByBiometricEnrollment]) and automatically
* re-generated on the next attempt.
*/
object BiometricUnlock {
sealed interface Result {
data object Success : Result
data object NotAvailable : Result
data class Failed(val message: String) : Result
}
fun isAvailable(activity: FragmentActivity): Boolean {
val bm = BiometricManager.from(activity)
return bm.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
BiometricManager.BIOMETRIC_SUCCESS
}
fun authenticate(
activity: FragmentActivity,
title: String,
subtitle: String,
negativeButtonText: String,
onResult: (Result) -> Unit,
) {
if (!isAvailable(activity)) {
onResult(Result.NotAvailable)
return
}
val cipher = try {
getCipher()
} catch (e: KeyPermanentlyInvalidatedException) {
Log.w(TAG, "Key invalidated (biometric enrollment changed), regenerating")
deleteKey()
try {
getCipher()
} catch (e2: Exception) {
Log.e(TAG, "Failed to regenerate key", e2)
onResult(Result.Failed(e2.message ?: "KeyStore error"))
return
}
} catch (e: Exception) {
Log.e(TAG, "Failed to init cipher", e)
onResult(Result.Failed(e.message ?: "KeyStore error"))
return
}
val cryptoObject = BiometricPrompt.CryptoObject(cipher)
val executor = ContextCompat.getMainExecutor(activity)
val prompt = BiometricPrompt(activity, executor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
val authedCipher = result.cryptoObject?.cipher
if (authedCipher != null) {
onResult(Result.Success)
} else {
onResult(Result.Failed("CryptoObject missing after auth"))
}
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
if (errorCode == BiometricPrompt.ERROR_USER_CANCELED ||
errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON ||
errorCode == BiometricPrompt.ERROR_CANCELED
) {
onResult(Result.Failed("cancelled"))
} else {
onResult(Result.Failed(errString.toString()))
}
}
override fun onAuthenticationFailed() {
// Called on each failed attempt (wrong finger), prompt stays open.
// Only onAuthenticationError terminates the flow.
}
})
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setNegativeButtonText(negativeButtonText)
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.build()
prompt.authenticate(promptInfo, cryptoObject)
}
private fun getOrCreateKey(): SecretKey {
val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey }
val builder = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_CBC)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
.setUserAuthenticationRequired(true)
.setInvalidatedByBiometricEnrollment(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
builder.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
}
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER)
.apply { init(builder.build()) }
.generateKey()
return keyStore.getKey(KEY_ALIAS, null) as SecretKey
}
private fun getCipher(): Cipher {
val key = getOrCreateKey()
return Cipher.getInstance("${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_CBC}/${KeyProperties.ENCRYPTION_PADDING_PKCS7}")
.apply { init(Cipher.ENCRYPT_MODE, key) }
}
private fun deleteKey() {
try {
val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
keyStore.deleteEntry(KEY_ALIAS)
} catch (_: Exception) { }
}
}

View file

@ -46,7 +46,7 @@ fun MeshBackground(content: @Composable BoxScope.() -> Unit) {
val accent = LocalKaizenAccent.current val accent = LocalKaizenAccent.current
val base = MaterialTheme.colorScheme.background val base = MaterialTheme.colorScheme.background
// Blobs are subtler in light mode. // Blobs are subtler in light mode.
val factor = if (dark) 1f else 0.65f val factor = if (dark) 1f else 0.55f
val transition = rememberInfiniteTransition(label = "blobs") val transition = rememberInfiniteTransition(label = "blobs")
val drift by transition.animateFloat( val drift by transition.animateFloat(
initialValue = 0f, initialValue = 0f,
@ -65,14 +65,14 @@ fun MeshBackground(content: @Composable BoxScope.() -> Unit) {
drawRect(brush = ditherBrush, alpha = DITHER_ALPHA) drawRect(brush = ditherBrush, alpha = DITHER_ALPHA)
}, },
) { ) {
Blob(accent.blob1, 360.dp, (-60f + drift * 30f).dp, (-40f + drift * 24f).dp, 0.42f * factor) Blob(accent.blob1, 360.dp, (-60f + drift * 30f).dp, (-40f + drift * 24f).dp, 0.30f * factor)
Blob(accent.blob2, 380.dp, (170f - drift * 44f).dp, (150f + drift * 40f).dp, 0.36f * factor) Blob(accent.blob2, 380.dp, (170f - drift * 44f).dp, (150f + drift * 40f).dp, 0.24f * factor)
Blob(accent.blob3, 320.dp, (220f + drift * 36f).dp, (80f - drift * 28f).dp, 0.30f * factor) Blob(accent.blob4, 300.dp, (30f + drift * 50f).dp, (560f - drift * 36f).dp, 0.22f * factor)
Blob(accent.blob4, 300.dp, (30f + drift * 50f).dp, (560f - drift * 36f).dp, 0.34f * factor) // Vignette: darken the edges so the center comes forward (dark mode only).
if (dark) { if (dark) {
Box( Box(
Modifier.fillMaxSize().background( Modifier.fillMaxSize().background(
Brush.radialGradient(listOf(Color.Transparent, base.copy(alpha = 0.40f))), Brush.radialGradient(listOf(Color.Transparent, base.copy(alpha = 0.55f))),
), ),
) )
} }
@ -137,7 +137,8 @@ fun KaizenOrb(
.pointerInput(Unit) {}, .pointerInput(Unit) {},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// --- LAYER 1: Ambient Outer Halo --- // --- LAYER 1: Ambient Outer Halo (Subtle larger glow) ---
// This is the ONLY layer that is blurred, providing a soft background glow.
Box( Box(
Modifier Modifier
.size(size * 1.5f) .size(size * 1.5f)
@ -146,143 +147,137 @@ fun KaizenOrb(
.background( .background(
Brush.radialGradient( Brush.radialGradient(
colors = if (recording) { colors = if (recording) {
listOf(accent.primary.copy(alpha = 0.45f), Color.Transparent) listOf(Color(0xFFE0A23E).copy(alpha = 0.45f), Color.Transparent)
} else { } else {
listOf(accent.primary.copy(alpha = if (streaming) 0.50f else 0.28f), Color.Transparent) listOf(accent.primary.copy(alpha = if (streaming) 0.55f else 0.32f), Color.Transparent)
} }
), ),
CircleShape, CircleShape,
), ),
) )
// --- LAYER 2: Glass body — transparent tint so background bleeds through --- // --- LAYER 2: Glass Sphere body with refracted backgrounds & conic caustics ---
Canvas(modifier = Modifier.fillMaxSize()) { // DRAWN PERFECTLY SHARP (No blur modifier on the Canvas!) to ensure razor-sharp,
// solid circular boundaries that give the orb its heavy, crystal-glass volume.
Canvas(
modifier = Modifier.fillMaxSize()
) {
val w = size.toPx() val w = size.toPx()
val h = size.toPx() val h = size.toPx()
val radius = w / 2f val radius = w / 2f
val shiftX = -tiltX * 0.12f * w
val shiftY = tiltY * 0.12f * h
val lightCenter = Offset(w * 0.30f + shiftX, h * 0.25f + shiftY)
// Base: very subtle theme tint (glass, not paint) // Shift centers slightly in direction of gravity to simulate holographic refractive parallax
val centerShiftX = -tiltX * 0.12f * w
val centerShiftY = tiltY * 0.12f * h
// 1. Base Gradient Fill (Refraction glow matching active theme)
val baseGradientCenter = Offset(w * 0.35f + centerShiftX, h * 0.30f + centerShiftY)
drawCircle( drawCircle(
brush = Brush.radialGradient( brush = Brush.radialGradient(
colorStops = arrayOf( colorStops = arrayOf(
0.0f to accent.primary.copy(alpha = if (isDark) 0.22f else 0.15f), 0.0f to Color(0xFFFFF1D6), // Ultra bright glowing cream-white core
0.6f to accent.primaryDeep.copy(alpha = if (isDark) 0.18f else 0.10f), 0.35f to Color(0xFFFFCC80), // Rich luminous warm amber
1.0f to accent.primaryDeep.copy(alpha = if (isDark) 0.30f else 0.20f), 0.75f to accent.primaryDeep, // Saturated accent
), 1.0f to Color(0xFF8D4F00) // Deep bronze-amber shadow edge (creates high contrast!)
center = lightCenter,
radius = radius * 1.1f,
), ),
center = baseGradientCenter,
radius = radius * 1.1f
)
) )
// Top-left brightness (light source) // 2. Heavy 3D Inner Volume Shadow (Sphere Bottom Shading)
drawCircle( // Adds massive physical 3D weight by darkening the sphere's lower edge.
brush = Brush.radialGradient(
colorStops = arrayOf(
0.0f to Color.White.copy(alpha = if (isDark) 0.25f else 0.40f),
0.5f to Color.White.copy(alpha = 0.05f),
1.0f to Color.Transparent,
),
center = lightCenter,
radius = radius * 0.8f,
),
)
// Bottom-right accent bleed
drawCircle(
brush = Brush.radialGradient(
colorStops = arrayOf(
0.0f to accent.primary.copy(alpha = if (isDark) 0.45f else 0.35f),
0.6f to Color.Transparent,
),
center = Offset(w * 0.72f, h * 0.78f),
radius = radius * 0.65f,
),
)
// Bottom shadow for volume (subtle, not harsh)
drawCircle( drawCircle(
brush = Brush.radialGradient( brush = Brush.radialGradient(
colorStops = arrayOf( colorStops = arrayOf(
0.0f to Color.Transparent, 0.0f to Color.Transparent,
0.55f to Color.Transparent, 0.5f to Color.Transparent,
0.85f to Color.Black.copy(alpha = if (isDark) 0.20f else 0.08f), 1.0f to Color(0xFF4A2300).copy(alpha = 0.85f) // Deep dark brown shadow edge
1.0f to Color.Black.copy(alpha = if (isDark) 0.35f else 0.15f),
),
center = Offset(w * 0.45f, h * 0.40f),
radius = radius,
), ),
center = Offset(w * 0.35f + centerShiftX, h * 0.30f + centerShiftY),
radius = radius * 1.0f
)
) )
// Caustic conic layer (depth structure) // Shift inner core in the opposite direction (parallax depth)
val coreShiftX = tiltX * 0.05f * w val coreShiftX = tiltX * 0.05f * w
val coreShiftY = -tiltY * 0.05f * h val coreShiftY = -tiltY * 0.05f * h
val coreCenter = Offset(w / 2f + coreShiftX, h / 2f + coreShiftY) val coreCenter = Offset(w / 2f + coreShiftX, h / 2f + coreShiftY)
drawCircle(
brush = Brush.sweepGradient( // 3. Conic Caustic Layer #1 (SweepGradient at 30° blended via BlendMode.Overlay)
val sweepBrush1 = Brush.sweepGradient(
colorStops = arrayOf( colorStops = arrayOf(
0.0f to Color.Transparent, 0.0f to Color.Transparent,
0.16f to accent.primary.copy(alpha = 0.30f), 0.16f to accent.primary.copy(alpha = 0.65f),
0.33f to Color.White.copy(alpha = 0.20f), 0.33f to Color.White.copy(alpha = 0.55f),
0.50f to Color.Transparent, 0.50f to Color.Transparent,
0.66f to accent.primaryDeep.copy(alpha = 0.25f), 0.66f to accent.primaryDeep.copy(alpha = 0.50f),
1.0f to Color.Transparent, 1.0f to Color.Transparent
),
center = coreCenter,
), ),
center = coreCenter
)
drawCircle(
brush = sweepBrush1,
radius = radius * 0.84f, radius = radius * 0.84f,
blendMode = BlendMode.Overlay, blendMode = BlendMode.Overlay
)
// 4. Conic Caustic Layer #2 (Secondary parallax SweepGradient at 200° via BlendMode.Softlight)
val sweepBrush2 = Brush.sweepGradient(
colorStops = arrayOf(
0.0f to Color.Transparent,
0.25f to Color.White.copy(alpha = 0.40f),
0.50f to Color.Transparent,
0.75f to accent.primary.copy(alpha = 0.50f),
1.0f to Color.Transparent
),
center = coreCenter
)
drawCircle(
brush = sweepBrush2,
radius = radius * 0.72f,
blendMode = BlendMode.Softlight
) )
} }
// --- LAYER 3: Specular highlights + rim --- // --- LAYER 3: Specular Highlights & Crisp Rim (Sharp reflections) ---
Canvas(modifier = Modifier.fillMaxSize()) { Canvas(modifier = Modifier.fillMaxSize()) {
val w = size.toPx() val w = size.toPx()
val h = size.toPx() val h = size.toPx()
val radius = w / 2f val radius = w / 2f
val specShiftX = -tiltX * 0.08f * w
val specShiftY = tiltY * 0.08f * h
// Primary specular — elliptical for realism // Specular shifts with gravity to give a high-gloss interactive reflection
drawOval( val specularShiftX = -tiltX * 0.08f * w
val specularShiftY = tiltY * 0.08f * h
val specularCenter1 = Offset(w * 0.28f + specularShiftX, h * 0.22f + specularShiftY)
val specularCenter2 = Offset(w * 0.70f + specularShiftX, h * 0.82f + specularShiftY)
// 1. Primary "wet" gloss highlight (top-left) - extremely bright, solid, and sharp!
drawCircle(
brush = Brush.radialGradient( brush = Brush.radialGradient(
colors = listOf( colors = listOf(Color.White, Color.Transparent),
Color.White.copy(alpha = if (isDark) 0.70f else 0.90f), center = specularCenter1,
Color.Transparent, radius = radius * 0.38f
), )
center = Offset(w * 0.30f + specShiftX, h * 0.24f + specShiftY),
radius = radius * 0.35f,
),
topLeft = Offset(w * 0.12f + specShiftX, h * 0.10f + specShiftY),
size = androidx.compose.ui.geometry.Size(radius * 0.7f, radius * 0.55f),
) )
// Secondary bounce (bottom-right, warm) // 2. Secondary ambient bounce highlight (bottom-right edge glow)
drawCircle( drawCircle(
brush = Brush.radialGradient( brush = Brush.radialGradient(
colors = listOf( colors = listOf(
accent.primary.copy(alpha = if (isDark) 0.50f else 0.35f), Color(0xFFFFD180).copy(alpha = 0.80f), // Bright warm amber light refraction
Color.Transparent, Color.Transparent
),
center = Offset(w * 0.72f + specShiftX, h * 0.80f + specShiftY),
radius = radius * 0.25f,
), ),
center = specularCenter2,
radius = radius * 0.28f
)
) )
// Crisp rim hairline // 3. Ultra-sharp outer rim (1px hairline highlight for crisp crystal reflection)
drawCircle( drawCircle(
color = Color.White.copy(alpha = if (isDark) 0.10f else 0.30f), color = Color.White.copy(alpha = if (isDark) 0.12f else 0.45f),
radius = radius - 0.5f, radius = radius - 0.5f,
style = Stroke(width = 1f), style = Stroke(width = 1.5f)
)
// Inner rim (inset edge for refraction feel)
drawCircle(
color = Color.White.copy(alpha = if (isDark) 0.05f else 0.12f),
radius = radius - 2f,
style = Stroke(width = 0.5f),
) )
} }
} }

View file

@ -7,11 +7,6 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@ -68,7 +63,6 @@ import dev.kaizen.app.net.Attachment
import dev.kaizen.app.ui.effect.GlassSurface import dev.kaizen.app.ui.effect.GlassSurface
import dev.kaizen.app.ui.effect.GlassTiers import dev.kaizen.app.ui.effect.GlassTiers
import dev.kaizen.app.ui.effect.KaizenShadows import dev.kaizen.app.ui.effect.KaizenShadows
import dev.kaizen.app.ui.effect.kaizenShadow
import dev.kaizen.app.ui.motion.Durations import dev.kaizen.app.ui.motion.Durations
import dev.kaizen.app.ui.shape.KaizenShapes import dev.kaizen.app.ui.shape.KaizenShapes
import dev.kaizen.app.ui.theme.LocalKaizenAccent import dev.kaizen.app.ui.theme.LocalKaizenAccent
@ -88,20 +82,6 @@ import androidx.compose.material.icons.rounded.PictureAsPdf
import androidx.compose.material.icons.rounded.VideoFile import androidx.compose.material.icons.rounded.VideoFile
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Mic
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.ui.platform.LocalClipboard
import dev.kaizen.app.haptics.rememberHaptics
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Psychology
import androidx.compose.material.icons.rounded.Tune
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@ -162,24 +142,14 @@ fun EmptyHero(userName: String, onPick: (String) -> Unit, modifier: Modifier = M
} }
@Composable @Composable
fun ModePillsRow( fun ModePillsRow(selected: Set<Int>, onToggle: (Int) -> Unit, modifier: Modifier = Modifier) {
activeMode: Int?,
onToggle: (Int) -> Unit,
reasoningPreset: ReasoningPreset,
onReasoningChange: (ReasoningPreset) -> Unit,
samplingPrefs: SamplingPrefs,
onSamplingChange: (SamplingPrefs) -> Unit,
modifier: Modifier = Modifier,
) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val accent = LocalKaizenAccent.current
var showReasoningMenu by remember { mutableStateOf(false) } val glassBorder = remember(isDark) {
var showSamplingPopup by remember { mutableStateOf(false) } if (isDark) Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.14f), Color.White.copy(alpha = 0.04f)))
else Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.50f), Color.Black.copy(alpha = 0.06f)))
val inactiveBg = if (isDark) cs.surface.copy(alpha = 0.35f) else cs.surface.copy(alpha = 0.55f) }
val inactiveBorder = if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f)
Row( Row(
modifier modifier
@ -188,98 +158,25 @@ fun ModePillsRow(
.padding(horizontal = 14.dp), .padding(horizontal = 14.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
// Reasoning pill (dropdown) chatModes.forEach { mode ->
Box {
Row(
Modifier
.clip(KaizenShapes.pill)
.background(inactiveBg)
.border(1.2.dp, inactiveBorder, KaizenShapes.pill)
.clickable { showReasoningMenu = true }
.padding(horizontal = 13.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Rounded.Psychology, null, tint = cs.onSurfaceVariant, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(reasoningPreset.label), color = cs.onSurfaceVariant, fontSize = 13.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.width(4.dp))
Icon(Icons.Rounded.ExpandMore, null, tint = cs.onSurfaceVariant.copy(alpha = 0.5f), modifier = Modifier.size(14.dp))
}
DropdownMenu(expanded = showReasoningMenu, onDismissRequest = { showReasoningMenu = false }) {
ReasoningPreset.entries.forEach { preset ->
DropdownMenuItem(
text = {
Text(
stringResource(preset.label),
fontSize = 14.sp,
color = if (preset == reasoningPreset) accent.primary else cs.onBackground,
fontWeight = if (preset == reasoningPreset) FontWeight.SemiBold else FontWeight.Normal,
)
},
onClick = { onReasoningChange(preset); showReasoningMenu = false },
)
}
}
}
// Sampling pill (popover)
Box {
val anyEnabled = samplingPrefs.temperature.enabled || samplingPrefs.topP.enabled || samplingPrefs.topK.enabled
Row(
Modifier
.clip(KaizenShapes.pill)
.background(if (anyEnabled) accent.primary.copy(alpha = if (isDark) 0.18f else 0.14f) else inactiveBg)
.border(1.2.dp, if (anyEnabled) accent.primary.copy(alpha = 0.4f) else inactiveBorder, KaizenShapes.pill)
.clickable { showSamplingPopup = !showSamplingPopup }
.padding(horizontal = 13.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Rounded.Tune, null, tint = if (anyEnabled) accent.primary else cs.onSurfaceVariant, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.mode_sampling), color = if (anyEnabled) cs.onBackground else cs.onSurfaceVariant, fontSize = 13.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.width(4.dp))
Icon(Icons.Rounded.ExpandMore, null, tint = cs.onSurfaceVariant.copy(alpha = 0.5f), modifier = Modifier.size(14.dp))
}
DropdownMenu(expanded = showSamplingPopup, onDismissRequest = { showSamplingPopup = false }) {
Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp).width(240.dp)) {
SamplingRow(
label = stringResource(R.string.sampling_temperature),
param = samplingPrefs.temperature, min = 0f, max = 2f, steps = 200,
onUpdate = { onSamplingChange(samplingPrefs.copy(temperature = it)) },
)
Spacer(Modifier.height(12.dp))
SamplingRow(
label = stringResource(R.string.sampling_top_p),
param = samplingPrefs.topP, min = 0f, max = 1f, steps = 100,
onUpdate = { onSamplingChange(samplingPrefs.copy(topP = it)) },
)
Spacer(Modifier.height(12.dp))
SamplingRow(
label = stringResource(R.string.sampling_top_k),
param = samplingPrefs.topK, min = 1f, max = 100f, steps = 99,
onUpdate = { onSamplingChange(samplingPrefs.copy(topK = it)) },
)
}
}
}
// Mode pills (Bild, Suche, Video, Audio)
chatModes.filter { it.labelRes != R.string.mode_standard && it.labelRes != R.string.mode_sampling }.forEach { mode ->
val label = stringResource(mode.labelRes) val label = stringResource(mode.labelRes)
val active = mode.labelRes == activeMode val active = mode.labelRes in selected
val backgroundFill = if (active) accent.primary.copy(alpha = if (isDark) 0.18f else 0.14f) else inactiveBg val backgroundFill = if (active) {
val borderColor = if (active) accent.primary.copy(alpha = 0.4f) else inactiveBorder if (isDark) cs.surface.copy(alpha = 0.8f) else cs.surface.copy(alpha = 0.88f)
} else {
if (isDark) cs.surface.copy(alpha = 0.35f) else cs.surface.copy(alpha = 0.55f)
}
Row( Row(
Modifier Modifier
.clip(KaizenShapes.pill) .clip(KaizenShapes.pill)
.background(backgroundFill) .background(backgroundFill)
.border(1.2.dp, borderColor, KaizenShapes.pill) .border(1.2.dp, glassBorder, KaizenShapes.pill)
.clickable { onToggle(mode.labelRes) } .clickable { onToggle(mode.labelRes) }
.padding(horizontal = 13.dp, vertical = 8.dp), .padding(horizontal = 13.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon(mode.icon, null, tint = if (active) accent.primary else cs.onSurfaceVariant, modifier = Modifier.size(16.dp)) Icon(mode.icon, null, tint = if (active) cs.onBackground else cs.onSurfaceVariant, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text(label, color = if (active) cs.onBackground else cs.onSurfaceVariant, fontSize = 13.sp, fontWeight = if (active) FontWeight.SemiBold else FontWeight.Medium) Text(label, color = if (active) cs.onBackground else cs.onSurfaceVariant, fontSize = 13.sp, fontWeight = if (active) FontWeight.SemiBold else FontWeight.Medium)
} }
@ -288,164 +185,42 @@ fun ModePillsRow(
} }
@Composable @Composable
private fun SamplingRow(label: String, param: SamplingParam, min: Float, max: Float, steps: Int, onUpdate: (SamplingParam) -> Unit) { fun MessageRow(message: Message) {
val cs = MaterialTheme.colorScheme
val accent = LocalKaizenAccent.current
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = param.enabled, onCheckedChange = { onUpdate(param.copy(enabled = it)) }, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(label, color = cs.onBackground, fontSize = 13.sp, modifier = Modifier.weight(1f))
Text(
if (max > 2f) param.value.toInt().toString() else "%.2f".format(param.value),
color = cs.onSurfaceVariant, fontSize = 12.sp,
)
}
Slider(
value = param.value, onValueChange = { onUpdate(param.copy(value = it)) },
valueRange = min..max, steps = steps,
enabled = param.enabled,
colors = SliderDefaults.colors(thumbColor = accent.primary, activeTrackColor = accent.primary),
modifier = Modifier.fillMaxWidth(),
)
}
@Composable
fun TokenBadge(used: Int, contextLength: Int, modifier: Modifier = Modifier) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
val accent = LocalKaizenAccent.current
fun formatK(n: Int): String = when {
n >= 1_000_000 -> "${n / 1_000_000}M"
n >= 1_000 -> "${n / 1_000}k"
else -> "$n"
}
GlassSurface(
modifier = modifier,
shape = KaizenShapes.pill,
tintAlpha = GlassTiers.pill(isDark),
shadowStack = KaizenShadows.level1,
cornerRadius = 16.dp,
) {
Row(
Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Rounded.Bolt, null, tint = accent.primary, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(4.dp))
Text(
"${formatK(used)} / ${formatK(contextLength)}",
color = cs.onSurfaceVariant,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
)
}
}
}
@Composable
fun MessageRow(
message: Message,
onCopy: (String) -> Unit = {},
onDelete: ((String) -> Unit)? = null,
onRegenerate: ((String) -> Unit)? = null,
) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val accent = LocalKaizenAccent.current val accent = LocalKaizenAccent.current
val hasAttachments = message.attachments.isNotEmpty() val hasAttachments = message.attachments.isNotEmpty()
val clipboard = LocalClipboard.current
val haptics = rememberHaptics()
val scope = rememberCoroutineScope()
var copied by remember { mutableStateOf(false) }
if (message.role == Role.User) { if (message.role == Role.User) {
Column(horizontalAlignment = Alignment.End, modifier = Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
val shape = KaizenShapes.lg val shape = KaizenShapes.lg
val bgBrush = remember(accent, isDark) { val borderBrush = remember(accent) { Brush.verticalGradient(listOf(accent.primary.copy(alpha = 0.45f), accent.primaryDeep.copy(alpha = 0.15f))) }
Brush.verticalGradient( val bgBrush = remember(accent) { Brush.verticalGradient(listOf(accent.primary.copy(alpha = 0.18f), accent.primaryDeep.copy(alpha = 0.08f))) }
listOf(
if (isDark) accent.primary.copy(alpha = 0.28f) else accent.primary.copy(alpha = 0.16f),
if (isDark) accent.primaryDeep.copy(alpha = 0.22f) else accent.primaryDeep.copy(alpha = 0.10f),
)
)
}
val borderBrush = remember(accent, isDark) {
Brush.verticalGradient(
listOf(
accent.primary.copy(alpha = if (isDark) 0.55f else 0.40f),
accent.primaryDeep.copy(alpha = if (isDark) 0.15f else 0.10f),
)
)
}
val innerHighlight = remember(accent, isDark) {
Brush.verticalGradient(
listOf(
accent.primary.copy(alpha = if (isDark) 0.12f else 0.08f),
Color.Transparent,
)
)
}
Box( Box(
Modifier Modifier
.widthIn(max = 300.dp) .widthIn(max = 300.dp)
.kaizenShadow(stack = KaizenShadows.level1, cornerRadius = 20.dp)
.clip(shape) .clip(shape)
.background(bgBrush) .background(bgBrush)
.background(innerHighlight)
.border(1.2.dp, borderBrush, shape), .border(1.2.dp, borderBrush, shape),
) { ) {
Column { Column {
if (hasAttachments) AttachmentGrid(message.attachments, Modifier.padding(6.dp)) if (hasAttachments) AttachmentGrid(message.attachments, Modifier.padding(6.dp))
if (message.content.isNotBlank()) { if (message.content.isNotBlank()) {
Text( Text(
message.content, color = cs.onBackground, fontSize = 16.sp, lineHeight = 26.sp, message.content, color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = if (hasAttachments) 4.dp else 13.dp, bottom = 13.dp), modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = if (hasAttachments) 4.dp else 11.dp, bottom = 11.dp),
) )
} }
} }
} }
} }
if (!message.streaming) {
Row(
Modifier.padding(top = 4.dp, end = 4.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
MessageActionButton(
icon = if (copied) Icons.Rounded.Check else Icons.Rounded.ContentCopy,
tint = if (copied) Color(0xFF10B981) else cs.onSurfaceVariant.copy(alpha = 0.35f),
) {
haptics.tick()
copied = true
scope.launch {
clipboard.setClipEntry(androidx.compose.ui.platform.ClipEntry(android.content.ClipData.newPlainText("msg", message.content)))
kotlinx.coroutines.delay(1500)
copied = false
}
}
onDelete?.let { del ->
MessageActionButton(Icons.Rounded.Delete, Color(0xFFEF4444).copy(alpha = 0.6f)) {
haptics.tick(); del(message.wireId)
}
}
}
}
}
} else { } else {
Column {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
KaizenOrb(28.dp, streaming = message.streaming) KaizenOrb(28.dp, streaming = message.streaming)
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
Box(Modifier.weight(1f).padding(top = 3.dp)) { Box(Modifier.weight(1f).padding(top = 3.dp)) {
Column { Column {
if (message.reasoning.isNotBlank()) {
ReasoningBlock(message.reasoning, streaming = message.streaming)
Spacer(Modifier.height(8.dp))
}
if (message.tools.isNotEmpty()) { if (message.tools.isNotEmpty()) {
ToolEventsBlock(message.tools, streaming = message.streaming) ToolEventsBlock(message.tools, streaming = message.streaming)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@ -456,6 +231,10 @@ fun MessageRow(
} }
if (message.thinking) TypingDots() if (message.thinking) TypingDots()
else MarkdownText(text = message.content, streaming = message.streaming) else MarkdownText(text = message.content, streaming = message.streaming)
if (message.reasoning.isNotBlank()) {
Spacer(Modifier.height(8.dp))
ReasoningBlock(message.reasoning, streaming = message.streaming)
}
if (message.sources.isNotEmpty()) { if (message.sources.isNotEmpty()) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
SourcesBlock(message.sources, query = message.query) SourcesBlock(message.sources, query = message.query)
@ -463,49 +242,6 @@ fun MessageRow(
} }
} }
} }
if (!message.streaming && !message.thinking && message.content.isNotBlank()) {
Row(
Modifier.padding(start = 38.dp, top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
MessageActionButton(
icon = if (copied) Icons.Rounded.Check else Icons.Rounded.ContentCopy,
tint = if (copied) Color(0xFF10B981) else cs.onSurfaceVariant.copy(alpha = 0.35f),
) {
haptics.tick()
copied = true
scope.launch {
clipboard.setClipEntry(androidx.compose.ui.platform.ClipEntry(android.content.ClipData.newPlainText("msg", message.content)))
kotlinx.coroutines.delay(1500)
copied = false
}
}
onRegenerate?.let { regen ->
MessageActionButton(Icons.Rounded.Refresh, cs.onSurfaceVariant.copy(alpha = 0.35f)) {
haptics.tick(); regen(message.wireId)
}
}
onDelete?.let { del ->
MessageActionButton(Icons.Rounded.Delete, Color(0xFFEF4444).copy(alpha = 0.6f)) {
haptics.tick(); del(message.wireId)
}
}
}
}
}
}
}
@Composable
private fun MessageActionButton(icon: ImageVector, tint: Color, onClick: () -> Unit) {
Box(
Modifier
.size(30.dp)
.clip(KaizenShapes.circle)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(icon, null, tint = tint, modifier = Modifier.size(16.dp))
} }
} }
@ -552,13 +288,10 @@ fun ChatInput(
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val hasContent = value.isNotBlank() || pendingFiles.any { it.uploaded != null }
val canSend = enabled && hasContent && pendingFiles.none { it.uploading }
GlassSurface( GlassSurface(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp), .padding(horizontal = 12.dp),
shape = KaizenShapes.xl, shape = KaizenShapes.xl,
tintAlpha = GlassTiers.input(isDark), tintAlpha = GlassTiers.input(isDark),
shadowStack = KaizenShadows.level2, shadowStack = KaizenShadows.level2,
@ -567,35 +300,39 @@ fun ChatInput(
Column { Column {
if (pendingFiles.isNotEmpty()) { if (pendingFiles.isNotEmpty()) {
Row( Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(start = 14.dp, end = 14.dp, top = 10.dp), Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(start = 12.dp, end = 12.dp, top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
pendingFiles.forEach { pf -> PendingFileChip(pf, onRemove = { onRemoveFile(pf.id) }) } pendingFiles.forEach { pf -> PendingFileChip(pf, onRemove = { onRemoveFile(pf.id) }) }
} }
} }
Row(Modifier.padding(start = 6.dp, end = 8.dp, top = 10.dp, bottom = 10.dp), verticalAlignment = Alignment.Bottom) { Row(Modifier.padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(42.dp).clip(KaizenShapes.circle).clickable(onClick = onAddClick), contentAlignment = Alignment.Center) { Box(Modifier.size(40.dp).clip(KaizenShapes.circle).clickable(onClick = onAddClick), contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.Add, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp)) Icon(Icons.Rounded.Add, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp))
} }
Box(Modifier.weight(1f).heightIn(min = 42.dp).padding(horizontal = 6.dp), contentAlignment = Alignment.CenterStart) { Box(Modifier.weight(1f).heightIn(min = 36.dp).padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart) {
if (value.isEmpty()) Text(stringResource(R.string.chat_input_placeholder), color = cs.onSurfaceVariant.copy(alpha = 0.6f), fontSize = 15.sp) if (value.isEmpty()) Text(stringResource(R.string.chat_input_placeholder), color = cs.onSurfaceVariant, fontSize = 16.sp)
val accent = LocalKaizenAccent.current val accent = LocalKaizenAccent.current
BasicTextField( BasicTextField(
value = value, onValueChange = onValueChange, value = value, onValueChange = onValueChange,
textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 24.sp), textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp),
cursorBrush = SolidColor(accent.primary), maxLines = 6, modifier = Modifier.fillMaxWidth(), cursorBrush = SolidColor(accent.primary), maxLines = 6, modifier = Modifier.fillMaxWidth(),
) )
} }
GhostIconButton(Icons.Rounded.Call, stringResource(R.string.chat_call))
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
if (hasContent) { val canSend = enabled && (value.isNotBlank() || pendingFiles.any { it.uploaded != null }) && pendingFiles.none { it.uploading }
SendButton(enabled = canSend, onClick = onSend) SendButton(enabled = canSend, onClick = onSend)
} else {
Box(Modifier.size(44.dp).clip(KaizenShapes.circle), contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.Mic, stringResource(R.string.chat_call), tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp))
}
} }
} }
} }
}
@Composable
private fun GhostIconButton(icon: ImageVector, contentDescription: String) {
val cs = MaterialTheme.colorScheme
Box(Modifier.size(40.dp).clip(KaizenShapes.circle), contentAlignment = Alignment.Center) {
Icon(icon, contentDescription, tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp))
} }
} }

View file

@ -18,25 +18,6 @@ import dev.kaizen.app.net.ToolEvent
enum class Role { User, Assistant } enum class Role { User, Assistant }
enum class ReasoningPreset(val label: Int, val effort: String?, val throughput: Boolean) {
Instant(R.string.reasoning_instant, "none", true),
None(R.string.reasoning_none, "none", false),
Minimal(R.string.reasoning_minimal, "minimal", false),
Low(R.string.reasoning_low, "low", false),
Standard(R.string.reasoning_standard, null, false),
Medium(R.string.reasoning_medium, "medium", false),
High(R.string.reasoning_high, "high", false),
XHigh(R.string.reasoning_xhigh, "xhigh", false),
}
data class SamplingParam(val enabled: Boolean, val value: Float)
data class SamplingPrefs(
val temperature: SamplingParam = SamplingParam(false, 1f),
val topP: SamplingParam = SamplingParam(false, 1f),
val topK: SamplingParam = SamplingParam(false, 40f),
)
data class Message( data class Message(
val id: Long, val id: Long,
val role: Role, val role: Role,

View file

@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -24,8 +23,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material.icons.rounded.Menu import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -99,10 +96,7 @@ fun ChatScreen(
var input by remember { mutableStateOf("") } var input by remember { mutableStateOf("") }
var isStreaming by remember { mutableStateOf(false) } var isStreaming by remember { mutableStateOf(false) }
var nextId by remember { mutableStateOf(0L) } var nextId by remember { mutableStateOf(0L) }
var activeMode by remember { mutableStateOf<Int?>(null) } var selectedModes by remember { mutableStateOf(setOf(R.string.mode_standard)) }
var reasoningPreset by remember { mutableStateOf(ReasoningPreset.Standard) }
var samplingPrefs by remember { mutableStateOf(SamplingPrefs()) }
var usedTokens by remember { mutableStateOf(0) }
// Navigation State // Navigation State
var currentScreen by remember { mutableStateOf(AppScreen.Chat) } var currentScreen by remember { mutableStateOf(AppScreen.Chat) }
@ -123,7 +117,6 @@ fun ChatScreen(
// Conversation persistence: sidebar list from Room (instant), active conversation id // Conversation persistence: sidebar list from Room (instant), active conversation id
var conversationId by remember { mutableStateOf<String?>(null) } var conversationId by remember { mutableStateOf<String?>(null) }
var viewingLockedChat by remember { mutableStateOf(false) }
val conversations by chat.conversationRepo.observeAll().collectAsState(initial = emptyList()) val conversations by chat.conversationRepo.observeAll().collectAsState(initial = emptyList())
var loadError by remember { mutableStateOf<String?>(null) } var loadError by remember { mutableStateOf<String?>(null) }
@ -201,12 +194,7 @@ fun ChatScreen(
session.config?.let { cfg -> session.config?.let { cfg ->
scope.launch { KaizenApi.prewarm(cfg.baseUrl) } scope.launch { KaizenApi.prewarm(cfg.baseUrl) }
} }
onPauseOrDispose { onPauseOrDispose { }
if (viewingLockedChat) {
messages.clear()
viewingLockedChat = false
}
}
} }
fun refreshConversations() { fun refreshConversations() {
@ -218,58 +206,14 @@ fun ChatScreen(
fun newChat() { fun newChat() {
messages.clear() messages.clear()
conversationId = null conversationId = null
viewingLockedChat = false
} }
fun openConversation(summary: ConversationSummary) { fun openConversation(summary: ConversationSummary) {
if (summary.locked) return
val cfg = session.config ?: return val cfg = session.config ?: return
if (summary.locked) {
val activity = context as? androidx.fragment.app.FragmentActivity ?: return
dev.kaizen.app.auth.BiometricUnlock.authenticate(
activity = activity,
title = context.getString(R.string.biometric_title),
subtitle = context.getString(R.string.biometric_subtitle),
negativeButtonText = context.getString(R.string.biometric_cancel),
) { result ->
when (result) {
is dev.kaizen.app.auth.BiometricUnlock.Result.Success -> {
scope.launch {
val unlockToken = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token)
if (unlockToken == null) {
loadError = context.getString(R.string.biometric_failed)
return@launch
}
val stored = KaizenApi.fetchLockedMessages(cfg.baseUrl, cfg.token, summary.id, unlockToken)
if (stored.isNotEmpty()) {
messages.clear()
stored.forEach { m ->
messages.add(
Message(
id = nextId++,
role = if (m.role == "assistant") Role.Assistant else Role.User,
content = m.content,
wireId = m.id,
attachments = m.attachments,
reasoning = m.reasoning ?: "",
sources = m.sources ?: emptyList(),
),
)
}
conversationId = summary.id
viewingLockedChat = true
}
}
}
is dev.kaizen.app.auth.BiometricUnlock.Result.NotAvailable -> {
loadError = context.getString(R.string.biometric_not_available)
}
is dev.kaizen.app.auth.BiometricUnlock.Result.Failed -> { /* user cancelled or failed, do nothing */ }
}
}
return
}
viewingLockedChat = false
scope.launch { scope.launch {
// Instant: load from Room cache first
val cached = chat.messageRepo.observeMessages(summary.id)
messages.clear() messages.clear()
val cachedList = chat.messageRepo.getCached(summary.id) val cachedList = chat.messageRepo.getCached(summary.id)
cachedList.forEach { m -> cachedList.forEach { m ->
@ -280,16 +224,16 @@ fun ChatScreen(
content = m.content, content = m.content,
wireId = m.id, wireId = m.id,
attachments = m.attachments, attachments = m.attachments,
reasoning = m.reasoning ?: "",
sources = m.sources ?: emptyList(),
), ),
) )
} }
conversationId = summary.id conversationId = summary.id
// Background: refresh from server and update Room + UI
val stored = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, summary.id) val stored = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, summary.id)
if (stored.isNotEmpty()) { if (stored.isNotEmpty()) {
val entities = stored.mapIndexed { i, m -> m.toEntity(summary.id, i) } val entities = stored.mapIndexed { i, m -> m.toEntity(summary.id, i) }
chat.messageRepo.cacheMessages(summary.id, entities) chat.messageRepo.cacheMessages(summary.id, entities)
// Only rebuild message list if content actually changed
if (stored.size != cachedList.size || stored.zip(cachedList).any { (s, c) -> s.id != c.id || s.content != c.content }) { if (stored.size != cachedList.size || stored.zip(cachedList).any { (s, c) -> s.id != c.id || s.content != c.content }) {
messages.clear() messages.clear()
stored.forEach { m -> stored.forEach { m ->
@ -300,8 +244,6 @@ fun ChatScreen(
content = m.content, content = m.content,
wireId = m.id, wireId = m.id,
attachments = m.attachments, attachments = m.attachments,
reasoning = m.reasoning ?: "",
sources = m.sources ?: emptyList(),
), ),
) )
} }
@ -342,14 +284,8 @@ fun ChatScreen(
} else null } else null
KaizenApi.chat( KaizenApi.chat(
cfg.baseUrl, cfg.token, cfg.model, history, cfg.baseUrl, cfg.token, cfg.model, history, cfg.speed,
reasoningEffort = reasoningPreset.effort,
preferThroughput = reasoningPreset.throughput,
attachments = uploadedAtts.takeIf { it.isNotEmpty() }, attachments = uploadedAtts.takeIf { it.isNotEmpty() },
webSearch = activeMode == R.string.mode_search,
temperature = samplingPrefs.temperature.takeIf { it.enabled }?.value,
topP = samplingPrefs.topP.takeIf { it.enabled }?.value,
topK = samplingPrefs.topK.takeIf { it.enabled }?.value?.toInt(),
).collect { state -> ).collect { state ->
val idx = messages.indexOfLast { it.id == assistantId } val idx = messages.indexOfLast { it.id == assistantId }
if (idx < 0) return@collect if (idx < 0) return@collect
@ -365,15 +301,9 @@ fun ChatScreen(
query = state.query, query = state.query,
sources = state.sources, sources = state.sources,
) )
if (state.inputTokens > 0 || state.outputTokens > 0) {
usedTokens = state.inputTokens + state.outputTokens
}
} }
val idx = messages.indexOfLast { it.id == assistantId } val idx = messages.indexOfLast { it.id == assistantId }
val finalMsg = if (idx >= 0) messages[idx] else null val finalContent = if (idx >= 0) messages[idx].content else ""
val finalContent = finalMsg?.content ?: ""
val finalReasoning = finalMsg?.reasoning?.takeIf { it.isNotBlank() }
val finalSources = finalMsg?.sources?.takeIf { it.isNotEmpty() }
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false) if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
val convId = convIdDeferred?.await()?.also { conversationId = it } ?: conversationId val convId = convIdDeferred?.await()?.also { conversationId = it } ?: conversationId
@ -387,15 +317,11 @@ fun ChatScreen(
role = "user", content = trimmed, role = "user", content = trimmed,
attachments = uploadedAtts.takeIf { it.isNotEmpty() }, attachments = uploadedAtts.takeIf { it.isNotEmpty() },
), ),
SaveMessage( SaveMessage(id = assistantWireId, parentId = userMsg.wireId, role = "assistant", content = finalContent, model = cfg.model),
id = assistantWireId, parentId = userMsg.wireId,
role = "assistant", content = finalContent, model = cfg.model,
reasoning = finalReasoning,
sources = finalSources,
),
), ),
) )
if (saved) { if (saved) {
// Cache new messages in Room for offline-first
val msgCount = messages.size val msgCount = messages.size
chat.messageRepo.cacheMessages(convId, listOf( chat.messageRepo.cacheMessages(convId, listOf(
MessageEntity( MessageEntity(
@ -407,8 +333,6 @@ fun ChatScreen(
id = assistantWireId, conversationId = convId, id = assistantWireId, conversationId = convId,
role = "assistant", content = finalContent, role = "assistant", content = finalContent,
attachments = emptyList(), sortOrder = msgCount - 1, attachments = emptyList(), sortOrder = msgCount - 1,
reasoning = finalReasoning,
sources = finalSources ?: emptyList(),
), ),
)) ))
if (wasNewConversation) KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId) if (wasNewConversation) KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId)
@ -485,32 +409,7 @@ fun ChatScreen(
newChat() newChat()
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
session.logout() session.logout()
},
onAction = { action ->
val cfg = session.config ?: return@KaizenSidebar
haptics.tick()
scope.launch {
when (action) {
is SidebarAction.Rename -> {
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("title" to action.newTitle))
refreshConversations()
} }
is SidebarAction.Delete -> {
KaizenApi.deleteConversation(cfg.baseUrl, cfg.token, action.id)
if (conversationId == action.id) newChat()
refreshConversations()
}
is SidebarAction.TogglePin -> {
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("pinned" to action.pinned))
refreshConversations()
}
is SidebarAction.ToggleLock -> {
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to action.locked))
refreshConversations()
}
}
}
},
) )
}, },
gesturesEnabled = true // Allows swiping from the screen's left edge to open the drawer gesturesEnabled = true // Allows swiping from the screen's left edge to open the drawer
@ -542,73 +441,7 @@ fun ChatScreen(
), ),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
items(messages, key = { it.id }) { message -> items(messages, key = { it.id }) { message -> MessageRow(message) }
MessageRow(
message = message,
onDelete = if (conversationId != null && !isStreaming) { wireId ->
val cfg = session.config ?: return@MessageRow
messages.removeAll { it.wireId == wireId }
scope.launch {
KaizenApi.deleteMessage(cfg.baseUrl, cfg.token, conversationId!!, wireId)
}
} else null,
onRegenerate = if (message.role == Role.Assistant && !isStreaming) { _ ->
val lastUserIdx = messages.indexOfLast { it.role == Role.User && messages.indexOf(it) < messages.indexOf(message) }
if (lastUserIdx >= 0) {
val userContent = messages[lastUserIdx].content
messages.removeAt(messages.indexOf(message))
send(userContent)
}
} else null,
)
}
}
// Scroll buttons
val showScrollUp by remember { derivedStateOf { listState.firstVisibleItemIndex > 2 } }
val showScrollDown by remember { derivedStateOf {
val info = listState.layoutInfo
info.visibleItemsInfo.lastOrNull()?.index != info.totalItemsCount - 1
}}
if (showScrollUp || showScrollDown) {
Column(
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (showScrollUp) {
GlassSurface(
shape = KaizenShapes.circle,
tintAlpha = GlassTiers.pill(isSystemInDarkTheme()),
shadowStack = KaizenShadows.level1,
cornerRadius = 18.dp,
modifier = Modifier
.size(36.dp)
.clickable { scope.launch { listState.animateScrollToItem(0) } },
) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.KeyboardArrowUp, null, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier.size(20.dp))
}
}
}
if (showScrollDown) {
GlassSurface(
shape = KaizenShapes.circle,
tintAlpha = GlassTiers.pill(isSystemInDarkTheme()),
shadowStack = KaizenShadows.level1,
cornerRadius = 18.dp,
modifier = Modifier
.size(36.dp)
.clickable { scope.launch { listState.animateScrollToItem(messages.size - 1) } },
) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.KeyboardArrowDown, null, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier.size(20.dp))
}
}
}
}
} }
} }
@ -657,11 +490,6 @@ fun ChatScreen(
onClick = { haptics.tick(); showModelSheet = true }, onClick = { haptics.tick(); showModelSheet = true },
modifier = Modifier.weight(1f, fill = false), modifier = Modifier.weight(1f, fill = false),
) )
if (usedTokens > 0 || messages.isNotEmpty()) {
val ctxLen = models.find { it.id == session.config?.model }?.context_length ?: 1_000_000
Spacer(Modifier.width(8.dp))
TokenBadge(used = usedTokens, contextLength = ctxLen)
}
} }
} }
@ -701,20 +529,15 @@ fun ChatScreen(
) )
) )
.navigationBarsPadding() .navigationBarsPadding()
.imePadding()
.then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier) .then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier)
) { ) {
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
ModePillsRow( ModePillsRow(
activeMode = activeMode, selected = selectedModes,
onToggle = { mode -> onToggle = { mode ->
activeMode = if (activeMode == mode) null else mode selectedModes = if (mode in selectedModes) selectedModes - mode else selectedModes + mode
haptics.tick() haptics.tick()
}, },
reasoningPreset = reasoningPreset,
onReasoningChange = { reasoningPreset = it; haptics.tick() },
samplingPrefs = samplingPrefs,
onSamplingChange = { samplingPrefs = it },
) )
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
ChatInput( ChatInput(

View file

@ -363,20 +363,20 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
val isLast = idx == blocks.lastIndex val isLast = idx == blocks.lastIndex
when (block) { when (block) {
is MdBlock.Heading -> { is MdBlock.Heading -> {
if (idx > 0) Spacer(Modifier.height(18.dp)) if (idx > 0) Spacer(Modifier.height(8.dp))
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg) val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg)
Text( Text(
text = rendered, text = rendered,
color = cs.onBackground, color = cs.onBackground,
fontSize = when (block.level) { 1 -> 22.sp; 2 -> 19.sp; else -> 17.sp }, fontSize = when (block.level) { 1 -> 22.sp; 2 -> 19.sp; else -> 17.sp },
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
lineHeight = when (block.level) { 1 -> 30.sp; 2 -> 27.sp; else -> 24.sp }, lineHeight = when (block.level) { 1 -> 28.sp; 2 -> 25.sp; else -> 23.sp },
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(4.dp))
} }
is MdBlock.CodeFence -> { is MdBlock.CodeFence -> {
if (idx > 0) Spacer(Modifier.height(12.dp)) if (idx > 0) Spacer(Modifier.height(6.dp))
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -416,13 +416,13 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
} }
} }
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(6.dp))
} }
is MdBlock.ListBlock -> { is MdBlock.ListBlock -> {
if (idx > 0) Spacer(Modifier.height(8.dp)) if (idx > 0) Spacer(Modifier.height(4.dp))
block.items.forEachIndexed { itemIdx, item -> block.items.forEachIndexed { itemIdx, item ->
Row(Modifier.fillMaxWidth().padding(start = 6.dp, bottom = 6.dp)) { Row(Modifier.fillMaxWidth().padding(start = 4.dp, bottom = 3.dp)) {
val bullet = if (item.ordered) "${item.index}." else "" val bullet = if (item.ordered) "${item.index}." else ""
Text( Text(
bullet, bullet,
@ -435,27 +435,27 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
text = inlineMarkdown(item.text + if (appendCursor) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg), text = inlineMarkdown(item.text + if (appendCursor) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg),
color = cs.onBackground, color = cs.onBackground,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 26.sp, lineHeight = 23.sp,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(4.dp))
} }
is MdBlock.HorizontalRule -> { is MdBlock.HorizontalRule -> {
if (idx > 0) Spacer(Modifier.height(14.dp)) if (idx > 0) Spacer(Modifier.height(8.dp))
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(1.dp) .height(1.dp)
.background(cs.onSurfaceVariant.copy(alpha = 0.20f)) .background(cs.onSurfaceVariant.copy(alpha = 0.25f))
) )
Spacer(Modifier.height(14.dp)) Spacer(Modifier.height(8.dp))
} }
is MdBlock.Blockquote -> { is MdBlock.Blockquote -> {
if (idx > 0) Spacer(Modifier.height(12.dp)) if (idx > 0) Spacer(Modifier.height(6.dp))
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -474,21 +474,21 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onSurfaceVariant, inlineCodeColor, inlineCodeBg), text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onSurfaceVariant, inlineCodeColor, inlineCodeBg),
color = cs.onSurfaceVariant, color = cs.onSurfaceVariant,
fontSize = 15.sp, fontSize = 15.sp,
lineHeight = 24.sp, lineHeight = 22.sp,
fontStyle = FontStyle.Italic, fontStyle = FontStyle.Italic,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(6.dp))
} }
is MdBlock.Paragraph -> { is MdBlock.Paragraph -> {
if (idx > 0) Spacer(Modifier.height(12.dp)) if (idx > 0) Spacer(Modifier.height(6.dp))
Text( Text(
text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg), text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg),
color = cs.onBackground, color = cs.onBackground,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 26.sp, lineHeight = 23.sp,
) )
} }

View file

@ -19,43 +19,25 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Logout import androidx.compose.material.icons.automirrored.rounded.Logout
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Lock import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material.icons.rounded.LockOpen
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.PushPin
import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.Star
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.kaizen.app.net.ConversationSummary import dev.kaizen.app.net.ConversationSummary
@ -94,13 +76,6 @@ private fun groupConversations(conversations: List<ConversationSummary>, labels:
return groups return groups
} }
sealed interface SidebarAction {
data class Rename(val id: String, val newTitle: String) : SidebarAction
data class Delete(val id: String) : SidebarAction
data class TogglePin(val id: String, val pinned: Boolean) : SidebarAction
data class ToggleLock(val id: String, val locked: Boolean) : SidebarAction
}
@Composable @Composable
fun KaizenSidebar( fun KaizenSidebar(
userName: String, userName: String,
@ -111,23 +86,18 @@ fun KaizenSidebar(
onSelectConversation: (ConversationSummary) -> Unit, onSelectConversation: (ConversationSummary) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onAction: (SidebarAction) -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
var deleteTarget by remember { mutableStateOf<ConversationSummary?>(null) }
var renameTarget by remember { mutableStateOf<ConversationSummary?>(null) }
var renameText by remember { mutableStateOf("") }
GlassSurface( GlassSurface(
modifier = modifier modifier = modifier
.fillMaxHeight() .fillMaxHeight()
.width(300.dp) .width(300.dp)
.padding(start = 12.dp, top = 12.dp, end = 4.dp, bottom = 12.dp), .padding(start = 12.dp, top = 12.dp, end = 4.dp, bottom = 12.dp),
shape = KaizenShapes.lg, shape = KaizenShapes.lg,
tintAlpha = if (isDark) 0.88f else 0.92f, tintAlpha = GlassTiers.sidebar(isDark),
shadowStack = KaizenShadows.level3, shadowStack = KaizenShadows.level3,
cornerRadius = 24.dp, cornerRadius = 24.dp,
) { ) {
@ -143,50 +113,46 @@ fun KaizenSidebar(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(stringResource(R.string.sidebar_brand), color = cs.onBackground, fontSize = 20.sp, fontWeight = FontWeight.Bold)
stringResource(R.string.sidebar_brand),
color = cs.onBackground,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
)
Box( Box(
modifier = Modifier modifier = Modifier
.size(34.dp) .size(36.dp)
.clip(KaizenShapes.circle) .clip(KaizenShapes.circle)
.background(if (isDark) Color(0x1AFFFFFF) else Color(0x0A000000)) .background(if (isDark) Color(0x1AFFFFFF) else Color(0x0A000000))
.clickable { onNewChat() }, .clickable { onNewChat() },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon(Icons.Rounded.Edit, stringResource(R.string.chat_new), tint = cs.onBackground, modifier = Modifier.size(17.dp)) Icon(Icons.Rounded.Edit, stringResource(R.string.chat_new), tint = cs.onBackground, modifier = Modifier.size(18.dp))
} }
} }
Spacer(Modifier.height(14.dp)) Spacer(Modifier.height(16.dp))
// Search // Search
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(KaizenShapes.pill) .clip(KaizenShapes.sm)
.background(if (isDark) Color(0x0DFFFFFF) else Color(0x08000000)) .background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000))
.padding(horizontal = 14.dp, vertical = 9.dp), .border(1.dp, if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.04f), KaizenShapes.sm)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon(Icons.Rounded.Search, null, tint = cs.onSurfaceVariant.copy(alpha = 0.45f), modifier = Modifier.size(16.dp)) Icon(Icons.Rounded.Search, null, tint = cs.onSurfaceVariant.copy(alpha = 0.6f), modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.chat_search), color = cs.onSurfaceVariant.copy(alpha = 0.45f), fontSize = 13.sp) Text(stringResource(R.string.chat_search), color = cs.onSurfaceVariant.copy(alpha = 0.6f), fontSize = 14.sp)
} }
Spacer(Modifier.height(18.dp)) Spacer(Modifier.height(24.dp))
// Conversation list // Conversation list
Box(Modifier.weight(1f).fillMaxWidth()) { Box(Modifier.weight(1f).fillMaxWidth()) {
if (conversations.isEmpty()) { if (conversations.isEmpty()) {
Text( Text(
stringResource(R.string.chat_no_conversations), stringResource(R.string.chat_no_conversations),
color = cs.onSurfaceVariant.copy(alpha = 0.4f), color = cs.onSurfaceVariant.copy(alpha = 0.5f),
fontSize = 13.sp, fontSize = 14.sp,
modifier = Modifier.padding(start = 8.dp, top = 8.dp) modifier = Modifier.padding(start = 12.dp, top = 12.dp)
) )
} else { } else {
val labels = DateLabels( val labels = DateLabels(
@ -198,16 +164,16 @@ fun KaizenSidebar(
val grouped = remember(conversations, labels) { groupConversations(conversations, labels) } val grouped = remember(conversations, labels) { groupConversations(conversations, labels) }
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
grouped.forEach { (dateGroup, items) -> grouped.forEach { (dateGroup, items) ->
item(key = "header_$dateGroup") { item(key = "header_$dateGroup") {
Text( Text(
dateGroup, dateGroup,
color = cs.onSurfaceVariant.copy(alpha = 0.4f), color = cs.onSurfaceVariant.copy(alpha = 0.45f),
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 8.dp, top = 14.dp, bottom = 6.dp) modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 4.dp)
) )
} }
items(items, key = { it.id }) { item -> items(items, key = { it.id }) { item ->
@ -215,13 +181,6 @@ fun KaizenSidebar(
item = item, item = item,
selected = item.id == currentId, selected = item.id == currentId,
onClick = { onSelectConversation(item) }, onClick = { onSelectConversation(item) },
onRename = {
renameTarget = item
renameText = item.title
},
onDelete = { deleteTarget = item },
onTogglePin = { onAction(SidebarAction.TogglePin(item.id, !item.pinned)) },
onToggleLock = { onAction(SidebarAction.ToggleLock(item.id, !item.locked)) },
) )
} }
} }
@ -229,242 +188,99 @@ fun KaizenSidebar(
} }
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(16.dp))
// User card — compact footer with settings + logout // User card
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(KaizenShapes.md) .clip(KaizenShapes.md)
.background(if (isDark) Color(0x0DFFFFFF) else Color(0x06000000)) .background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000))
.border(1.dp, if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.04f), KaizenShapes.md)
.clickable { onOpenSettings() } .clickable { onOpenSettings() }
.padding(horizontal = 12.dp, vertical = 10.dp), .padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(30.dp) .size(40.dp)
.clip(KaizenShapes.circle) .clip(KaizenShapes.circle)
.background(if (isDark) Color(0xFF1E293B) else Color(0xFF0F172A)), .background(if (isDark) Color(0xFF1E293B) else Color(0xFF0F172A)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
userName.firstOrNull()?.toString()?.uppercase() ?: "A", userName.firstOrNull()?.toString()?.uppercase() ?: "A",
color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.Bold color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.Bold
) )
} }
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
Text( Column(Modifier.weight(1f)) {
userName, Text(userName, color = cs.onBackground, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
color = cs.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
)
Icon(
Icons.Rounded.Settings,
stringResource(R.string.sidebar_settings),
tint = cs.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(6.dp))
Box(
modifier = Modifier
.size(28.dp)
.clip(KaizenShapes.circle)
.clickable { onLogout() },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.AutoMirrored.Rounded.Logout,
stringResource(R.string.sidebar_logout),
tint = cs.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(16.dp),
)
}
}
} }
Icon(Icons.Rounded.Settings, stringResource(R.string.sidebar_settings), tint = cs.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.size(20.dp))
} }
// Delete confirmation dialog Spacer(Modifier.height(10.dp))
deleteTarget?.let { target ->
AlertDialog(
onDismissRequest = { deleteTarget = null },
title = { Text(stringResource(R.string.sidebar_delete)) },
text = { Text(stringResource(R.string.sidebar_delete_confirm)) },
confirmButton = {
TextButton(onClick = {
onAction(SidebarAction.Delete(target.id))
deleteTarget = null
}) {
Text(stringResource(R.string.sidebar_confirm), color = Color(0xFFEF4444))
}
},
dismissButton = {
TextButton(onClick = { deleteTarget = null }) {
Text(stringResource(R.string.sidebar_cancel))
}
},
)
}
// Rename dialog // Logout
renameTarget?.let { target ->
val focusManager = LocalFocusManager.current
AlertDialog(
onDismissRequest = { renameTarget = null },
title = { Text(stringResource(R.string.sidebar_rename_title)) },
text = {
BasicTextField(
value = renameText,
onValueChange = { renameText = it },
singleLine = true,
textStyle = TextStyle(
color = cs.onBackground,
fontSize = 15.sp,
),
cursorBrush = SolidColor(cs.onBackground),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {
focusManager.clearFocus()
if (renameText.isNotBlank()) {
onAction(SidebarAction.Rename(target.id, renameText.trim()))
}
renameTarget = null
}),
modifier = Modifier
.fillMaxWidth()
.clip(KaizenShapes.sm)
.background(if (isDark) Color(0x12FFFFFF) else Color(0x08000000))
.padding(horizontal = 14.dp, vertical = 12.dp),
)
},
confirmButton = {
TextButton(onClick = {
if (renameText.isNotBlank()) {
onAction(SidebarAction.Rename(target.id, renameText.trim()))
}
renameTarget = null
}) {
Text(stringResource(R.string.sidebar_save))
}
},
dismissButton = {
TextButton(onClick = { renameTarget = null }) {
Text(stringResource(R.string.sidebar_cancel))
}
},
)
}
}
@Composable
private fun HistoryItemRow(
item: ConversationSummary,
selected: Boolean,
onClick: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
onTogglePin: () -> Unit,
onToggleLock: () -> Unit,
) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
val accent = LocalKaizenAccent.current
var showMenu by remember { mutableStateOf(false) }
val itemBg = when {
selected -> accent.primary.copy(alpha = if (isDark) 0.14f else 0.10f)
else -> Color.Transparent
}
Box {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(KaizenShapes.sm) .clip(KaizenShapes.sm)
.background(itemBg) .clickable { onLogout() }
.clickable { onClick() } .padding(horizontal = 12.dp, vertical = 10.dp),
.padding(start = 10.dp, end = 4.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (item.pinned) { Icon(Icons.AutoMirrored.Rounded.Logout, stringResource(R.string.sidebar_logout), tint = cs.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.size(18.dp))
Icon(Icons.Rounded.Star, null, tint = accent.primary.copy(alpha = 0.7f), modifier = Modifier.size(13.dp)) Spacer(Modifier.width(10.dp))
Spacer(Modifier.width(6.dp)) Text(stringResource(R.string.sidebar_logout), color = cs.onSurfaceVariant.copy(alpha = 0.85f), fontSize = 14.sp, fontWeight = FontWeight.Medium)
} }
Text( }
text = if (item.locked) stringResource(R.string.chat_locked) else item.title, }
color = if (item.locked) cs.onSurfaceVariant.copy(alpha = 0.45f) else cs.onBackground.copy(alpha = 0.85f), }
fontSize = 13.5.sp,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, @Composable
fontStyle = if (item.locked) FontStyle.Italic else FontStyle.Normal, private fun HistoryItemRow(item: ConversationSummary, selected: Boolean, onClick: () -> Unit) {
maxLines = 1, val cs = MaterialTheme.colorScheme
overflow = TextOverflow.Ellipsis, val isDark = isSystemInDarkTheme()
modifier = Modifier.weight(1f), val accent = LocalKaizenAccent.current
)
if (item.locked) { val itemBg = when {
Icon(Icons.Rounded.Lock, null, tint = accent.primary.copy(alpha = 0.5f), modifier = Modifier.size(13.dp)) selected -> accent.primary.copy(alpha = 0.14f)
Spacer(Modifier.width(2.dp)) isDark -> Color(0x0AFFFFFF)
} else -> Color(0x05000000)
Box( }
modifier = Modifier val itemBorder = when {
.size(28.dp) selected -> accent.primary.copy(alpha = 0.35f)
.clip(KaizenShapes.circle) isDark -> Color.White.copy(alpha = 0.04f)
.clickable { showMenu = true }, else -> Color.Black.copy(alpha = 0.03f)
contentAlignment = Alignment.Center, }
) {
Icon( Row(
Icons.Rounded.MoreVert, modifier = Modifier
stringResource(R.string.sidebar_more), .fillMaxWidth()
tint = cs.onSurfaceVariant.copy(alpha = 0.35f), .clip(KaizenShapes.pill)
modifier = Modifier.size(16.dp), .background(itemBg)
) .border(1.2.dp, itemBorder, KaizenShapes.pill)
} .clickable { onClick() }
} .padding(horizontal = 16.dp, vertical = 11.dp),
verticalAlignment = Alignment.CenterVertically
DropdownMenu( ) {
expanded = showMenu, if (item.pinned) {
onDismissRequest = { showMenu = false }, Icon(Icons.Rounded.Star, null, tint = accent.primary, modifier = Modifier.size(14.dp))
) { Spacer(Modifier.width(8.dp))
if (!item.locked) { }
DropdownMenuItem( Text(
text = { Text(stringResource(R.string.sidebar_rename), fontSize = 14.sp) }, text = if (item.locked) stringResource(R.string.chat_locked) else item.title,
onClick = { showMenu = false; onRename() }, color = if (item.locked) cs.onSurfaceVariant.copy(alpha = 0.5f) else cs.onBackground,
leadingIcon = { Icon(Icons.Rounded.Edit, null, modifier = Modifier.size(18.dp)) }, fontSize = 14.sp,
) fontWeight = FontWeight.Medium,
} fontStyle = if (item.locked) FontStyle.Italic else FontStyle.Normal,
DropdownMenuItem( modifier = Modifier.weight(1f)
text = { )
Text( if (item.locked) {
stringResource(if (item.pinned) R.string.sidebar_unpin else R.string.sidebar_pin), Spacer(Modifier.width(8.dp))
fontSize = 14.sp, Icon(Icons.Rounded.Lock, stringResource(R.string.chat_locked_badge), tint = accent.primary, modifier = Modifier.size(15.dp))
)
},
onClick = { showMenu = false; onTogglePin() },
leadingIcon = { Icon(Icons.Rounded.PushPin, null, modifier = Modifier.size(18.dp)) },
)
DropdownMenuItem(
text = {
Text(
stringResource(if (item.locked) R.string.sidebar_unlock else R.string.sidebar_lock),
fontSize = 14.sp,
)
},
onClick = { showMenu = false; onToggleLock() },
leadingIcon = {
Icon(
if (item.locked) Icons.Rounded.LockOpen else Icons.Rounded.Lock,
null,
modifier = Modifier.size(18.dp),
)
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.sidebar_delete), fontSize = 14.sp, color = Color(0xFFEF4444)) },
onClick = { showMenu = false; onDelete() },
leadingIcon = { Icon(Icons.Rounded.Delete, null, tint = Color(0xFFEF4444), modifier = Modifier.size(18.dp)) },
)
} }
} }
} }

View file

@ -79,7 +79,7 @@ fun ReasoningBlock(reasoning: String, streaming: Boolean = false) {
Icon(Icons.Rounded.Psychology, null, tint = accent.primary, modifier = Modifier.size(16.dp)) Icon(Icons.Rounded.Psychology, null, tint = accent.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text( Text(
stringResource(R.string.stream_reasoning), "Gedankengang",
color = cs.onSurfaceVariant, color = cs.onSurfaceVariant,
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,

View file

@ -2,7 +2,6 @@ package dev.kaizen.app.db
import androidx.room.TypeConverter import androidx.room.TypeConverter
import dev.kaizen.app.net.Attachment import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.SearchSource
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -16,12 +15,4 @@ class Converters {
@TypeConverter @TypeConverter
fun jsonToAttachments(value: String): List<Attachment> = fun jsonToAttachments(value: String): List<Attachment> =
try { json.decodeFromString(value) } catch (_: Exception) { emptyList() } try { json.decodeFromString(value) } catch (_: Exception) { emptyList() }
@TypeConverter
fun sourcesToJson(sources: List<SearchSource>): String =
json.encodeToString(sources)
@TypeConverter
fun jsonToSources(value: String): List<SearchSource> =
try { json.decodeFromString(value) } catch (_: Exception) { emptyList() }
} }

View file

@ -5,7 +5,6 @@ import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import dev.kaizen.app.net.Attachment import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.SearchSource
@Entity(tableName = "conversations") @Entity(tableName = "conversations")
data class ConversationEntity( data class ConversationEntity(
@ -34,6 +33,4 @@ data class MessageEntity(
val content: String, val content: String,
val attachments: List<Attachment>, val attachments: List<Attachment>,
val sortOrder: Int, val sortOrder: Int,
val reasoning: String? = null,
val sources: List<SearchSource> = emptyList(),
) )

View file

@ -27,8 +27,6 @@ fun StoredMessage.toEntity(conversationId: String, sortOrder: Int) = MessageEnti
content = content, content = content,
attachments = attachments, attachments = attachments,
sortOrder = sortOrder, sortOrder = sortOrder,
reasoning = reasoning,
sources = sources ?: emptyList(),
) )
fun MessageEntity.toStoredMessage() = StoredMessage( fun MessageEntity.toStoredMessage() = StoredMessage(
@ -36,6 +34,4 @@ fun MessageEntity.toStoredMessage() = StoredMessage(
role = role, role = role,
content = content, content = content,
attachments = attachments, attachments = attachments,
reasoning = reasoning,
sources = sources.takeIf { it.isNotEmpty() },
) )

View file

@ -35,10 +35,6 @@ import java.util.concurrent.TimeUnit
val reasoningEffort: String? = null, val reasoningEffort: String? = null,
val preferThroughput: Boolean? = null, val preferThroughput: Boolean? = null,
val attachments: List<Attachment>? = null, val attachments: List<Attachment>? = null,
val webSearch: Boolean? = null,
val temperature: Float? = null,
val topP: Float? = null,
val topK: Int? = null,
) )
/** One entry from GET /api/v1/models — the picker's row. `provider == "vertex"` drives the hoster badge. */ /** One entry from GET /api/v1/models — the picker's row. `provider == "vertex"` drives the hoster badge. */
@ -47,7 +43,6 @@ data class KaizenModel(
val id: String, val id: String,
val name: String? = null, val name: String? = null,
val provider: String? = null, val provider: String? = null,
val context_length: Int? = null,
) )
@Serializable private data class ModelsResponse(val models: List<KaizenModel> = emptyList()) @Serializable private data class ModelsResponse(val models: List<KaizenModel> = emptyList())
@ -63,7 +58,6 @@ data class ConversationSummary(
) )
@Serializable private data class ConversationsResponse(val conversations: List<ConversationSummary> = emptyList()) @Serializable private data class ConversationsResponse(val conversations: List<ConversationSummary> = emptyList())
@Serializable private data class UnlockResponse(val unlockToken: String = "")
@Serializable private data class CreateConvoRequest(val title: String? = null) @Serializable private data class CreateConvoRequest(val title: String? = null)
@Serializable private data class CreatedConversation(val id: String) @Serializable private data class CreatedConversation(val id: String)
@ -85,8 +79,6 @@ data class StoredMessage(
val role: String, val role: String,
val content: String = "", val content: String = "",
val attachments: List<Attachment> = emptyList(), val attachments: List<Attachment> = emptyList(),
val reasoning: String? = null,
val sources: List<SearchSource>? = null,
) )
@Serializable private data class ConversationDetail(val messages: List<StoredMessage> = emptyList()) @Serializable private data class ConversationDetail(val messages: List<StoredMessage> = emptyList())
@ -101,8 +93,6 @@ data class SaveMessage(
val kind: String = "chat", val kind: String = "chat",
val model: String? = null, val model: String? = null,
val attachments: List<Attachment>? = null, val attachments: List<Attachment>? = null,
val reasoning: String? = null,
val sources: List<SearchSource>? = null,
) )
@Serializable private data class SaveMessagesRequest(val messages: List<SaveMessage>) @Serializable private data class SaveMessagesRequest(val messages: List<SaveMessage>)
@ -326,108 +316,6 @@ object KaizenApi {
} }
} }
/** PATCH /api/v1/conversations/[id] — update title, locked, pinned. */
suspend fun patchConversation(baseUrl: String, token: String, id: String, body: Map<String, Any?>): Boolean =
withContext(Dispatchers.IO) {
val payload = json.encodeToString(kotlinx.serialization.json.JsonObject(
body.mapValues { (_, v) ->
when (v) {
is String -> kotlinx.serialization.json.JsonPrimitive(v)
is Boolean -> kotlinx.serialization.json.JsonPrimitive(v)
else -> kotlinx.serialization.json.JsonNull
}
}
)).toRequestBody(jsonMedia)
val req = Request.Builder()
.url("$baseUrl/api/v1/conversations/$id")
.patch(payload)
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { it.isSuccessful }
} catch (e: Exception) {
Log.w(TAG, "patchConversation($id): ${e.javaClass.simpleName}: ${e.message}")
false
}
}
/** DELETE /api/v1/conversations/[id] — permanently remove a conversation. */
suspend fun deleteConversation(baseUrl: String, token: String, id: String): Boolean =
withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/api/v1/conversations/$id")
.delete()
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { it.isSuccessful }
} catch (e: Exception) {
Log.w(TAG, "deleteConversation($id): ${e.javaClass.simpleName}: ${e.message}")
false
}
}
/** POST /api/v1/auth/unlock — get a short-lived unlock token for locked conversations. */
suspend fun requestUnlock(baseUrl: String, token: String): String? =
withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/api/v1/auth/unlock")
.post("{}".toRequestBody(jsonMedia))
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) {
Log.w(TAG, "requestUnlock: HTTP ${resp.code}")
return@use null
}
json.decodeFromString<UnlockResponse>(resp.body!!.string()).unlockToken
}
} catch (e: Exception) {
Log.w(TAG, "requestUnlock: ${e.javaClass.simpleName}: ${e.message}")
null
}
}
/** GET /api/v1/conversations/[id] with unlock token — fetch messages of a locked conversation. */
suspend fun fetchLockedMessages(baseUrl: String, token: String, id: String, unlockToken: String): List<StoredMessage> =
withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/api/v1/conversations/$id")
.header("Authorization", "Bearer $token")
.header("X-Unlock-Token", unlockToken)
.build()
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) {
Log.w(TAG, "fetchLockedMessages($id): HTTP ${resp.code}")
return@use emptyList()
}
json.decodeFromString<ConversationDetail>(resp.body!!.string()).messages
}
} catch (e: Exception) {
Log.w(TAG, "fetchLockedMessages($id): ${e.javaClass.simpleName}: ${e.message}")
emptyList()
}
}
/** DELETE /api/v1/conversations/[id]/messages — delete a single message. */
suspend fun deleteMessage(baseUrl: String, token: String, conversationId: String, messageId: String): Boolean =
withContext(Dispatchers.IO) {
val body = """{"messageId":"$messageId"}""".toRequestBody(jsonMedia)
val req = Request.Builder()
.url("$baseUrl/api/v1/conversations/$conversationId/messages")
.delete(body)
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { it.isSuccessful }
} catch (e: Exception) {
Log.w(TAG, "deleteMessage: ${e.javaClass.simpleName}: ${e.message}")
false
}
}
/** POST /api/v1/conversations/[id]/title — fire-and-forget auto-title after the first exchange. */ /** POST /api/v1/conversations/[id]/title — fire-and-forget auto-title after the first exchange. */
suspend fun generateTitle(baseUrl: String, token: String, id: String) = suspend fun generateTitle(baseUrl: String, token: String, id: String) =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@ -460,25 +348,16 @@ object KaizenApi {
token: String, token: String,
model: String, model: String,
messages: List<WireMessage>, messages: List<WireMessage>,
reasoningEffort: String? = null, speed: ChatSpeed = ChatSpeed.Normal,
preferThroughput: Boolean = false,
attachments: List<Attachment>? = null, attachments: List<Attachment>? = null,
webSearch: Boolean = false,
temperature: Float? = null,
topP: Float? = null,
topK: Int? = null,
): Flow<StreamState> = flow { ): Flow<StreamState> = flow {
val payload = json.encodeToString( val payload = json.encodeToString(
ChatRequest( ChatRequest(
model = model, model = model,
messages = messages, messages = messages,
reasoningEffort = reasoningEffort, reasoningEffort = speed.reasoningEffort,
preferThroughput = if (preferThroughput) true else null, preferThroughput = if (speed.preferThroughput) true else null,
attachments = attachments?.takeIf { it.isNotEmpty() }, attachments = attachments?.takeIf { it.isNotEmpty() },
webSearch = if (webSearch) true else null,
temperature = temperature,
topP = topP,
topK = topK,
), ),
).toRequestBody(jsonMedia) ).toRequestBody(jsonMedia)
val req = Request.Builder() val req = Request.Builder()

View file

@ -28,20 +28,12 @@ data class SearchSource(
val snippet: String = "", val snippet: String = "",
) )
@Serializable
private data class UsageJson(
val i: Int = 0,
val o: Int = 0,
)
data class StreamState( data class StreamState(
val content: String = "", val content: String = "",
val reasoning: String = "", val reasoning: String = "",
val tools: List<ToolEvent> = emptyList(), val tools: List<ToolEvent> = emptyList(),
val query: String = "", val query: String = "",
val sources: List<SearchSource> = emptyList(), val sources: List<SearchSource> = emptyList(),
val inputTokens: Int = 0,
val outputTokens: Int = 0,
) )
object StreamConsumer { object StreamConsumer {
@ -109,15 +101,12 @@ object StreamConsumer {
private fun buildState(): StreamState { private fun buildState(): StreamState {
val queryStr = parseTrailingField(QUERY) val queryStr = parseTrailingField(QUERY)
val sourcesList = parseTrailingSources() val sourcesList = parseTrailingSources()
val usage = parseTrailingUsage()
return StreamState( return StreamState(
content = content.toString(), content = content.toString(),
reasoning = reasoningBuf.toString(), reasoning = reasoningBuf.toString(),
tools = tools.toList(), tools = tools.toList(),
query = queryStr, query = queryStr,
sources = sourcesList, sources = sourcesList,
inputTokens = usage?.i ?: 0,
outputTokens = usage?.o ?: 0,
) )
} }
@ -146,15 +135,6 @@ object StreamConsumer {
} }
} }
private fun parseTrailingUsage(): UsageJson? {
val full = trailingBuf.toString()
val idx = full.indexOf(USAGE)
if (idx == -1) return null
val json = full.substring(idx + 1).trim()
if (json.isEmpty()) return null
return try { lenientJson.decodeFromString<UsageJson>(json) } catch (_: Exception) { null }
}
private fun parseToolEvents(buf: CharSequence, from: Int, to: Int) { private fun parseToolEvents(buf: CharSequence, from: Int, to: Int) {
val s = buf.toString() val s = buf.toString()
var pos = from var pos = from

View file

@ -92,7 +92,7 @@ fun GlassSurface(
* Use these constants with [GlassSurface] instead of ad-hoc alpha values. * Use these constants with [GlassSurface] instead of ad-hoc alpha values.
*/ */
object GlassTiers { object GlassTiers {
fun sidebar(isDark: Boolean) = if (isDark) 0.88f else 0.92f fun sidebar(isDark: Boolean) = if (isDark) 0.72f else 0.68f
fun input(isDark: Boolean) = if (isDark) 0.75f else 0.74f fun input(isDark: Boolean) = if (isDark) 0.75f else 0.74f
fun pill(isDark: Boolean) = if (isDark) 0.80f else 0.82f fun pill(isDark: Boolean) = if (isDark) 0.80f else 0.82f
fun chip(isDark: Boolean) = if (isDark) 0.85f else 0.90f fun chip(isDark: Boolean) = if (isDark) 0.85f else 0.90f

View file

@ -61,41 +61,6 @@
<string name="sidebar_earlier">EARLIER</string> <string name="sidebar_earlier">EARLIER</string>
<string name="sidebar_logout">Sign Out</string> <string name="sidebar_logout">Sign Out</string>
<string name="sidebar_settings">Settings</string> <string name="sidebar_settings">Settings</string>
<string name="sidebar_rename">Rename</string>
<string name="sidebar_delete">Delete</string>
<string name="sidebar_pin">Pin</string>
<string name="sidebar_unpin">Unpin</string>
<string name="sidebar_lock">Lock</string>
<string name="sidebar_unlock">Unlock</string>
<string name="sidebar_more">More</string>
<string name="sidebar_rename_title">Rename Chat</string>
<string name="sidebar_delete_confirm">Permanently delete this chat?</string>
<string name="sidebar_cancel">Cancel</string>
<string name="sidebar_confirm">Confirm</string>
<string name="sidebar_save">Save</string>
<!-- Reasoning presets -->
<string name="reasoning_instant">Instant</string>
<string name="reasoning_none">Off</string>
<string name="reasoning_minimal">Minimal</string>
<string name="reasoning_low">Low</string>
<string name="reasoning_standard">Standard</string>
<string name="reasoning_medium">Medium</string>
<string name="reasoning_high">High</string>
<string name="reasoning_xhigh">Maximum</string>
<!-- Sampling -->
<string name="sampling_temperature">Temperature</string>
<string name="sampling_top_p">Top-P</string>
<string name="sampling_top_k">Top-K</string>
<!-- Biometric unlock -->
<string name="biometric_title">Unlock Chat</string>
<string name="biometric_subtitle">Confirm your identity</string>
<string name="biometric_cancel">Cancel</string>
<string name="biometric_not_available">Biometrics not available</string>
<string name="biometric_failed">Authentication failed</string>
<string name="unlock_tap_to_unlock">Tap to unlock</string>
<!-- Model sheet --> <!-- Model sheet -->
<string name="model_sheet_title">Select Model</string> <string name="model_sheet_title">Select Model</string>

View file

@ -63,41 +63,6 @@
<string name="sidebar_earlier">FRÜHER</string> <string name="sidebar_earlier">FRÜHER</string>
<string name="sidebar_logout">Abmelden</string> <string name="sidebar_logout">Abmelden</string>
<string name="sidebar_settings">Einstellungen</string> <string name="sidebar_settings">Einstellungen</string>
<string name="sidebar_rename">Umbenennen</string>
<string name="sidebar_delete">Löschen</string>
<string name="sidebar_pin">Anheften</string>
<string name="sidebar_unpin">Lösen</string>
<string name="sidebar_lock">Sperren</string>
<string name="sidebar_unlock">Entsperren</string>
<string name="sidebar_more">Mehr</string>
<string name="sidebar_rename_title">Chat umbenennen</string>
<string name="sidebar_delete_confirm">Chat endgültig löschen?</string>
<string name="sidebar_cancel">Abbrechen</string>
<string name="sidebar_confirm">Bestätigen</string>
<string name="sidebar_save">Speichern</string>
<!-- Reasoning presets -->
<string name="reasoning_instant">Sofort</string>
<string name="reasoning_none">Aus</string>
<string name="reasoning_minimal">Minimal</string>
<string name="reasoning_low">Niedrig</string>
<string name="reasoning_standard">Standard</string>
<string name="reasoning_medium">Mittel</string>
<string name="reasoning_high">Hoch</string>
<string name="reasoning_xhigh">Maximal</string>
<!-- Sampling -->
<string name="sampling_temperature">Temperatur</string>
<string name="sampling_top_p">Top-P</string>
<string name="sampling_top_k">Top-K</string>
<!-- Biometric unlock -->
<string name="biometric_title">Chat entsperren</string>
<string name="biometric_subtitle">Bestätige deine Identität</string>
<string name="biometric_cancel">Abbrechen</string>
<string name="biometric_not_available">Biometrie nicht verfügbar</string>
<string name="biometric_failed">Authentifizierung fehlgeschlagen</string>
<string name="unlock_tap_to_unlock">Tippen zum Entsperren</string>
<!-- Model sheet --> <!-- Model sheet -->
<string name="model_sheet_title">Modell auswählen</string> <string name="model_sheet_title">Modell auswählen</string>

View file

@ -2,7 +2,6 @@ package dev.kaizen.app
import dev.kaizen.app.db.Converters import dev.kaizen.app.db.Converters
import dev.kaizen.app.net.Attachment import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.SearchSource
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@ -62,56 +61,4 @@ class ConvertersTest {
assertEquals(1, result.size) assertEquals(1, result.size)
assertEquals("https://x.com/f", result[0].url) assertEquals("https://x.com/f", result[0].url)
} }
// --- SearchSource converter tests ---
@Test
fun emptySourcesRoundTrip() {
val json = converters.sourcesToJson(emptyList())
val result = converters.jsonToSources(json)
assertTrue(result.isEmpty())
}
@Test
fun singleSourceRoundTrip() {
val original = listOf(
SearchSource(title = "Wikipedia", url = "https://en.wikipedia.org/wiki/Test", snippet = "A test article")
)
val json = converters.sourcesToJson(original)
val result = converters.jsonToSources(json)
assertEquals(1, result.size)
assertEquals("Wikipedia", result[0].title)
assertEquals("https://en.wikipedia.org/wiki/Test", result[0].url)
assertEquals("A test article", result[0].snippet)
}
@Test
fun multipleSourcesRoundTrip() {
val original = listOf(
SearchSource(title = "Source 1", url = "https://a.com"),
SearchSource(title = "Source 2", url = "https://b.com", snippet = "Details"),
)
val json = converters.sourcesToJson(original)
val result = converters.jsonToSources(json)
assertEquals(2, result.size)
assertEquals("Source 1", result[0].title)
assertEquals("Source 2", result[1].title)
assertEquals("Details", result[1].snippet)
}
@Test
fun malformedSourcesJsonReturnsEmptyList() {
val result = converters.jsonToSources("{broken")
assertTrue(result.isEmpty())
}
@Test
fun sourcesWithUnknownFieldsIgnored() {
val json = """[{"title":"T","url":"https://x.com","snippet":"","futureField":"v"}]"""
val result = converters.jsonToSources(json)
assertEquals(1, result.size)
assertEquals("T", result[0].title)
}
} }

View file

@ -7,10 +7,8 @@ import dev.kaizen.app.db.toStoredMessage
import dev.kaizen.app.db.toSummary import dev.kaizen.app.db.toSummary
import dev.kaizen.app.net.Attachment import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.ConversationSummary import dev.kaizen.app.net.ConversationSummary
import dev.kaizen.app.net.SearchSource
import dev.kaizen.app.net.StoredMessage import dev.kaizen.app.net.StoredMessage
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@ -121,64 +119,4 @@ class MappersTest {
assertEquals(original.attachments[0].mimeType, roundTripped.attachments[0].mimeType) assertEquals(original.attachments[0].mimeType, roundTripped.attachments[0].mimeType)
assertEquals(original.attachments[0].size, roundTripped.attachments[0].size) assertEquals(original.attachments[0].size, roundTripped.attachments[0].size)
} }
@Test
fun reasoningPreservedInRoundTrip() {
val original = StoredMessage(
id = "msg-r", role = "assistant", content = "Answer",
reasoning = "Let me think about this step by step...",
)
val roundTripped = original.toEntity("c1", 0).toStoredMessage()
assertEquals("Let me think about this step by step...", roundTripped.reasoning)
}
@Test
fun nullReasoningPreserved() {
val original = StoredMessage(id = "msg-nr", role = "assistant", content = "Simple answer")
val entity = original.toEntity("c1", 0)
assertNull(entity.reasoning)
val roundTripped = entity.toStoredMessage()
assertNull(roundTripped.reasoning)
}
@Test
fun sourcesPreservedInRoundTrip() {
val sources = listOf(
SearchSource(title = "Wikipedia", url = "https://en.wikipedia.org/wiki/Test", snippet = "A test"),
SearchSource(title = "MDN", url = "https://developer.mozilla.org", snippet = "Web docs"),
)
val original = StoredMessage(
id = "msg-s", role = "assistant", content = "Search result",
sources = sources,
)
val roundTripped = original.toEntity("c1", 0).toStoredMessage()
assertEquals(2, roundTripped.sources?.size)
assertEquals("Wikipedia", roundTripped.sources!![0].title)
assertEquals("https://developer.mozilla.org", roundTripped.sources!![1].url)
}
@Test
fun emptySourcesBecomeNull() {
val original = StoredMessage(id = "msg-es", role = "assistant", content = "No sources")
val entity = original.toEntity("c1", 0)
assertTrue(entity.sources.isEmpty())
val roundTripped = entity.toStoredMessage()
assertNull(roundTripped.sources)
}
@Test
fun reasoningAndSourcesTogether() {
val original = StoredMessage(
id = "msg-rs", role = "assistant", content = "Full answer",
reasoning = "Thinking...",
sources = listOf(SearchSource(title = "Src", url = "https://src.com")),
)
val roundTripped = original.toEntity("c1", 0).toStoredMessage()
assertEquals("Thinking...", roundTripped.reasoning)
assertEquals(1, roundTripped.sources?.size)
assertEquals("Src", roundTripped.sources!![0].title)
}
} }

View file

@ -13,7 +13,6 @@ okhttp = "4.12.0"
kotlinxSerialization = "1.7.3" kotlinxSerialization = "1.7.3"
securityCrypto = "1.1.0-alpha06" securityCrypto = "1.1.0-alpha06"
room = "2.8.4" room = "2.8.4"
biometric = "1.4.0-alpha02"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -22,7 +21,6 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }