diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt index 6578038..5a349e6 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt @@ -46,6 +46,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -64,6 +65,7 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import dev.kaizen.app.net.Attachment import dev.kaizen.app.ui.theme.Amber import dev.kaizen.app.ui.theme.AmberDeep import dev.kaizen.app.ui.theme.OnAmber @@ -71,6 +73,27 @@ import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.util.Locale import kotlin.OptIn +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.material.icons.rounded.AudioFile +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Code +import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.automirrored.rounded.InsertDriveFile +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.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.util.concurrent.TimeUnit @Composable fun KaizenHeader(modifier: Modifier = Modifier) { @@ -283,38 +306,45 @@ fun MessageRow(message: Message) { val cs = MaterialTheme.colorScheme val isDark = isSystemInDarkTheme() + val hasAttachments = message.attachments.isNotEmpty() + if (message.role == Role.User) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { val shape = RoundedCornerShape(20.dp, 20.dp, 6.dp, 20.dp) - - // Specular highlighting and gradient matching the oklch brand colors + val borderBrush = remember { - Brush.verticalGradient( - listOf( - Amber.copy(alpha = 0.45f), - AmberDeep.copy(alpha = 0.15f) - ) - ) + Brush.verticalGradient(listOf(Amber.copy(alpha = 0.45f), AmberDeep.copy(alpha = 0.15f))) } val bgBrush = remember { - Brush.verticalGradient( - listOf( - Amber.copy(alpha = 0.18f), - AmberDeep.copy(alpha = 0.08f) - ) - ) + Brush.verticalGradient(listOf(Amber.copy(alpha = 0.18f), AmberDeep.copy(alpha = 0.08f))) } Box( Modifier .widthIn(max = 300.dp) - .shadow(elevation = 3.dp, shape = shape, clip = false) // Raised amber glass bubble! + .shadow(elevation = 3.dp, shape = shape, clip = false) .clip(shape) .background(bgBrush) - .border(1.2.dp, borderBrush, shape) - .padding(horizontal = 16.dp, vertical = 11.dp), + .border(1.2.dp, borderBrush, shape), ) { - Text(message.content, color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp) + 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, + ), + ) + } + } } } } else { @@ -322,13 +352,16 @@ fun MessageRow(message: Message) { KaizenOrb(28.dp, streaming = message.streaming) Spacer(Modifier.width(10.dp)) Box(Modifier.weight(1f).padding(top = 3.dp)) { - if (message.thinking) { - TypingDots() - } else { - MarkdownText( - text = message.content, - streaming = message.streaming, - ) + Column { + if (hasAttachments) { + AttachmentGrid(message.attachments) + if (message.content.isNotBlank() || message.thinking) Spacer(Modifier.height(8.dp)) + } + if (message.thinking) { + TypingDots() + } else { + MarkdownText(text = message.content, streaming = message.streaming) + } } } } @@ -361,12 +394,26 @@ private fun TypingDots() { } } +/** A file pending upload or already uploaded, shown as a preview chip above the input. */ +data class PendingFile( + val id: String = java.util.UUID.randomUUID().toString(), + val name: String, + val mimeType: String, + val bytes: ByteArray, + val uploaded: Attachment? = null, + val error: String? = null, + val uploading: Boolean = true, +) + @Composable fun ChatInput( value: String, onValueChange: (String) -> Unit, onSend: () -> Unit, enabled: Boolean, + pendingFiles: List = emptyList(), + onAddClick: () -> Unit = {}, + onRemoveFile: (String) -> Unit = {}, modifier: Modifier = Modifier, ) { val cs = MaterialTheme.colorScheme @@ -408,40 +455,63 @@ fun ChatInput( } } - Row( + Column( modifier .fillMaxWidth() .padding(horizontal = 12.dp) - .shadow(elevation = 8.dp, shape = RoundedCornerShape(28.dp), clip = false) // Luxurious floating ambient shadow! + .shadow(elevation = 8.dp, shape = RoundedCornerShape(28.dp), clip = false) .clip(RoundedCornerShape(28.dp)) .background(glassBg) - .border(1.2.dp, glassBorder, RoundedCornerShape(28.dp)) - .padding(horizontal = 8.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, + .border(1.2.dp, glassBorder, RoundedCornerShape(28.dp)), ) { - GhostIconButton(Icons.Rounded.Add, "Anhang") - Box( - Modifier - .weight(1f) - .heightIn(min = 36.dp) - .padding(horizontal = 4.dp), - contentAlignment = Alignment.CenterStart - ) { - if (value.isEmpty()) { - Text("Nachricht eingeben …", color = cs.onSurfaceVariant, fontSize = 16.sp) + if (pendingFiles.isNotEmpty()) { + Row( + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(start = 12.dp, end = 12.dp, top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + pendingFiles.forEach { pf -> + PendingFileChip(pf, onRemove = { onRemoveFile(pf.id) }) + } } - BasicTextField( - value = value, - onValueChange = onValueChange, - textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp), - cursorBrush = SolidColor(Amber), - maxLines = 6, - modifier = Modifier.fillMaxWidth(), - ) } - GhostIconButton(Icons.Rounded.Call, "Anruf") - Spacer(Modifier.width(4.dp)) - SendButton(enabled = enabled && value.isNotBlank(), onClick = onSend) + Row( + Modifier.padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier.size(40.dp).clip(CircleShape).clickable(onClick = onAddClick), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Rounded.Add, "Anhang", 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("Nachricht eingeben …", color = cs.onSurfaceVariant, fontSize = 16.sp) + } + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp), + cursorBrush = SolidColor(Amber), + maxLines = 6, + modifier = Modifier.fillMaxWidth(), + ) + } + GhostIconButton(Icons.Rounded.Call, "Anruf") + Spacer(Modifier.width(4.dp)) + val canSend = enabled && (value.isNotBlank() || pendingFiles.any { it.uploaded != null }) + && pendingFiles.none { it.uploading } + SendButton(enabled = canSend, onClick = onSend) + } } } @@ -453,6 +523,83 @@ private fun GhostIconButton(icon: ImageVector, contentDescription: String) { } } +@Composable +private fun PendingFileChip(pf: PendingFile, onRemove: () -> Unit) { + val cs = MaterialTheme.colorScheme + val isDark = isSystemInDarkTheme() + val bg = if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f) + val isImage = pf.mimeType.startsWith("image/") + + Box( + Modifier + .size(64.dp) + .clip(RoundedCornerShape(10.dp)) + .background(bg), + contentAlignment = Alignment.Center, + ) { + if (isImage && pf.uploaded != null) { + NetworkImage( + url = pf.uploaded.url, + contentDescription = pf.name, + modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(10.dp)), + ) + } else { + val icon = when { + pf.mimeType.startsWith("image/") -> Icons.Rounded.Image + pf.mimeType.startsWith("video/") -> Icons.Rounded.VideoFile + pf.mimeType.startsWith("audio/") -> Icons.Rounded.AudioFile + pf.mimeType == "application/pdf" -> Icons.Rounded.PictureAsPdf + else -> Icons.AutoMirrored.Rounded.InsertDriveFile + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(icon, null, tint = cs.onSurfaceVariant, modifier = Modifier.size(20.dp)) + Text( + pf.name, + color = cs.onSurfaceVariant, + fontSize = 9.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 4.dp), + ) + } + } + if (pf.uploading) { + Box( + Modifier.fillMaxSize() + .background(Color.Black.copy(alpha = 0.4f)), + contentAlignment = Alignment.Center, + ) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = Color.White, + ) + } + } + if (pf.error != null) { + Box( + Modifier.fillMaxSize() + .background(Color(0xFFDC2626).copy(alpha = 0.3f)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Rounded.Close, "Fehler", tint = Color.White, modifier = Modifier.size(18.dp)) + } + } + Box( + Modifier + .align(Alignment.TopEnd) + .padding(2.dp) + .size(18.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.5f)) + .clickable(onClick = onRemove), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Rounded.Close, "Entfernen", tint = Color.White, modifier = Modifier.size(12.dp)) + } + } +} + @Composable private fun SendButton(enabled: Boolean, onClick: () -> Unit) { val cs = MaterialTheme.colorScheme @@ -478,6 +625,117 @@ private fun SendButton(enabled: Boolean, onClick: () -> Unit) { } } +// ── Attachment rendering ──────────────────────────────────────────────────── + +private object NetworkImageCache { + private val cache = object : LinkedHashMap(32, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?) = size > 50 + } + private val client = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + suspend fun load(url: String): ImageBitmap? = withContext(Dispatchers.IO) { + synchronized(cache) { cache[url] }?.let { return@withContext it } + try { + val req = Request.Builder().url(url).build() + client.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) return@withContext null + val bytes = resp.body!!.bytes() + val probe = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, probe) + val scale = maxOf(1, maxOf(probe.outWidth, probe.outHeight) / 1536) + val opts = BitmapFactory.Options().apply { inSampleSize = scale } + val bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts) + ?: return@withContext null + bmp.asImageBitmap().also { synchronized(cache) { cache[url] = it } } + } + } catch (_: Exception) { null } + } +} + +@Composable +private fun NetworkImage( + url: String, + contentDescription: String?, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Crop, +) { + var bitmap by remember(url) { mutableStateOf(null) } + LaunchedEffect(url) { bitmap = NetworkImageCache.load(url) } + + val bmp = bitmap + if (bmp != null) { + Image( + bitmap = bmp, + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + ) + } else { + Box( + modifier + .fillMaxWidth() + .height(160.dp) + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)), + ) + } +} + +@Composable +fun AttachmentGrid(attachments: List, modifier: Modifier = Modifier) { + if (attachments.isEmpty()) return + val images = attachments.filter { it.kind == "image" } + val others = attachments.filter { it.kind != "image" } + + Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) { + images.forEach { att -> + NetworkImage( + url = att.url, + contentDescription = att.name, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 280.dp) + .clip(RoundedCornerShape(10.dp)), + ) + } + others.forEach { att -> FileChip(att) } + } +} + +@Composable +private fun FileChip(attachment: Attachment) { + val cs = MaterialTheme.colorScheme + val isDark = isSystemInDarkTheme() + val bg = if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f) + val icon = when (attachment.kind) { + "pdf" -> Icons.Rounded.PictureAsPdf + "audio" -> Icons.Rounded.AudioFile + "video" -> Icons.Rounded.VideoFile + "code" -> Icons.Rounded.Code + else -> Icons.AutoMirrored.Rounded.InsertDriveFile + } + Row( + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(bg) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, null, tint = cs.onSurfaceVariant, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text( + attachment.name, + color = cs.onSurfaceVariant, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + /** Crisp upward arrow drawn with strokes (sharper than a glyph). */ @Composable private fun SendArrow(color: Color) { diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt b/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt index 1e811ed..8a94a3e 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt @@ -9,6 +9,7 @@ import androidx.compose.material.icons.rounded.Movie import androidx.compose.material.icons.rounded.MusicNote import androidx.compose.material.icons.rounded.Tune import androidx.compose.ui.graphics.vector.ImageVector +import dev.kaizen.app.net.Attachment enum class Role { User, Assistant } @@ -18,9 +19,8 @@ data class Message( val content: String, val streaming: Boolean = false, val thinking: Boolean = false, - // Stable server-side id (UUID) used for persistence + parentId chaining. Generated - // on creation; overwritten with the DB id when loading an existing conversation. val wireId: String = java.util.UUID.randomUUID().toString(), + val attachments: List = emptyList(), ) // Empty-state suggestion pills (mirrors the web hero) diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt index ae17f03..0516b39 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -54,6 +54,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.kaizen.app.haptics.rememberHaptics +import dev.kaizen.app.net.Attachment import dev.kaizen.app.net.ChatHttpException import dev.kaizen.app.net.ChatSpeed import dev.kaizen.app.net.ConversationSummary @@ -65,6 +66,9 @@ import dev.kaizen.app.net.SessionViewModel import dev.kaizen.app.net.WireMessage import dev.kaizen.app.settings.SettingsScreen import dev.kaizen.app.settings.SettingsViewModel +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.LifecycleResumeEffect import kotlinx.coroutines.async import kotlinx.coroutines.launch @@ -110,6 +114,33 @@ fun ChatScreen( var loadError by remember { mutableStateOf(null) } + // Pending file attachments — uploaded immediately on pick, cleared on send. + val pendingFiles = remember { mutableStateListOf() } + val context = LocalContext.current + + val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + uri ?: return@rememberLauncherForActivityResult + val cfg = session.config ?: return@rememberLauncherForActivityResult + val cr = context.contentResolver + val mimeType = cr.getType(uri) ?: "application/octet-stream" + val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "file" + val bytes = cr.openInputStream(uri)?.use { it.readBytes() } ?: return@rememberLauncherForActivityResult + val pf = PendingFile(name = fileName, mimeType = mimeType, bytes = bytes) + pendingFiles.add(pf) + scope.launch { + when (val r = KaizenApi.uploadFile(cfg.baseUrl, cfg.token, fileName, mimeType, bytes)) { + 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) + } + } + } + } + LaunchedEffect(session.config?.baseUrl, session.config?.token) { val cfg = session.config ?: return@LaunchedEffect loadError = null @@ -169,6 +200,7 @@ fun ChatScreen( role = if (m.role == "assistant") Role.Assistant else Role.User, content = m.content, wireId = m.id, + attachments = m.attachments, ), ) } @@ -178,21 +210,23 @@ fun ChatScreen( fun send(text: String) { val trimmed = text.trim() - if (trimmed.isEmpty() || isStreaming) return + val uploadedAtts = pendingFiles.mapNotNull { it.uploaded } + if (trimmed.isEmpty() && uploadedAtts.isEmpty()) return + if (isStreaming) return val cfg = session.config ?: return haptics.click() - // parentId chains the linear history: the user msg hangs off the last message. + val userParentId = messages.lastOrNull()?.wireId - val userMsg = Message(nextId++, Role.User, trimmed) + val userMsg = Message(nextId++, Role.User, trimmed, attachments = uploadedAtts) messages.add(userMsg) input = "" + pendingFiles.clear() val assistantId = nextId++ val assistantWireId = java.util.UUID.randomUUID().toString() messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId)) isStreaming = true val wasNewConversation = conversationId == null - // Wire history = the visible conversation minus the streaming placeholder. val history = messages .filter { it.id != assistantId } .map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) } @@ -201,14 +235,14 @@ fun ChatScreen( haptics.thinkingStart() var sawContent = false try { - // Launch conversation creation in parallel — the chat stream does not - // need a conversation id, only the post-stream persistence does. This - // eliminates a full network RTT from the TTFT on new chats. val convIdDeferred = if (conversationId == null) { async { KaizenApi.createConversation(cfg.baseUrl, cfg.token) } } else null - KaizenApi.chat(cfg.baseUrl, cfg.token, cfg.model, history, cfg.speed).collect { visible -> + KaizenApi.chat( + cfg.baseUrl, cfg.token, cfg.model, history, cfg.speed, + attachments = uploadedAtts.takeIf { it.isNotEmpty() }, + ).collect { visible -> val idx = messages.indexOfLast { it.id == assistantId } if (idx < 0) return@collect if (!sawContent && visible.isNotEmpty()) { @@ -224,15 +258,17 @@ fun ChatScreen( val finalContent = if (idx >= 0) messages[idx].content else "" if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false) - // Await the conversation id — the create ran in parallel with the stream val convId = convIdDeferred?.await()?.also { conversationId = it } ?: conversationId - // Persist the exchange (best-effort); auto-title + sidebar refresh on a new conversation. if (convId != null && finalContent.isNotEmpty()) { val saved = KaizenApi.saveMessages( cfg.baseUrl, cfg.token, convId, listOf( - SaveMessage(id = userMsg.wireId, parentId = userParentId, role = "user", content = trimmed), + SaveMessage( + id = userMsg.wireId, parentId = userParentId, + role = "user", content = trimmed, + attachments = uploadedAtts.takeIf { it.isNotEmpty() }, + ), SaveMessage(id = assistantWireId, parentId = userMsg.wireId, role = "assistant", content = finalContent, model = cfg.model), ), ) @@ -425,6 +461,9 @@ fun ChatScreen( onValueChange = { input = it }, onSend = { send(input) }, enabled = !isStreaming, + pendingFiles = pendingFiles, + onAddClick = { filePicker.launch("*/*") }, + onRemoveFile = { id -> pendingFiles.removeAll { it.id == id } }, ) Spacer(Modifier.height(16.dp)) // Completely clears keyboard edges on all screens } diff --git a/app/src/main/java/dev/kaizen/app/chat/Markdown.kt b/app/src/main/java/dev/kaizen/app/chat/Markdown.kt index 9193343..0988e20 100644 --- a/app/src/main/java/dev/kaizen/app/chat/Markdown.kt +++ b/app/src/main/java/dev/kaizen/app/chat/Markdown.kt @@ -2,25 +2,40 @@ package dev.kaizen.app.chat import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.platform.LocalClipboard import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -28,8 +43,12 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import dev.kaizen.app.haptics.rememberHaptics +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch private sealed interface MdBlock { data class Paragraph(val text: String) : MdBlock @@ -37,6 +56,8 @@ private sealed interface MdBlock { data class CodeFence(val lang: String, val code: String) : MdBlock data class ListBlock(val items: List) : MdBlock data class Item(val text: String, val ordered: Boolean, val index: Int) : MdBlock + data object HorizontalRule : MdBlock + data class Blockquote(val text: String) : MdBlock } private data class ListItem(val text: String, val ordered: Boolean, val index: Int) @@ -48,6 +69,29 @@ private fun parseBlocks(src: String): List { while (i < lines.size) { val line = lines[i] + // Horizontal rule: ---, ***, ___ (3+ of same char, optionally spaced) + if (Regex("^\\s*([-*_])\\s*\\1\\s*\\1[\\s\\-*_]*$").matches(line)) { + blocks.add(MdBlock.HorizontalRule) + i++ + continue + } + + // Blockquote: > ... + val bqMatch = Regex("^\\s*>\\s?(.*)").matchEntire(line) + if (bqMatch != null) { + val bqLines = mutableListOf(bqMatch.groupValues[1]) + i++ + while (i < lines.size) { + val m = Regex("^\\s*>\\s?(.*)").matchEntire(lines[i]) + if (m != null) { + bqLines.add(m.groupValues[1]) + i++ + } else break + } + blocks.add(MdBlock.Blockquote(bqLines.joinToString("\n"))) + continue + } + // Code fence if (line.trimStart().startsWith("```")) { val lang = line.trimStart().removePrefix("```").trim() @@ -117,6 +161,8 @@ private fun parseBlocks(src: String): List { || Regex("^#{1,3}\\s+").containsMatchIn(next) || Regex("^\\s*[-*+]\\s+").containsMatchIn(next) || Regex("^\\s*\\d+[.)]\\s+").containsMatchIn(next) + || Regex("^\\s*([-*_])\\s*\\1\\s*\\1[\\s\\-*_]*$").matches(next) + || Regex("^\\s*>\\s?").containsMatchIn(next) ) break paraLines.add(next) i++ @@ -126,11 +172,26 @@ private fun parseBlocks(src: String): List { return blocks } -private fun inlineMarkdown(text: String, baseColor: Color, codeColor: Color, codeBg: Color): AnnotatedString { +private fun inlineMarkdown(text: String, baseColor: Color, codeColor: Color, codeBg: Color, linkColor: Color = Color(0xFF3B82F6)): AnnotatedString { return buildAnnotatedString { var pos = 0 val src = text while (pos < src.length) { + // Link: [text](url) + if (src[pos] == '[') { + val closeBracket = src.indexOf(']', pos + 1) + if (closeBracket > pos && closeBracket + 1 < src.length && src[closeBracket + 1] == '(') { + val closeParen = src.indexOf(')', closeBracket + 2) + if (closeParen > closeBracket) { + val linkText = src.substring(pos + 1, closeBracket) + withStyle(SpanStyle(color = linkColor, fontWeight = FontWeight.Medium)) { + append(linkText) + } + pos = closeParen + 1 + continue + } + } + } // Inline code: `...` if (src[pos] == '`' && pos + 1 < src.length) { val end = src.indexOf('`', pos + 1) @@ -324,20 +385,27 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier = .border(1.dp, codeBorder, RoundedCornerShape(10.dp)) ) { Column { - if (block.lang.isNotBlank()) { + // Header row: lang badge + copy button + Row( + Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 6.dp, top = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { Text( - block.lang, + if (block.lang.isNotBlank()) block.lang else "code", color = langBadgeColor, fontSize = 11.sp, fontWeight = FontWeight.Medium, - modifier = Modifier.padding(start = 12.dp, top = 8.dp), ) + Spacer(Modifier.weight(1f)) + CopyButton(block.code) } Box( Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()) - .padding(horizontal = 12.dp, vertical = if (block.lang.isNotBlank()) 6.dp else 10.dp) + .padding(horizontal = 12.dp, vertical = 6.dp) ) { Text( text = highlightCode(block.code + if (isLast) cursor else "", block.lang, isDark), @@ -375,6 +443,45 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier = Spacer(Modifier.height(4.dp)) } + is MdBlock.HorizontalRule -> { + if (idx > 0) Spacer(Modifier.height(8.dp)) + Box( + Modifier + .fillMaxWidth() + .height(1.dp) + .background(cs.onSurfaceVariant.copy(alpha = 0.25f)) + ) + Spacer(Modifier.height(8.dp)) + } + + is MdBlock.Blockquote -> { + if (idx > 0) Spacer(Modifier.height(6.dp)) + Row( + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(start = 2.dp) + ) { + Box( + Modifier + .width(3.dp) + .fillMaxHeight() + .clip(RoundedCornerShape(1.5.dp)) + .background(cs.primary.copy(alpha = 0.5f)) + ) + Spacer(Modifier.width(10.dp)) + Text( + text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onSurfaceVariant, inlineCodeColor, inlineCodeBg), + color = cs.onSurfaceVariant, + fontSize = 15.sp, + lineHeight = 22.sp, + fontStyle = FontStyle.Italic, + modifier = Modifier.weight(1f), + ) + } + Spacer(Modifier.height(6.dp)) + } + is MdBlock.Paragraph -> { if (idx > 0) Spacer(Modifier.height(6.dp)) Text( @@ -390,3 +497,39 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier = } } } + +@Composable +private fun CopyButton(code: String) { + val clipboard = LocalClipboard.current + val haptics = rememberHaptics() + val scope = rememberCoroutineScope() + var copied by remember { mutableStateOf(false) } + val cs = MaterialTheme.colorScheme + + Box( + Modifier + .size(28.dp) + .clip(CircleShape) + .clickable { + haptics.tick() + copied = true + scope.launch { + clipboard.setClipEntry( + androidx.compose.ui.platform.ClipEntry( + android.content.ClipData.newPlainText("code", code) + ) + ) + delay(1500) + copied = false + } + }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = if (copied) Icons.Rounded.Check else Icons.Rounded.ContentCopy, + contentDescription = "Kopieren", + tint = if (copied) Color(0xFF22C55E) else cs.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.size(14.dp), + ) + } +} 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 89cec84..5968231 100644 --- a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt +++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt @@ -10,6 +10,7 @@ import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody @@ -29,6 +30,7 @@ import java.util.concurrent.TimeUnit val messages: List, val reasoningEffort: String? = null, val preferThroughput: Boolean? = null, + val attachments: List? = null, ) /** One entry from GET /api/v1/models — the picker's row. `provider == "vertex"` drives the hoster badge. */ @@ -55,12 +57,24 @@ data class ConversationSummary( @Serializable private data class CreateConvoRequest(val title: String? = null) @Serializable private data class CreatedConversation(val id: String) +/** Attachment on a persisted message (image, audio, video, pdf, document, code). */ +@Serializable +data class Attachment( + val url: String, + val kind: String = "", + val name: String = "", + val mimeType: String = "", + val size: Long? = null, + val fileId: String? = null, +) + /** One persisted message from GET /api/v1/conversations/[id]. */ @Serializable data class StoredMessage( val id: String, val role: String, val content: String = "", + val attachments: List = emptyList(), ) @Serializable private data class ConversationDetail(val messages: List = emptyList()) @@ -74,6 +88,7 @@ data class SaveMessage( val content: String, val kind: String = "chat", val model: String? = null, + val attachments: List? = null, ) @Serializable private data class SaveMessagesRequest(val messages: List) @@ -314,6 +329,7 @@ object KaizenApi { model: String, messages: List, speed: ChatSpeed = ChatSpeed.Normal, + attachments: List? = null, ): Flow = flow { val payload = json.encodeToString( ChatRequest( @@ -321,6 +337,7 @@ object KaizenApi { messages = messages, reasoningEffort = speed.reasoningEffort, preferThroughput = if (speed.preferThroughput) true else null, + attachments = attachments?.takeIf { it.isNotEmpty() }, ), ).toRequestBody(jsonMedia) val req = Request.Builder() @@ -354,6 +371,46 @@ object KaizenApi { * handshake (~150-300ms on mobile) from the first real request after app * start or background resume. */ + /** POST /api/v1/upload — multipart file upload, returns the created Attachment. */ + suspend fun uploadFile( + baseUrl: String, + token: String, + fileName: String, + mimeType: String, + bytes: ByteArray, + ): FetchResult = withContext(Dispatchers.IO) { + val body = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart( + "file", fileName, + bytes.toRequestBody(mimeType.toMediaType()), + ) + .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) + } + val raw = resp.body!!.string() + FetchResult.Ok(json.decodeFromString(raw)) + } + } catch (e: Exception) { + Log.w(TAG, "uploadFile: ${e.javaClass.simpleName}: ${e.message}") + FetchResult.Fail(diagnoseFetchError("upload", e)) + } + } + suspend fun prewarm(baseUrl: String) = withContext(Dispatchers.IO) { try { val req = Request.Builder().url("$baseUrl/api/v1/meta").build()