Compare commits
15 commits
e56e9a4c83
...
f22838e99a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f22838e99a | ||
|
|
4ebe812c42 | ||
|
|
f7849025d7 | ||
|
|
2d57afff61 | ||
|
|
fb90577884 | ||
|
|
3ca1d2c18e | ||
|
|
f6dc5db363 | ||
|
|
30d3f2807d | ||
|
|
a16dfffd5c | ||
|
|
edbdbd8697 | ||
|
|
88559bf49e | ||
|
|
278d98d125 | ||
|
|
532cb8c27b | ||
|
|
f3d8154052 | ||
|
|
3986443a56 |
21 changed files with 1430 additions and 276 deletions
12
CLAUDE.md
12
CLAUDE.md
|
|
@ -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/`)
|
||||
|
||||
```
|
||||
auth/ LoginScreen
|
||||
auth/ LoginScreen, BiometricUnlock (KeyStore + CryptoObject)
|
||||
chat/ ChatScreen, ChatViewModel, Sidebar, ChatComponents, ChatModels,
|
||||
ModelSheet, Markdown, Background
|
||||
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/ConversationRepository.kt` — `observeAll()` (Flow of ConversationSummary), `refresh()` (server → Room), `allUnlockedIds()`
|
||||
- `db/MessageRepository.kt` — `observeMessages()`, `getCached()`, `fetchAndCache()`, `cacheMessages()`, `prefetchMissing()` (background prefetch for offline)
|
||||
- `db/Converters.kt` — `List<Attachment>` ↔ JSON string via kotlinx.serialization
|
||||
- `db/Converters.kt` — `List<Attachment>` + `List<SearchSource>` ↔ JSON string via kotlinx.serialization
|
||||
- `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)).
|
||||
|
|
@ -191,6 +191,8 @@ 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/models`** — Bearer-authed, returns filtered model catalog.
|
||||
- **`/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.
|
||||
- **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.
|
||||
|
|
@ -221,7 +223,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` |
|
||||
| `cad445f` | **i18n cleanup** — replaced all remaining hardcoded German strings with `stringResource()` |
|
||||
| `5c54b9b` | **ToolEventsBlock rewrite** — state machine matching web frontend (thinking/analyzing/start/end/error) |
|
||||
| `0948931` | **Attachment picker menu** — camera capture, gallery (image/*), and file picker (*/*) via dropdown |
|
||||
| `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 `*/*` |
|
||||
|
||||
**Earlier UI/feel work (Phase 0 prototype → feel-first):**
|
||||
- Liquid glass styling, floating panels, glassmorphic sidebar
|
||||
|
|
@ -254,13 +256,13 @@ These are what separate "prototype" from "daily-driver":
|
|||
|
||||
- [ ] **Conversation rename/delete** from the sidebar (currently read-only)
|
||||
- [ ] **Conversation pin/unpin** from the sidebar
|
||||
- [ ] **In-app unlock for locked conversations** (currently skipped with "Gesperrter Chat" label)
|
||||
- [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
|
||||
|
||||
### 3. Security hardening
|
||||
|
||||
- [ ] **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
|
||||
- [ ] **Biometric lock** (`BiometricPrompt` + KeyStore binding) — see `LATER.md` §1C
|
||||
- [x] **Biometric lock** (`BiometricPrompt` + KeyStore/CryptoObject) — implemented for conversation unlock; `setUserAuthenticationRequired(true)` + `setInvalidatedByBiometricEnrollment(true)` ensures hardware-enforced biometric, not bypassable even on rooted devices
|
||||
- [ ] **`FLAG_SECURE`** on sensitive screens — see `LATER.md` §1B
|
||||
|
||||
### 4. Features for parity with the web
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ dependencies {
|
|||
// Room offline cache (local SQLite read-cache for conversations + messages)
|
||||
implementation(libs.androidx.room.runtime)
|
||||
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)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
|
|
|
|||
158
app/src/main/java/dev/kaizen/app/auth/BiometricUnlock.kt
Normal file
158
app/src/main/java/dev/kaizen/app/auth/BiometricUnlock.kt
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
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) { }
|
||||
}
|
||||
}
|
||||
|
|
@ -46,7 +46,7 @@ fun MeshBackground(content: @Composable BoxScope.() -> Unit) {
|
|||
val accent = LocalKaizenAccent.current
|
||||
val base = MaterialTheme.colorScheme.background
|
||||
// Blobs are subtler in light mode.
|
||||
val factor = if (dark) 1f else 0.55f
|
||||
val factor = if (dark) 1f else 0.65f
|
||||
val transition = rememberInfiniteTransition(label = "blobs")
|
||||
val drift by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
|
|
@ -65,14 +65,14 @@ fun MeshBackground(content: @Composable BoxScope.() -> Unit) {
|
|||
drawRect(brush = ditherBrush, alpha = DITHER_ALPHA)
|
||||
},
|
||||
) {
|
||||
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.24f * factor)
|
||||
Blob(accent.blob4, 300.dp, (30f + drift * 50f).dp, (560f - drift * 36f).dp, 0.22f * factor)
|
||||
// Vignette: darken the edges so the center comes forward (dark mode only).
|
||||
Blob(accent.blob1, 360.dp, (-60f + drift * 30f).dp, (-40f + drift * 24f).dp, 0.42f * factor)
|
||||
Blob(accent.blob2, 380.dp, (170f - drift * 44f).dp, (150f + drift * 40f).dp, 0.36f * 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.34f * factor)
|
||||
if (dark) {
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.radialGradient(listOf(Color.Transparent, base.copy(alpha = 0.55f))),
|
||||
Brush.radialGradient(listOf(Color.Transparent, base.copy(alpha = 0.40f))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -137,8 +137,7 @@ fun KaizenOrb(
|
|||
.pointerInput(Unit) {},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// --- LAYER 1: Ambient Outer Halo (Subtle larger glow) ---
|
||||
// This is the ONLY layer that is blurred, providing a soft background glow.
|
||||
// --- LAYER 1: Ambient Outer Halo ---
|
||||
Box(
|
||||
Modifier
|
||||
.size(size * 1.5f)
|
||||
|
|
@ -147,137 +146,143 @@ fun KaizenOrb(
|
|||
.background(
|
||||
Brush.radialGradient(
|
||||
colors = if (recording) {
|
||||
listOf(Color(0xFFE0A23E).copy(alpha = 0.45f), Color.Transparent)
|
||||
listOf(accent.primary.copy(alpha = 0.45f), Color.Transparent)
|
||||
} else {
|
||||
listOf(accent.primary.copy(alpha = if (streaming) 0.55f else 0.32f), Color.Transparent)
|
||||
listOf(accent.primary.copy(alpha = if (streaming) 0.50f else 0.28f), Color.Transparent)
|
||||
}
|
||||
),
|
||||
CircleShape,
|
||||
),
|
||||
)
|
||||
|
||||
// --- LAYER 2: Glass Sphere body with refracted backgrounds & conic caustics ---
|
||||
// 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 h = size.toPx()
|
||||
val radius = w / 2f
|
||||
|
||||
// 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(
|
||||
brush = Brush.radialGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to Color(0xFFFFF1D6), // Ultra bright glowing cream-white core
|
||||
0.35f to Color(0xFFFFCC80), // Rich luminous warm amber
|
||||
0.75f to accent.primaryDeep, // Saturated accent
|
||||
1.0f to Color(0xFF8D4F00) // Deep bronze-amber shadow edge (creates high contrast!)
|
||||
),
|
||||
center = baseGradientCenter,
|
||||
radius = radius * 1.1f
|
||||
)
|
||||
)
|
||||
|
||||
// 2. Heavy 3D Inner Volume Shadow (Sphere Bottom Shading)
|
||||
// Adds massive physical 3D weight by darkening the sphere's lower edge.
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to Color.Transparent,
|
||||
0.5f to Color.Transparent,
|
||||
1.0f to Color(0xFF4A2300).copy(alpha = 0.85f) // Deep dark brown shadow edge
|
||||
),
|
||||
center = Offset(w * 0.35f + centerShiftX, h * 0.30f + centerShiftY),
|
||||
radius = radius * 1.0f
|
||||
)
|
||||
)
|
||||
|
||||
// Shift inner core in the opposite direction (parallax depth)
|
||||
val coreShiftX = tiltX * 0.05f * w
|
||||
val coreShiftY = -tiltY * 0.05f * h
|
||||
val coreCenter = Offset(w / 2f + coreShiftX, h / 2f + coreShiftY)
|
||||
|
||||
// 3. Conic Caustic Layer #1 (SweepGradient at 30° blended via BlendMode.Overlay)
|
||||
val sweepBrush1 = Brush.sweepGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to Color.Transparent,
|
||||
0.16f to accent.primary.copy(alpha = 0.65f),
|
||||
0.33f to Color.White.copy(alpha = 0.55f),
|
||||
0.50f to Color.Transparent,
|
||||
0.66f to accent.primaryDeep.copy(alpha = 0.50f),
|
||||
1.0f to Color.Transparent
|
||||
),
|
||||
center = coreCenter
|
||||
)
|
||||
drawCircle(
|
||||
brush = sweepBrush1,
|
||||
radius = radius * 0.84f,
|
||||
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 & Crisp Rim (Sharp reflections) ---
|
||||
// --- LAYER 2: Glass body — transparent tint so background bleeds through ---
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val w = size.toPx()
|
||||
val h = size.toPx()
|
||||
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)
|
||||
|
||||
// Specular shifts with gravity to give a high-gloss interactive reflection
|
||||
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!
|
||||
// Base: very subtle theme tint (glass, not paint)
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(Color.White, Color.Transparent),
|
||||
center = specularCenter1,
|
||||
radius = radius * 0.38f
|
||||
)
|
||||
colorStops = arrayOf(
|
||||
0.0f to accent.primary.copy(alpha = if (isDark) 0.22f else 0.15f),
|
||||
0.6f to accent.primaryDeep.copy(alpha = if (isDark) 0.18f else 0.10f),
|
||||
1.0f to accent.primaryDeep.copy(alpha = if (isDark) 0.30f else 0.20f),
|
||||
),
|
||||
center = lightCenter,
|
||||
radius = radius * 1.1f,
|
||||
),
|
||||
)
|
||||
|
||||
// 2. Secondary ambient bounce highlight (bottom-right edge glow)
|
||||
// Top-left brightness (light source)
|
||||
drawCircle(
|
||||
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(
|
||||
brush = Brush.radialGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to Color.Transparent,
|
||||
0.55f to Color.Transparent,
|
||||
0.85f to Color.Black.copy(alpha = if (isDark) 0.20f else 0.08f),
|
||||
1.0f to Color.Black.copy(alpha = if (isDark) 0.35f else 0.15f),
|
||||
),
|
||||
center = Offset(w * 0.45f, h * 0.40f),
|
||||
radius = radius,
|
||||
),
|
||||
)
|
||||
|
||||
// Caustic conic layer (depth structure)
|
||||
val coreShiftX = tiltX * 0.05f * w
|
||||
val coreShiftY = -tiltY * 0.05f * h
|
||||
val coreCenter = Offset(w / 2f + coreShiftX, h / 2f + coreShiftY)
|
||||
drawCircle(
|
||||
brush = Brush.sweepGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to Color.Transparent,
|
||||
0.16f to accent.primary.copy(alpha = 0.30f),
|
||||
0.33f to Color.White.copy(alpha = 0.20f),
|
||||
0.50f to Color.Transparent,
|
||||
0.66f to accent.primaryDeep.copy(alpha = 0.25f),
|
||||
1.0f to Color.Transparent,
|
||||
),
|
||||
center = coreCenter,
|
||||
),
|
||||
radius = radius * 0.84f,
|
||||
blendMode = BlendMode.Overlay,
|
||||
)
|
||||
}
|
||||
|
||||
// --- LAYER 3: Specular highlights + rim ---
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val w = size.toPx()
|
||||
val h = size.toPx()
|
||||
val radius = w / 2f
|
||||
val specShiftX = -tiltX * 0.08f * w
|
||||
val specShiftY = tiltY * 0.08f * h
|
||||
|
||||
// Primary specular — elliptical for realism
|
||||
drawOval(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color.White.copy(alpha = if (isDark) 0.70f else 0.90f),
|
||||
Color.Transparent,
|
||||
),
|
||||
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)
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color(0xFFFFD180).copy(alpha = 0.80f), // Bright warm amber light refraction
|
||||
Color.Transparent
|
||||
accent.primary.copy(alpha = if (isDark) 0.50f else 0.35f),
|
||||
Color.Transparent,
|
||||
),
|
||||
center = specularCenter2,
|
||||
radius = radius * 0.28f
|
||||
)
|
||||
center = Offset(w * 0.72f + specShiftX, h * 0.80f + specShiftY),
|
||||
radius = radius * 0.25f,
|
||||
),
|
||||
)
|
||||
|
||||
// 3. Ultra-sharp outer rim (1px hairline highlight for crisp crystal reflection)
|
||||
// Crisp rim hairline
|
||||
drawCircle(
|
||||
color = Color.White.copy(alpha = if (isDark) 0.12f else 0.45f),
|
||||
color = Color.White.copy(alpha = if (isDark) 0.10f else 0.30f),
|
||||
radius = radius - 0.5f,
|
||||
style = Stroke(width = 1.5f)
|
||||
style = Stroke(width = 1f),
|
||||
)
|
||||
|
||||
// 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ import androidx.compose.animation.core.animateFloatAsState
|
|||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
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.background
|
||||
import androidx.compose.foundation.border
|
||||
|
|
@ -63,6 +68,7 @@ import dev.kaizen.app.net.Attachment
|
|||
import dev.kaizen.app.ui.effect.GlassSurface
|
||||
import dev.kaizen.app.ui.effect.GlassTiers
|
||||
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.shape.KaizenShapes
|
||||
import dev.kaizen.app.ui.theme.LocalKaizenAccent
|
||||
|
|
@ -82,6 +88,20 @@ import androidx.compose.material.icons.rounded.PictureAsPdf
|
|||
import androidx.compose.material.icons.rounded.VideoFile
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
|
|
@ -142,14 +162,24 @@ fun EmptyHero(userName: String, onPick: (String) -> Unit, modifier: Modifier = M
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun ModePillsRow(selected: Set<Int>, onToggle: (Int) -> Unit, modifier: Modifier = Modifier) {
|
||||
fun ModePillsRow(
|
||||
activeMode: Int?,
|
||||
onToggle: (Int) -> Unit,
|
||||
reasoningPreset: ReasoningPreset,
|
||||
onReasoningChange: (ReasoningPreset) -> Unit,
|
||||
samplingPrefs: SamplingPrefs,
|
||||
onSamplingChange: (SamplingPrefs) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val cs = MaterialTheme.colorScheme
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val accent = LocalKaizenAccent.current
|
||||
|
||||
val glassBorder = remember(isDark) {
|
||||
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)))
|
||||
}
|
||||
var showReasoningMenu by remember { mutableStateOf(false) }
|
||||
var showSamplingPopup by remember { mutableStateOf(false) }
|
||||
|
||||
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(
|
||||
modifier
|
||||
|
|
@ -158,25 +188,98 @@ fun ModePillsRow(selected: Set<Int>, onToggle: (Int) -> Unit, modifier: Modifier
|
|||
.padding(horizontal = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
chatModes.forEach { mode ->
|
||||
val label = stringResource(mode.labelRes)
|
||||
val active = mode.labelRes in selected
|
||||
val backgroundFill = if (active) {
|
||||
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)
|
||||
// Reasoning pill (dropdown)
|
||||
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 active = mode.labelRes == activeMode
|
||||
val backgroundFill = if (active) accent.primary.copy(alpha = if (isDark) 0.18f else 0.14f) else inactiveBg
|
||||
val borderColor = if (active) accent.primary.copy(alpha = 0.4f) else inactiveBorder
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.clip(KaizenShapes.pill)
|
||||
.background(backgroundFill)
|
||||
.border(1.2.dp, glassBorder, KaizenShapes.pill)
|
||||
.border(1.2.dp, borderColor, KaizenShapes.pill)
|
||||
.clickable { onToggle(mode.labelRes) }
|
||||
.padding(horizontal = 13.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(mode.icon, null, tint = if (active) cs.onBackground else cs.onSurfaceVariant, modifier = Modifier.size(16.dp))
|
||||
Icon(mode.icon, null, tint = if (active) accent.primary else cs.onSurfaceVariant, modifier = Modifier.size(16.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)
|
||||
}
|
||||
|
|
@ -185,42 +288,164 @@ fun ModePillsRow(selected: Set<Int>, onToggle: (Int) -> Unit, modifier: Modifier
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun MessageRow(message: Message) {
|
||||
private fun SamplingRow(label: String, param: SamplingParam, min: Float, max: Float, steps: Int, onUpdate: (SamplingParam) -> Unit) {
|
||||
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 isDark = isSystemInDarkTheme()
|
||||
val accent = LocalKaizenAccent.current
|
||||
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) {
|
||||
Column(horizontalAlignment = Alignment.End, modifier = Modifier.fillMaxWidth()) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
val shape = KaizenShapes.lg
|
||||
val borderBrush = remember(accent) { Brush.verticalGradient(listOf(accent.primary.copy(alpha = 0.45f), accent.primaryDeep.copy(alpha = 0.15f))) }
|
||||
val bgBrush = remember(accent) { Brush.verticalGradient(listOf(accent.primary.copy(alpha = 0.18f), accent.primaryDeep.copy(alpha = 0.08f))) }
|
||||
val bgBrush = remember(accent, isDark) {
|
||||
Brush.verticalGradient(
|
||||
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(
|
||||
Modifier
|
||||
.widthIn(max = 300.dp)
|
||||
.kaizenShadow(stack = KaizenShadows.level1, cornerRadius = 20.dp)
|
||||
.clip(shape)
|
||||
.background(bgBrush)
|
||||
.background(innerHighlight)
|
||||
.border(1.2.dp, borderBrush, shape),
|
||||
) {
|
||||
Column {
|
||||
if (hasAttachments) AttachmentGrid(message.attachments, Modifier.padding(6.dp))
|
||||
if (message.content.isNotBlank()) {
|
||||
Text(
|
||||
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 11.dp, bottom = 11.dp),
|
||||
message.content, color = cs.onBackground, fontSize = 16.sp, lineHeight = 26.sp,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = if (hasAttachments) 4.dp else 13.dp, bottom = 13.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 {
|
||||
Column {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
|
||||
KaizenOrb(28.dp, streaming = message.streaming)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Box(Modifier.weight(1f).padding(top = 3.dp)) {
|
||||
Column {
|
||||
if (message.reasoning.isNotBlank()) {
|
||||
ReasoningBlock(message.reasoning, streaming = message.streaming)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
if (message.tools.isNotEmpty()) {
|
||||
ToolEventsBlock(message.tools, streaming = message.streaming)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
|
@ -231,10 +456,6 @@ fun MessageRow(message: Message) {
|
|||
}
|
||||
if (message.thinking) TypingDots()
|
||||
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()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SourcesBlock(message.sources, query = message.query)
|
||||
|
|
@ -242,6 +463,49 @@ fun MessageRow(message: Message) {
|
|||
}
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -288,10 +552,13 @@ fun ChatInput(
|
|||
val cs = MaterialTheme.colorScheme
|
||||
val isDark = isSystemInDarkTheme()
|
||||
|
||||
val hasContent = value.isNotBlank() || pendingFiles.any { it.uploaded != null }
|
||||
val canSend = enabled && hasContent && pendingFiles.none { it.uploading }
|
||||
|
||||
GlassSurface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp),
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = KaizenShapes.xl,
|
||||
tintAlpha = GlassTiers.input(isDark),
|
||||
shadowStack = KaizenShadows.level2,
|
||||
|
|
@ -300,42 +567,38 @@ fun ChatInput(
|
|||
Column {
|
||||
if (pendingFiles.isNotEmpty()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(start = 12.dp, end = 12.dp, top = 8.dp),
|
||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(start = 14.dp, end = 14.dp, top = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
pendingFiles.forEach { pf -> PendingFileChip(pf, onRemove = { onRemoveFile(pf.id) }) }
|
||||
}
|
||||
}
|
||||
Row(Modifier.padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(40.dp).clip(KaizenShapes.circle).clickable(onClick = onAddClick), contentAlignment = Alignment.Center) {
|
||||
Row(Modifier.padding(start = 6.dp, end = 8.dp, top = 10.dp, bottom = 10.dp), verticalAlignment = Alignment.Bottom) {
|
||||
Box(Modifier.size(42.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))
|
||||
}
|
||||
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, fontSize = 16.sp)
|
||||
Box(Modifier.weight(1f).heightIn(min = 42.dp).padding(horizontal = 6.dp), contentAlignment = Alignment.CenterStart) {
|
||||
if (value.isEmpty()) Text(stringResource(R.string.chat_input_placeholder), color = cs.onSurfaceVariant.copy(alpha = 0.6f), fontSize = 15.sp)
|
||||
val accent = LocalKaizenAccent.current
|
||||
BasicTextField(
|
||||
value = value, onValueChange = onValueChange,
|
||||
textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp),
|
||||
textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 24.sp),
|
||||
cursorBrush = SolidColor(accent.primary), maxLines = 6, modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
GhostIconButton(Icons.Rounded.Call, stringResource(R.string.chat_call))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
val canSend = enabled && (value.isNotBlank() || pendingFiles.any { it.uploaded != null }) && pendingFiles.none { it.uploading }
|
||||
SendButton(enabled = canSend, onClick = onSend)
|
||||
if (hasContent) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PendingFileChip(pf: PendingFile, onRemove: () -> Unit) {
|
||||
val cs = MaterialTheme.colorScheme
|
||||
|
|
|
|||
|
|
@ -18,6 +18,25 @@ import dev.kaizen.app.net.ToolEvent
|
|||
|
||||
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(
|
||||
val id: Long,
|
||||
val role: Role,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Spacer
|
|||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
|
|
@ -23,6 +24,8 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
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.material3.DrawerValue
|
||||
import androidx.compose.material3.Icon
|
||||
|
|
@ -96,7 +99,10 @@ fun ChatScreen(
|
|||
var input by remember { mutableStateOf("") }
|
||||
var isStreaming by remember { mutableStateOf(false) }
|
||||
var nextId by remember { mutableStateOf(0L) }
|
||||
var selectedModes by remember { mutableStateOf(setOf(R.string.mode_standard)) }
|
||||
var activeMode by remember { mutableStateOf<Int?>(null) }
|
||||
var reasoningPreset by remember { mutableStateOf(ReasoningPreset.Standard) }
|
||||
var samplingPrefs by remember { mutableStateOf(SamplingPrefs()) }
|
||||
var usedTokens by remember { mutableStateOf(0) }
|
||||
|
||||
// Navigation State
|
||||
var currentScreen by remember { mutableStateOf(AppScreen.Chat) }
|
||||
|
|
@ -117,6 +123,7 @@ fun ChatScreen(
|
|||
|
||||
// Conversation persistence: sidebar list from Room (instant), active conversation id
|
||||
var conversationId by remember { mutableStateOf<String?>(null) }
|
||||
var viewingLockedChat by remember { mutableStateOf(false) }
|
||||
val conversations by chat.conversationRepo.observeAll().collectAsState(initial = emptyList())
|
||||
|
||||
var loadError by remember { mutableStateOf<String?>(null) }
|
||||
|
|
@ -194,7 +201,12 @@ fun ChatScreen(
|
|||
session.config?.let { cfg ->
|
||||
scope.launch { KaizenApi.prewarm(cfg.baseUrl) }
|
||||
}
|
||||
onPauseOrDispose { }
|
||||
onPauseOrDispose {
|
||||
if (viewingLockedChat) {
|
||||
messages.clear()
|
||||
viewingLockedChat = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshConversations() {
|
||||
|
|
@ -206,14 +218,58 @@ fun ChatScreen(
|
|||
fun newChat() {
|
||||
messages.clear()
|
||||
conversationId = null
|
||||
viewingLockedChat = false
|
||||
}
|
||||
|
||||
fun openConversation(summary: ConversationSummary) {
|
||||
if (summary.locked) 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 {
|
||||
// Instant: load from Room cache first
|
||||
val cached = chat.messageRepo.observeMessages(summary.id)
|
||||
messages.clear()
|
||||
val cachedList = chat.messageRepo.getCached(summary.id)
|
||||
cachedList.forEach { m ->
|
||||
|
|
@ -224,16 +280,16 @@ fun ChatScreen(
|
|||
content = m.content,
|
||||
wireId = m.id,
|
||||
attachments = m.attachments,
|
||||
reasoning = m.reasoning ?: "",
|
||||
sources = m.sources ?: emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
conversationId = summary.id
|
||||
// Background: refresh from server and update Room + UI
|
||||
val stored = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, summary.id)
|
||||
if (stored.isNotEmpty()) {
|
||||
val entities = stored.mapIndexed { i, m -> m.toEntity(summary.id, i) }
|
||||
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 }) {
|
||||
messages.clear()
|
||||
stored.forEach { m ->
|
||||
|
|
@ -244,6 +300,8 @@ fun ChatScreen(
|
|||
content = m.content,
|
||||
wireId = m.id,
|
||||
attachments = m.attachments,
|
||||
reasoning = m.reasoning ?: "",
|
||||
sources = m.sources ?: emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -284,8 +342,14 @@ fun ChatScreen(
|
|||
} else null
|
||||
|
||||
KaizenApi.chat(
|
||||
cfg.baseUrl, cfg.token, cfg.model, history, cfg.speed,
|
||||
cfg.baseUrl, cfg.token, cfg.model, history,
|
||||
reasoningEffort = reasoningPreset.effort,
|
||||
preferThroughput = reasoningPreset.throughput,
|
||||
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 ->
|
||||
val idx = messages.indexOfLast { it.id == assistantId }
|
||||
if (idx < 0) return@collect
|
||||
|
|
@ -301,9 +365,15 @@ fun ChatScreen(
|
|||
query = state.query,
|
||||
sources = state.sources,
|
||||
)
|
||||
if (state.inputTokens > 0 || state.outputTokens > 0) {
|
||||
usedTokens = state.inputTokens + state.outputTokens
|
||||
}
|
||||
}
|
||||
val idx = messages.indexOfLast { it.id == assistantId }
|
||||
val finalContent = if (idx >= 0) messages[idx].content else ""
|
||||
val finalMsg = if (idx >= 0) messages[idx] else null
|
||||
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)
|
||||
|
||||
val convId = convIdDeferred?.await()?.also { conversationId = it } ?: conversationId
|
||||
|
|
@ -317,11 +387,15 @@ fun ChatScreen(
|
|||
role = "user", content = trimmed,
|
||||
attachments = uploadedAtts.takeIf { it.isNotEmpty() },
|
||||
),
|
||||
SaveMessage(id = assistantWireId, parentId = userMsg.wireId, role = "assistant", content = finalContent, model = cfg.model),
|
||||
SaveMessage(
|
||||
id = assistantWireId, parentId = userMsg.wireId,
|
||||
role = "assistant", content = finalContent, model = cfg.model,
|
||||
reasoning = finalReasoning,
|
||||
sources = finalSources,
|
||||
),
|
||||
),
|
||||
)
|
||||
if (saved) {
|
||||
// Cache new messages in Room for offline-first
|
||||
val msgCount = messages.size
|
||||
chat.messageRepo.cacheMessages(convId, listOf(
|
||||
MessageEntity(
|
||||
|
|
@ -333,6 +407,8 @@ fun ChatScreen(
|
|||
id = assistantWireId, conversationId = convId,
|
||||
role = "assistant", content = finalContent,
|
||||
attachments = emptyList(), sortOrder = msgCount - 1,
|
||||
reasoning = finalReasoning,
|
||||
sources = finalSources ?: emptyList(),
|
||||
),
|
||||
))
|
||||
if (wasNewConversation) KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId)
|
||||
|
|
@ -409,7 +485,32 @@ fun ChatScreen(
|
|||
newChat()
|
||||
scope.launch { drawerState.close() }
|
||||
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
|
||||
|
|
@ -441,7 +542,73 @@ fun ChatScreen(
|
|||
),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(messages, key = { it.id }) { message -> MessageRow(message) }
|
||||
items(messages, key = { it.id }) { 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -490,6 +657,11 @@ fun ChatScreen(
|
|||
onClick = { haptics.tick(); showModelSheet = true },
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -529,15 +701,20 @@ fun ChatScreen(
|
|||
)
|
||||
)
|
||||
.navigationBarsPadding()
|
||||
.imePadding()
|
||||
.then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier)
|
||||
) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
ModePillsRow(
|
||||
selected = selectedModes,
|
||||
activeMode = activeMode,
|
||||
onToggle = { mode ->
|
||||
selectedModes = if (mode in selectedModes) selectedModes - mode else selectedModes + mode
|
||||
activeMode = if (activeMode == mode) null else mode
|
||||
haptics.tick()
|
||||
},
|
||||
reasoningPreset = reasoningPreset,
|
||||
onReasoningChange = { reasoningPreset = it; haptics.tick() },
|
||||
samplingPrefs = samplingPrefs,
|
||||
onSamplingChange = { samplingPrefs = it },
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
ChatInput(
|
||||
|
|
|
|||
|
|
@ -363,20 +363,20 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
|
|||
val isLast = idx == blocks.lastIndex
|
||||
when (block) {
|
||||
is MdBlock.Heading -> {
|
||||
if (idx > 0) Spacer(Modifier.height(8.dp))
|
||||
if (idx > 0) Spacer(Modifier.height(18.dp))
|
||||
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg)
|
||||
Text(
|
||||
text = rendered,
|
||||
color = cs.onBackground,
|
||||
fontSize = when (block.level) { 1 -> 22.sp; 2 -> 19.sp; else -> 17.sp },
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = when (block.level) { 1 -> 28.sp; 2 -> 25.sp; else -> 23.sp },
|
||||
lineHeight = when (block.level) { 1 -> 30.sp; 2 -> 27.sp; else -> 24.sp },
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
is MdBlock.CodeFence -> {
|
||||
if (idx > 0) Spacer(Modifier.height(6.dp))
|
||||
if (idx > 0) Spacer(Modifier.height(12.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -416,13 +416,13 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
|
|||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
is MdBlock.ListBlock -> {
|
||||
if (idx > 0) Spacer(Modifier.height(4.dp))
|
||||
if (idx > 0) Spacer(Modifier.height(8.dp))
|
||||
block.items.forEachIndexed { itemIdx, item ->
|
||||
Row(Modifier.fillMaxWidth().padding(start = 4.dp, bottom = 3.dp)) {
|
||||
Row(Modifier.fillMaxWidth().padding(start = 6.dp, bottom = 6.dp)) {
|
||||
val bullet = if (item.ordered) "${item.index}." else "•"
|
||||
Text(
|
||||
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),
|
||||
color = cs.onBackground,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 23.sp,
|
||||
lineHeight = 26.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
is MdBlock.HorizontalRule -> {
|
||||
if (idx > 0) Spacer(Modifier.height(8.dp))
|
||||
if (idx > 0) Spacer(Modifier.height(14.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(cs.onSurfaceVariant.copy(alpha = 0.25f))
|
||||
.background(cs.onSurfaceVariant.copy(alpha = 0.20f))
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Spacer(Modifier.height(14.dp))
|
||||
}
|
||||
|
||||
is MdBlock.Blockquote -> {
|
||||
if (idx > 0) Spacer(Modifier.height(6.dp))
|
||||
if (idx > 0) Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
Modifier
|
||||
.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),
|
||||
color = cs.onSurfaceVariant,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 22.sp,
|
||||
lineHeight = 24.sp,
|
||||
fontStyle = FontStyle.Italic,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
is MdBlock.Paragraph -> {
|
||||
if (idx > 0) Spacer(Modifier.height(6.dp))
|
||||
if (idx > 0) Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg),
|
||||
color = cs.onBackground,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 23.sp,
|
||||
lineHeight = 26.sp,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,25 +19,43 @@ import androidx.compose.foundation.layout.statusBarsPadding
|
|||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.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.Settings
|
||||
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.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.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.sp
|
||||
import dev.kaizen.app.net.ConversationSummary
|
||||
|
|
@ -76,6 +94,13 @@ private fun groupConversations(conversations: List<ConversationSummary>, labels:
|
|||
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
|
||||
fun KaizenSidebar(
|
||||
userName: String,
|
||||
|
|
@ -86,18 +111,23 @@ fun KaizenSidebar(
|
|||
onSelectConversation: (ConversationSummary) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onAction: (SidebarAction) -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val cs = MaterialTheme.colorScheme
|
||||
val isDark = isSystemInDarkTheme()
|
||||
|
||||
var deleteTarget by remember { mutableStateOf<ConversationSummary?>(null) }
|
||||
var renameTarget by remember { mutableStateOf<ConversationSummary?>(null) }
|
||||
var renameText by remember { mutableStateOf("") }
|
||||
|
||||
GlassSurface(
|
||||
modifier = modifier
|
||||
.fillMaxHeight()
|
||||
.width(300.dp)
|
||||
.padding(start = 12.dp, top = 12.dp, end = 4.dp, bottom = 12.dp),
|
||||
shape = KaizenShapes.lg,
|
||||
tintAlpha = GlassTiers.sidebar(isDark),
|
||||
tintAlpha = if (isDark) 0.88f else 0.92f,
|
||||
shadowStack = KaizenShadows.level3,
|
||||
cornerRadius = 24.dp,
|
||||
) {
|
||||
|
|
@ -113,46 +143,50 @@ fun KaizenSidebar(
|
|||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(stringResource(R.string.sidebar_brand), color = cs.onBackground, fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
stringResource(R.string.sidebar_brand),
|
||||
color = cs.onBackground,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.size(34.dp)
|
||||
.clip(KaizenShapes.circle)
|
||||
.background(if (isDark) Color(0x1AFFFFFF) else Color(0x0A000000))
|
||||
.clickable { onNewChat() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.Rounded.Edit, stringResource(R.string.chat_new), tint = cs.onBackground, modifier = Modifier.size(18.dp))
|
||||
Icon(Icons.Rounded.Edit, stringResource(R.string.chat_new), tint = cs.onBackground, modifier = Modifier.size(17.dp))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Spacer(Modifier.height(14.dp))
|
||||
|
||||
// Search
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(KaizenShapes.sm)
|
||||
.background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000))
|
||||
.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),
|
||||
.clip(KaizenShapes.pill)
|
||||
.background(if (isDark) Color(0x0DFFFFFF) else Color(0x08000000))
|
||||
.padding(horizontal = 14.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Rounded.Search, null, tint = cs.onSurfaceVariant.copy(alpha = 0.6f), modifier = Modifier.size(18.dp))
|
||||
Icon(Icons.Rounded.Search, null, tint = cs.onSurfaceVariant.copy(alpha = 0.45f), modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.chat_search), color = cs.onSurfaceVariant.copy(alpha = 0.6f), fontSize = 14.sp)
|
||||
Text(stringResource(R.string.chat_search), color = cs.onSurfaceVariant.copy(alpha = 0.45f), fontSize = 13.sp)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Spacer(Modifier.height(18.dp))
|
||||
|
||||
// Conversation list
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
if (conversations.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.chat_no_conversations),
|
||||
color = cs.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(start = 12.dp, top = 12.dp)
|
||||
color = cs.onSurfaceVariant.copy(alpha = 0.4f),
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(start = 8.dp, top = 8.dp)
|
||||
)
|
||||
} else {
|
||||
val labels = DateLabels(
|
||||
|
|
@ -164,16 +198,16 @@ fun KaizenSidebar(
|
|||
val grouped = remember(conversations, labels) { groupConversations(conversations, labels) }
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
grouped.forEach { (dateGroup, items) ->
|
||||
item(key = "header_$dateGroup") {
|
||||
Text(
|
||||
dateGroup,
|
||||
color = cs.onSurfaceVariant.copy(alpha = 0.45f),
|
||||
color = cs.onSurfaceVariant.copy(alpha = 0.4f),
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 4.dp)
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(start = 8.dp, top = 14.dp, bottom = 6.dp)
|
||||
)
|
||||
}
|
||||
items(items, key = { it.id }) { item ->
|
||||
|
|
@ -181,6 +215,13 @@ fun KaizenSidebar(
|
|||
item = item,
|
||||
selected = item.id == currentId,
|
||||
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)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -188,99 +229,242 @@ fun KaizenSidebar(
|
|||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// User card
|
||||
// User card — compact footer with settings + logout
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(KaizenShapes.md)
|
||||
.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)
|
||||
.background(if (isDark) Color(0x0DFFFFFF) else Color(0x06000000))
|
||||
.clickable { onOpenSettings() }
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.size(30.dp)
|
||||
.clip(KaizenShapes.circle)
|
||||
.background(if (isDark) Color(0xFF1E293B) else Color(0xFF0F172A)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
userName.firstOrNull()?.toString()?.uppercase() ?: "A",
|
||||
color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.Bold
|
||||
color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(userName, color = cs.onBackground, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
userName,
|
||||
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))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
// Logout
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(KaizenShapes.sm)
|
||||
.clickable { onLogout() }
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Rounded.Logout, stringResource(R.string.sidebar_logout), tint = cs.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(stringResource(R.string.sidebar_logout), color = cs.onSurfaceVariant.copy(alpha = 0.85f), fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete confirmation dialog
|
||||
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
|
||||
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) {
|
||||
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 = 0.14f)
|
||||
isDark -> Color(0x0AFFFFFF)
|
||||
else -> Color(0x05000000)
|
||||
}
|
||||
val itemBorder = when {
|
||||
selected -> accent.primary.copy(alpha = 0.35f)
|
||||
isDark -> Color.White.copy(alpha = 0.04f)
|
||||
else -> Color.Black.copy(alpha = 0.03f)
|
||||
selected -> accent.primary.copy(alpha = if (isDark) 0.14f else 0.10f)
|
||||
else -> Color.Transparent
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(KaizenShapes.pill)
|
||||
.background(itemBg)
|
||||
.border(1.2.dp, itemBorder, KaizenShapes.pill)
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 16.dp, vertical = 11.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (item.pinned) {
|
||||
Icon(Icons.Rounded.Star, null, tint = accent.primary, modifier = Modifier.size(14.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Box {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(KaizenShapes.sm)
|
||||
.background(itemBg)
|
||||
.clickable { onClick() }
|
||||
.padding(start = 10.dp, end = 4.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (item.pinned) {
|
||||
Icon(Icons.Rounded.Star, null, tint = accent.primary.copy(alpha = 0.7f), modifier = Modifier.size(13.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
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,
|
||||
fontStyle = if (item.locked) FontStyle.Italic else FontStyle.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (item.locked) {
|
||||
Icon(Icons.Rounded.Lock, null, tint = accent.primary.copy(alpha = 0.5f), modifier = Modifier.size(13.dp))
|
||||
Spacer(Modifier.width(2.dp))
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(KaizenShapes.circle)
|
||||
.clickable { showMenu = true },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.MoreVert,
|
||||
stringResource(R.string.sidebar_more),
|
||||
tint = cs.onSurfaceVariant.copy(alpha = 0.35f),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = if (item.locked) stringResource(R.string.chat_locked) else item.title,
|
||||
color = if (item.locked) cs.onSurfaceVariant.copy(alpha = 0.5f) else cs.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontStyle = if (item.locked) FontStyle.Italic else FontStyle.Normal,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (item.locked) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Icon(Icons.Rounded.Lock, stringResource(R.string.chat_locked_badge), tint = accent.primary, modifier = Modifier.size(15.dp))
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
) {
|
||||
if (!item.locked) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.sidebar_rename), fontSize = 14.sp) },
|
||||
onClick = { showMenu = false; onRename() },
|
||||
leadingIcon = { Icon(Icons.Rounded.Edit, null, modifier = Modifier.size(18.dp)) },
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
stringResource(if (item.pinned) R.string.sidebar_unpin else R.string.sidebar_pin),
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
},
|
||||
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)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ fun ReasoningBlock(reasoning: String, streaming: Boolean = false) {
|
|||
Icon(Icons.Rounded.Psychology, null, tint = accent.primary, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Gedankengang",
|
||||
stringResource(R.string.stream_reasoning),
|
||||
color = cs.onSurfaceVariant,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package dev.kaizen.app.db
|
|||
|
||||
import androidx.room.TypeConverter
|
||||
import dev.kaizen.app.net.Attachment
|
||||
import dev.kaizen.app.net.SearchSource
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
|
|
@ -15,4 +16,12 @@ class Converters {
|
|||
@TypeConverter
|
||||
fun jsonToAttachments(value: String): List<Attachment> =
|
||||
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() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.room.ForeignKey
|
|||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import dev.kaizen.app.net.Attachment
|
||||
import dev.kaizen.app.net.SearchSource
|
||||
|
||||
@Entity(tableName = "conversations")
|
||||
data class ConversationEntity(
|
||||
|
|
@ -33,4 +34,6 @@ data class MessageEntity(
|
|||
val content: String,
|
||||
val attachments: List<Attachment>,
|
||||
val sortOrder: Int,
|
||||
val reasoning: String? = null,
|
||||
val sources: List<SearchSource> = emptyList(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ fun StoredMessage.toEntity(conversationId: String, sortOrder: Int) = MessageEnti
|
|||
content = content,
|
||||
attachments = attachments,
|
||||
sortOrder = sortOrder,
|
||||
reasoning = reasoning,
|
||||
sources = sources ?: emptyList(),
|
||||
)
|
||||
|
||||
fun MessageEntity.toStoredMessage() = StoredMessage(
|
||||
|
|
@ -34,4 +36,6 @@ fun MessageEntity.toStoredMessage() = StoredMessage(
|
|||
role = role,
|
||||
content = content,
|
||||
attachments = attachments,
|
||||
reasoning = reasoning,
|
||||
sources = sources.takeIf { it.isNotEmpty() },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ import java.util.concurrent.TimeUnit
|
|||
val reasoningEffort: String? = null,
|
||||
val preferThroughput: Boolean? = 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. */
|
||||
|
|
@ -43,6 +47,7 @@ data class KaizenModel(
|
|||
val id: String,
|
||||
val name: String? = null,
|
||||
val provider: String? = null,
|
||||
val context_length: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable private data class ModelsResponse(val models: List<KaizenModel> = emptyList())
|
||||
|
|
@ -58,6 +63,7 @@ data class ConversationSummary(
|
|||
)
|
||||
|
||||
@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 CreatedConversation(val id: String)
|
||||
|
||||
|
|
@ -79,6 +85,8 @@ data class StoredMessage(
|
|||
val role: String,
|
||||
val content: String = "",
|
||||
val attachments: List<Attachment> = emptyList(),
|
||||
val reasoning: String? = null,
|
||||
val sources: List<SearchSource>? = null,
|
||||
)
|
||||
|
||||
@Serializable private data class ConversationDetail(val messages: List<StoredMessage> = emptyList())
|
||||
|
|
@ -93,6 +101,8 @@ data class SaveMessage(
|
|||
val kind: String = "chat",
|
||||
val model: String? = null,
|
||||
val attachments: List<Attachment>? = null,
|
||||
val reasoning: String? = null,
|
||||
val sources: List<SearchSource>? = null,
|
||||
)
|
||||
|
||||
@Serializable private data class SaveMessagesRequest(val messages: List<SaveMessage>)
|
||||
|
|
@ -316,6 +326,108 @@ 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. */
|
||||
suspend fun generateTitle(baseUrl: String, token: String, id: String) =
|
||||
withContext(Dispatchers.IO) {
|
||||
|
|
@ -348,16 +460,25 @@ object KaizenApi {
|
|||
token: String,
|
||||
model: String,
|
||||
messages: List<WireMessage>,
|
||||
speed: ChatSpeed = ChatSpeed.Normal,
|
||||
reasoningEffort: String? = null,
|
||||
preferThroughput: Boolean = false,
|
||||
attachments: List<Attachment>? = null,
|
||||
webSearch: Boolean = false,
|
||||
temperature: Float? = null,
|
||||
topP: Float? = null,
|
||||
topK: Int? = null,
|
||||
): Flow<StreamState> = flow {
|
||||
val payload = json.encodeToString(
|
||||
ChatRequest(
|
||||
model = model,
|
||||
messages = messages,
|
||||
reasoningEffort = speed.reasoningEffort,
|
||||
preferThroughput = if (speed.preferThroughput) true else null,
|
||||
reasoningEffort = reasoningEffort,
|
||||
preferThroughput = if (preferThroughput) true else null,
|
||||
attachments = attachments?.takeIf { it.isNotEmpty() },
|
||||
webSearch = if (webSearch) true else null,
|
||||
temperature = temperature,
|
||||
topP = topP,
|
||||
topK = topK,
|
||||
),
|
||||
).toRequestBody(jsonMedia)
|
||||
val req = Request.Builder()
|
||||
|
|
|
|||
|
|
@ -28,12 +28,20 @@ data class SearchSource(
|
|||
val snippet: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class UsageJson(
|
||||
val i: Int = 0,
|
||||
val o: Int = 0,
|
||||
)
|
||||
|
||||
data class StreamState(
|
||||
val content: String = "",
|
||||
val reasoning: String = "",
|
||||
val tools: List<ToolEvent> = emptyList(),
|
||||
val query: String = "",
|
||||
val sources: List<SearchSource> = emptyList(),
|
||||
val inputTokens: Int = 0,
|
||||
val outputTokens: Int = 0,
|
||||
)
|
||||
|
||||
object StreamConsumer {
|
||||
|
|
@ -101,12 +109,15 @@ object StreamConsumer {
|
|||
private fun buildState(): StreamState {
|
||||
val queryStr = parseTrailingField(QUERY)
|
||||
val sourcesList = parseTrailingSources()
|
||||
val usage = parseTrailingUsage()
|
||||
return StreamState(
|
||||
content = content.toString(),
|
||||
reasoning = reasoningBuf.toString(),
|
||||
tools = tools.toList(),
|
||||
query = queryStr,
|
||||
sources = sourcesList,
|
||||
inputTokens = usage?.i ?: 0,
|
||||
outputTokens = usage?.o ?: 0,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -135,6 +146,15 @@ 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) {
|
||||
val s = buf.toString()
|
||||
var pos = from
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ fun GlassSurface(
|
|||
* Use these constants with [GlassSurface] instead of ad-hoc alpha values.
|
||||
*/
|
||||
object GlassTiers {
|
||||
fun sidebar(isDark: Boolean) = if (isDark) 0.72f else 0.68f
|
||||
fun sidebar(isDark: Boolean) = if (isDark) 0.88f else 0.92f
|
||||
fun input(isDark: Boolean) = if (isDark) 0.75f else 0.74f
|
||||
fun pill(isDark: Boolean) = if (isDark) 0.80f else 0.82f
|
||||
fun chip(isDark: Boolean) = if (isDark) 0.85f else 0.90f
|
||||
|
|
|
|||
|
|
@ -61,6 +61,41 @@
|
|||
<string name="sidebar_earlier">EARLIER</string>
|
||||
<string name="sidebar_logout">Sign Out</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 -->
|
||||
<string name="model_sheet_title">Select Model</string>
|
||||
|
|
|
|||
|
|
@ -63,6 +63,41 @@
|
|||
<string name="sidebar_earlier">FRÜHER</string>
|
||||
<string name="sidebar_logout">Abmelden</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 -->
|
||||
<string name="model_sheet_title">Modell auswählen</string>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package dev.kaizen.app
|
|||
|
||||
import dev.kaizen.app.db.Converters
|
||||
import dev.kaizen.app.net.Attachment
|
||||
import dev.kaizen.app.net.SearchSource
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
|
@ -61,4 +62,56 @@ class ConvertersTest {
|
|||
assertEquals(1, result.size)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ import dev.kaizen.app.db.toStoredMessage
|
|||
import dev.kaizen.app.db.toSummary
|
||||
import dev.kaizen.app.net.Attachment
|
||||
import dev.kaizen.app.net.ConversationSummary
|
||||
import dev.kaizen.app.net.SearchSource
|
||||
import dev.kaizen.app.net.StoredMessage
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
|
|
@ -119,4 +121,64 @@ class MappersTest {
|
|||
assertEquals(original.attachments[0].mimeType, roundTripped.attachments[0].mimeType)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ okhttp = "4.12.0"
|
|||
kotlinxSerialization = "1.7.3"
|
||||
securityCrypto = "1.1.0-alpha06"
|
||||
room = "2.8.4"
|
||||
biometric = "1.4.0-alpha02"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
|
|
@ -21,6 +22,7 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-
|
|||
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-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" }
|
||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||
|
|
|
|||
Loading…
Reference in a new issue