diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 16d35ca..1177c40 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -16,7 +16,8 @@
+ fun uploadPickedUri(uri: android.net.Uri) {
+ val cfg = session.config ?: return
val cr = context.contentResolver
- for (uri in uris) {
- val mimeType = cr.getType(uri) ?: "image/jpeg"
- val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "image_${System.currentTimeMillis()}"
- val bytes = cr.openInputStream(uri)?.use { it.readBytes() } ?: continue
- uploadPickedFile(fileName, mimeType, bytes)
+ val mimeType = cr.getType(uri) ?: "application/octet-stream"
+ val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "file_${System.currentTimeMillis()}"
+ val size = try { cr.openFileDescriptor(uri, "r")?.use { it.statSize } ?: -1L } catch (_: Exception) { -1L }
+ val pf = PendingFile(name = fileName, mimeType = mimeType)
+ pendingFiles.add(pf)
+ scope.launch {
+ val inputStream = cr.openInputStream(uri)
+ if (inputStream == null) {
+ val idx = pendingFiles.indexOfFirst { it.id == pf.id }
+ if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(error = "Cannot read file", uploading = false)
+ return@launch
+ }
+ val r = inputStream.use { stream ->
+ KaizenApi.uploadStream(cfg.baseUrl, cfg.token, fileName, mimeType, stream, size)
+ }
+ when (r) {
+ is FetchResult.Ok -> {
+ val idx = pendingFiles.indexOfFirst { it.id == pf.id }
+ if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(uploaded = r.data, uploading = false)
+ }
+ is FetchResult.Fail -> {
+ val idx = pendingFiles.indexOfFirst { it.id == pf.id }
+ if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(error = r.reason, uploading = false)
+ }
+ }
}
}
+ val galleryPicker = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris ->
+ for (uri in uris) uploadPickedUri(uri)
+ }
+
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
- val cr = context.contentResolver
- for (uri in uris) {
- val mimeType = cr.getType(uri) ?: "application/octet-stream"
- val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "file"
- val bytes = cr.openInputStream(uri)?.use { it.readBytes() } ?: continue
- uploadPickedFile(fileName, mimeType, bytes)
- }
+ for (uri in uris) uploadPickedUri(uri)
}
val cameraPicker = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap ->
@@ -409,10 +428,13 @@ fun ChatScreen(
fun toggleRecording() {
if (isRecording) {
- stopRecording(recorderRef, audioFileRef) { name, mime, bytes ->
+ stopRecording(recorderRef, audioFileRef) { name, mime, file ->
isRecording = false
val cfg = session.config ?: return@stopRecording
scope.launch {
+ val bytes = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
+ file.readBytes().also { file.delete() }
+ }
when (val r = KaizenApi.uploadFile(cfg.baseUrl, cfg.token, name, mime, bytes)) {
is FetchResult.Ok -> {
val att = r.data
@@ -421,14 +443,12 @@ fun ChatScreen(
messages.add(userMsg)
val assistantId = nextId++
val assistantWireId = java.util.UUID.randomUUID().toString()
+ val history = messages.map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) }
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId))
+ val voiceAssistantIdx = messages.lastIndex
isStreaming = true
haptics.click()
- val history = messages
- .filter { it.id != assistantId }
- .map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) }
-
val convIdDeferred = if (conversationId == null) {
scope.async { KaizenApi.createConversation(cfg.baseUrl, cfg.token) }
} else null
@@ -441,13 +461,12 @@ fun ChatScreen(
cfg.baseUrl, cfg.token, cfg.model, history,
attachments = listOf(att),
).collect { state ->
- val idx = messages.indexOfLast { it.id == assistantId }
- if (idx < 0) return@collect
+ if (voiceAssistantIdx >= messages.size) return@collect
if (!sawContent && state.content.isNotEmpty()) {
sawContent = true; haptics.responseStart()
}
- messages[idx] = messages[idx].copy(
- thinking = if (sawContent) false else messages[idx].thinking,
+ messages[voiceAssistantIdx] = messages[voiceAssistantIdx].copy(
+ thinking = if (sawContent) false else messages[voiceAssistantIdx].thinking,
content = state.content,
reasoning = state.reasoning,
tools = state.tools,
@@ -458,14 +477,12 @@ fun ChatScreen(
}
} catch (_: kotlinx.coroutines.CancellationException) {
} catch (_: Exception) {}
- val assistantIdx = messages.indexOfLast { it.id == assistantId }
- if (assistantIdx >= 0) messages[assistantIdx] = messages[assistantIdx].copy(streaming = false, thinking = false)
+ if (voiceAssistantIdx < messages.size) messages[voiceAssistantIdx] = messages[voiceAssistantIdx].copy(streaming = false, thinking = false)
isStreaming = false
haptics.responseEnd()
- // Persist
val cid = convIdDeferred?.await() ?: conversationId ?: return@launch
if (conversationId == null) conversationId = cid
- val assistantMsg = messages.lastOrNull { it.id == assistantId } ?: return@launch
+ val assistantMsg = if (voiceAssistantIdx < messages.size) messages[voiceAssistantIdx] else return@launch
val finalContent = assistantMsg.content
val finalReasoning = assistantMsg.reasoning.takeIf { it.isNotBlank() }
val finalSources = assistantMsg.sources.takeIf { it.isNotEmpty() }
@@ -765,7 +782,8 @@ fun ChatScreen(
val uploadedAtts = if (branchAttachments.isNotEmpty()) branchAttachments else pendingFiles.mapNotNull { it.uploaded }
if (trimmed.isEmpty() && uploadedAtts.isEmpty()) return
if (isStreaming) return
- val cfg = session.config ?: return
+ isStreaming = true
+ val cfg = session.config ?: run { isStreaming = false; return }
haptics.click()
val userParentId = editParentId ?: branchParentId ?: messages.lastOrNull()?.wireId
@@ -776,14 +794,11 @@ fun ChatScreen(
if (branchAttachments.isEmpty()) pendingFiles.clear()
val assistantId = nextId++
val assistantWireId = java.util.UUID.randomUUID().toString()
+ val history = messages.map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) }
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId))
- isStreaming = true
+ val assistantIdx = messages.lastIndex
val wasNewConversation = conversationId == null
- val history = messages
- .filter { it.id != assistantId }
- .map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) }
-
streamJob = scope.launch {
haptics.thinkingStart()
var sawContent = false
@@ -803,14 +818,13 @@ fun ChatScreen(
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
+ if (assistantIdx >= messages.size) return@collect
if (!sawContent && state.content.isNotEmpty()) {
sawContent = true
haptics.responseStart()
}
- messages[idx] = messages[idx].copy(
- thinking = if (sawContent) false else messages[idx].thinking,
+ messages[assistantIdx] = messages[assistantIdx].copy(
+ thinking = if (sawContent) false else messages[assistantIdx].thinking,
content = state.content,
reasoning = state.reasoning,
tools = state.tools,
@@ -821,12 +835,11 @@ fun ChatScreen(
usedTokens = state.inputTokens + state.outputTokens
}
}
- val idx = messages.indexOfLast { it.id == assistantId }
- val finalMsg = if (idx >= 0) messages[idx] else null
+ val finalMsg = if (assistantIdx < messages.size) messages[assistantIdx] 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)
+ if (assistantIdx < messages.size) messages[assistantIdx] = messages[assistantIdx].copy(streaming = false, thinking = false)
val convId = convIdDeferred?.await()?.also { cid ->
conversationId = cid
@@ -897,16 +910,16 @@ fun ChatScreen(
}
}
} catch (e: Exception) {
- val idx = messages.indexOfLast { it.id == assistantId }
+ val valid = assistantIdx < messages.size
if (e is kotlinx.coroutines.CancellationException) {
- if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
+ if (valid) messages[assistantIdx] = messages[assistantIdx].copy(streaming = false, thinking = false)
} else if (e is ChatHttpException && e.code == 401) {
- if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
+ if (valid) messages[assistantIdx] = messages[assistantIdx].copy(streaming = false, thinking = false)
session.logout()
- } else if (idx >= 0) {
- val existing = messages[idx].content
+ } else if (valid) {
+ val existing = messages[assistantIdx].content
val errorSuffix = "\n\n⚠ ${chatErrorText(e, context)}"
- messages[idx] = messages[idx].copy(
+ messages[assistantIdx] = messages[assistantIdx].copy(
streaming = false,
thinking = false,
content = if (existing.isNotBlank()) existing + errorSuffix else chatErrorText(e, context),
@@ -1517,7 +1530,7 @@ private fun startRecording(
private fun stopRecording(
recorderRef: androidx.compose.runtime.MutableState,
audioFileRef: androidx.compose.runtime.MutableState,
- onRecorded: (String, String, ByteArray) -> Unit,
+ onRecorded: (String, String, File) -> Unit,
) {
val recorder = recorderRef.value ?: return
val file = audioFileRef.value ?: return
@@ -1530,8 +1543,6 @@ private fun stopRecording(
recorderRef.value = null
audioFileRef.value = null
if (file.exists() && file.length() > 0) {
- val bytes = file.readBytes()
- file.delete()
- onRecorded("voice_${System.currentTimeMillis()}.m4a", "audio/mp4", bytes)
+ onRecorded("voice_${System.currentTimeMillis()}.m4a", "audio/mp4", file)
}
}
diff --git a/app/src/main/java/dev/kaizen/app/db/MessageRepository.kt b/app/src/main/java/dev/kaizen/app/db/MessageRepository.kt
index b90df6d..cd7fd10 100644
--- a/app/src/main/java/dev/kaizen/app/db/MessageRepository.kt
+++ b/app/src/main/java/dev/kaizen/app/db/MessageRepository.kt
@@ -1,7 +1,9 @@
package dev.kaizen.app.db
import dev.kaizen.app.net.KaizenApi
+import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.launch
class MessageRepository(private val dao: MessageDao) {
@@ -24,11 +26,14 @@ class MessageRepository(private val dao: MessageDao) {
suspend fun prefetchMissing(baseUrl: String, token: String, conversationIds: List) {
val cached = dao.cachedConversationIds().toSet()
- for (id in conversationIds) {
- if (id in cached) continue
- try {
- fetchAndCache(baseUrl, token, id)
- } catch (_: Exception) { }
+ val missing = conversationIds.filter { it !in cached }
+ if (missing.isEmpty()) return
+ coroutineScope {
+ for (id in missing) {
+ launch {
+ try { fetchAndCache(baseUrl, token, id) } catch (_: Exception) { }
+ }
+ }
}
}
}
diff --git a/app/src/main/java/dev/kaizen/app/live/LiveClient.kt b/app/src/main/java/dev/kaizen/app/live/LiveClient.kt
index 34fd548..7583d4e 100644
--- a/app/src/main/java/dev/kaizen/app/live/LiveClient.kt
+++ b/app/src/main/java/dev/kaizen/app/live/LiveClient.kt
@@ -9,6 +9,7 @@ import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
+import dev.kaizen.app.net.KaizenApi
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
@@ -57,10 +58,9 @@ class LiveClient(
val conversationId: String?,
)
- private val client = OkHttpClient.Builder()
+ private val client = KaizenApi.baseClient.newBuilder()
.readTimeout(0, TimeUnit.MILLISECONDS)
.pingInterval(15, TimeUnit.SECONDS)
- .connectTimeout(10, TimeUnit.SECONDS)
.build()
// ─── Connect ──────────────────────────────────────────────────────────
diff --git a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt
index 34a8266..b9ec7b4 100644
--- a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt
+++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt
@@ -13,9 +13,13 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
+import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
+import okio.BufferedSink
+import okio.source
import android.util.Log
import java.io.IOException
+import java.io.InputStream
import java.io.InputStreamReader
import java.util.concurrent.TimeUnit
@@ -80,6 +84,7 @@ data class ConversationSummary(
val isCurrent: Boolean = false,
)
@Serializable private data class AppDevicesResponse(val tokens: List = emptyList())
+@Serializable private data class DeleteMessageRequest(val messageId: String)
@Serializable private data class ChangePasswordRequest(val currentPassword: String, val newPassword: String)
/** Attachment on a persisted message (image, audio, video, pdf, document, code). */
@@ -176,15 +181,19 @@ object KaizenApi {
private const val TAG = "KaizenApi"
- private val client = OkHttpClient.Builder()
+ val baseClient: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
- .readTimeout(0, TimeUnit.SECONDS) // streaming chat — never time out a healthy stream
- .build()
-
- val imageClient: OkHttpClient = client.newBuilder()
.readTimeout(30, TimeUnit.SECONDS)
.build()
+ private val client = baseClient
+
+ private val streamClient: OkHttpClient = client.newBuilder()
+ .readTimeout(0, TimeUnit.SECONDS)
+ .build()
+
+ val imageClient: OkHttpClient = client
+
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val jsonMedia = "application/json".toMediaType()
@@ -227,22 +236,6 @@ object KaizenApi {
}
}
- suspend fun fetchDefaultModel(baseUrl: String, token: String): String? =
- withContext(Dispatchers.IO) {
- val req = Request.Builder()
- .url("$baseUrl/api/v1/me")
- .header("Authorization", "Bearer $token")
- .build()
- try {
- client.newCall(req).execute().use { resp ->
- if (!resp.isSuccessful) return@use null
- json.decodeFromString(resp.body!!.string()).defaultModel
- }
- } catch (e: Exception) {
- null
- }
- }
-
/** GET /api/v1/models — the full model catalog for the picker. */
suspend fun fetchModels(baseUrl: String, token: String): FetchResult> =
withContext(Dispatchers.IO) {
@@ -258,7 +251,7 @@ object KaizenApi {
403 -> " (Zugriff verweigert)"
else -> ""
}
- Log.w(TAG, "fetchModels: HTTP ${resp.code} from $baseUrl")
+ Log.w(TAG, "fetchModels: HTTP ${resp.code}")
return@use FetchResult.Fail(reason)
}
FetchResult.Ok(json.decodeFromString(resp.body!!.string()).models)
@@ -284,7 +277,7 @@ object KaizenApi {
403 -> " (Zugriff verweigert)"
else -> ""
}
- Log.w(TAG, "fetchConversations: HTTP ${resp.code} from $baseUrl")
+ Log.w(TAG, "fetchConversations: HTTP ${resp.code}")
return@use FetchResult.Fail(reason)
}
FetchResult.Ok(json.decodeFromString(resp.body!!.string()).conversations)
@@ -459,7 +452,7 @@ object KaizenApi {
/** 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 body = json.encodeToString(DeleteMessageRequest(messageId)).toRequestBody(jsonMedia)
val req = Request.Builder()
.url("$baseUrl/api/v1/conversations/$conversationId/messages")
.delete(body)
@@ -539,7 +532,7 @@ object KaizenApi {
.header("Authorization", "Bearer $token")
.build()
- val resp = client.newCall(req).execute()
+ val resp = streamClient.newCall(req).execute()
if (!resp.isSuccessful) {
val code = resp.code
resp.close()
@@ -604,6 +597,50 @@ object KaizenApi {
}
}
+ suspend fun uploadStream(
+ baseUrl: String,
+ token: String,
+ fileName: String,
+ mimeType: String,
+ inputStream: InputStream,
+ contentLength: Long = -1L,
+ ): FetchResult = withContext(Dispatchers.IO) {
+ val mediaType = mimeType.toMediaType()
+ val streamBody = object : RequestBody() {
+ override fun contentType() = mediaType
+ override fun contentLength() = contentLength
+ override fun writeTo(sink: BufferedSink) {
+ inputStream.source().use { source -> sink.writeAll(source) }
+ }
+ }
+ val body = MultipartBody.Builder()
+ .setType(MultipartBody.FORM)
+ .addFormDataPart("file", fileName, streamBody)
+ .build()
+ val req = Request.Builder()
+ .url("$baseUrl/api/v1/upload")
+ .post(body)
+ .header("Authorization", "Bearer $token")
+ .build()
+ try {
+ client.newCall(req).execute().use { resp ->
+ if (!resp.isSuccessful) {
+ val reason = "upload: HTTP ${resp.code}" + when (resp.code) {
+ 401 -> " (Token ungültig)"
+ 413 -> " (Datei zu groß)"
+ else -> ""
+ }
+ Log.w(TAG, "uploadFile: HTTP ${resp.code}")
+ return@use FetchResult.Fail(reason)
+ }
+ FetchResult.Ok(json.decodeFromString(resp.body!!.string()))
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "uploadStream: ${e.javaClass.simpleName}: ${e.message}")
+ FetchResult.Fail(diagnoseFetchError("upload", e))
+ }
+ }
+
@Serializable private data class SpeechRequest(val prompt: String, val kind: String = "speech", val voice: String? = null)
@Serializable private data class SpeechResponse(val url: String = "", val cost: Double? = null)
diff --git a/app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt b/app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt
index 11a8c6f..670419b 100644
--- a/app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt
+++ b/app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt
@@ -39,7 +39,8 @@ class SessionViewModel(val store: SecureStore) {
val baseUrl = normalizeBaseUrl(baseUrlInput)
return when (val result = KaizenApi.login(baseUrl, email, password, deviceName)) {
is LoginResult.Success -> {
- val model = KaizenApi.fetchDefaultModel(baseUrl, result.token) ?: DEFAULT_MODEL
+ val me = KaizenApi.fetchMe(baseUrl, result.token)
+ val model = me?.defaultModel ?: DEFAULT_MODEL
val cfg = ServerConfig(baseUrl = baseUrl, token = result.token, model = model, email = email.trim())
store.save(cfg)
config = cfg