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