feat: attachment support — display, upload, and send files in chat

- Parse and render message attachments from loaded conversations (images
  inline via OkHttp + in-memory cache, other kinds as icon chips)
- Wire up the + button with Android file picker (ActivityResultContracts)
- Upload files to /api/v1/upload with progress tracking and preview chips
- Send attachments with chat requests and persist them in saved messages
- Markdown: horizontal rules, blockquotes, links, code block copy button
This commit is contained in:
Bruno Deanoz 2026-06-20 00:36:07 +02:00
parent 4616ef2ba1
commit d8dcb25e3d
5 changed files with 566 additions and 69 deletions

View file

@ -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<PendingFile> = 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<String, ImageBitmap>(32, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, ImageBitmap>?) = 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<ImageBitmap?>(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<Attachment>, 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) {

View file

@ -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<Attachment> = emptyList(),
)
// Empty-state suggestion pills (mirrors the web hero)

View file

@ -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<String?>(null) }
// Pending file attachments — uploaded immediately on pick, cleared on send.
val pendingFiles = remember { mutableStateListOf<PendingFile>() }
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
}

View file

@ -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<ListItem>) : 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<MdBlock> {
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<MdBlock> {
|| 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<MdBlock> {
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),
)
}
}

View file

@ -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<WireMessage>,
val reasoningEffort: String? = null,
val preferThroughput: Boolean? = null,
val attachments: List<Attachment>? = 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<Attachment> = emptyList(),
)
@Serializable private data class ConversationDetail(val messages: List<StoredMessage> = emptyList())
@ -74,6 +88,7 @@ data class SaveMessage(
val content: String,
val kind: String = "chat",
val model: String? = null,
val attachments: List<Attachment>? = null,
)
@Serializable private data class SaveMessagesRequest(val messages: List<SaveMessage>)
@ -314,6 +329,7 @@ object KaizenApi {
model: String,
messages: List<WireMessage>,
speed: ChatSpeed = ChatSpeed.Normal,
attachments: List<Attachment>? = null,
): Flow<String> = 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<Attachment> = 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<Attachment>(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()