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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.Amber
import dev.kaizen.app.ui.theme.AmberDeep import dev.kaizen.app.ui.theme.AmberDeep
import dev.kaizen.app.ui.theme.OnAmber import dev.kaizen.app.ui.theme.OnAmber
@ -71,6 +73,27 @@ import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
import kotlin.OptIn 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 @Composable
fun KaizenHeader(modifier: Modifier = Modifier) { fun KaizenHeader(modifier: Modifier = Modifier) {
@ -283,38 +306,45 @@ fun MessageRow(message: Message) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val hasAttachments = message.attachments.isNotEmpty()
if (message.role == Role.User) { if (message.role == Role.User) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
val shape = RoundedCornerShape(20.dp, 20.dp, 6.dp, 20.dp) val shape = RoundedCornerShape(20.dp, 20.dp, 6.dp, 20.dp)
// Specular highlighting and gradient matching the oklch brand colors
val borderBrush = remember { val borderBrush = remember {
Brush.verticalGradient( Brush.verticalGradient(listOf(Amber.copy(alpha = 0.45f), AmberDeep.copy(alpha = 0.15f)))
listOf(
Amber.copy(alpha = 0.45f),
AmberDeep.copy(alpha = 0.15f)
)
)
} }
val bgBrush = remember { val bgBrush = remember {
Brush.verticalGradient( Brush.verticalGradient(listOf(Amber.copy(alpha = 0.18f), AmberDeep.copy(alpha = 0.08f)))
listOf(
Amber.copy(alpha = 0.18f),
AmberDeep.copy(alpha = 0.08f)
)
)
} }
Box( Box(
Modifier Modifier
.widthIn(max = 300.dp) .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) .clip(shape)
.background(bgBrush) .background(bgBrush)
.border(1.2.dp, borderBrush, shape) .border(1.2.dp, borderBrush, shape),
.padding(horizontal = 16.dp, vertical = 11.dp),
) { ) {
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 { } else {
@ -322,13 +352,16 @@ fun MessageRow(message: Message) {
KaizenOrb(28.dp, streaming = message.streaming) KaizenOrb(28.dp, streaming = message.streaming)
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
Box(Modifier.weight(1f).padding(top = 3.dp)) { Box(Modifier.weight(1f).padding(top = 3.dp)) {
if (message.thinking) { Column {
TypingDots() if (hasAttachments) {
} else { AttachmentGrid(message.attachments)
MarkdownText( if (message.content.isNotBlank() || message.thinking) Spacer(Modifier.height(8.dp))
text = message.content, }
streaming = message.streaming, 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 @Composable
fun ChatInput( fun ChatInput(
value: String, value: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
onSend: () -> Unit, onSend: () -> Unit,
enabled: Boolean, enabled: Boolean,
pendingFiles: List<PendingFile> = emptyList(),
onAddClick: () -> Unit = {},
onRemoveFile: (String) -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
@ -408,40 +455,63 @@ fun ChatInput(
} }
} }
Row( Column(
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp) .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)) .clip(RoundedCornerShape(28.dp))
.background(glassBg) .background(glassBg)
.border(1.2.dp, glassBorder, RoundedCornerShape(28.dp)) .border(1.2.dp, glassBorder, RoundedCornerShape(28.dp)),
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
GhostIconButton(Icons.Rounded.Add, "Anhang") if (pendingFiles.isNotEmpty()) {
Box( Row(
Modifier Modifier
.weight(1f) .fillMaxWidth()
.heightIn(min = 36.dp) .horizontalScroll(rememberScrollState())
.padding(horizontal = 4.dp), .padding(start = 12.dp, end = 12.dp, top = 8.dp),
contentAlignment = Alignment.CenterStart horizontalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
if (value.isEmpty()) { pendingFiles.forEach { pf ->
Text("Nachricht eingeben …", color = cs.onSurfaceVariant, fontSize = 16.sp) 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") Row(
Spacer(Modifier.width(4.dp)) Modifier.padding(horizontal = 8.dp, vertical = 8.dp),
SendButton(enabled = enabled && value.isNotBlank(), onClick = onSend) 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 @Composable
private fun SendButton(enabled: Boolean, onClick: () -> Unit) { private fun SendButton(enabled: Boolean, onClick: () -> Unit) {
val cs = MaterialTheme.colorScheme 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). */ /** Crisp upward arrow drawn with strokes (sharper than a glyph). */
@Composable @Composable
private fun SendArrow(color: Color) { 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.MusicNote
import androidx.compose.material.icons.rounded.Tune import androidx.compose.material.icons.rounded.Tune
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import dev.kaizen.app.net.Attachment
enum class Role { User, Assistant } enum class Role { User, Assistant }
@ -18,9 +19,8 @@ data class Message(
val content: String, val content: String,
val streaming: Boolean = false, val streaming: Boolean = false,
val thinking: 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 wireId: String = java.util.UUID.randomUUID().toString(),
val attachments: List<Attachment> = emptyList(),
) )
// Empty-state suggestion pills (mirrors the web hero) // 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.kaizen.app.haptics.rememberHaptics import dev.kaizen.app.haptics.rememberHaptics
import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.ChatHttpException import dev.kaizen.app.net.ChatHttpException
import dev.kaizen.app.net.ChatSpeed import dev.kaizen.app.net.ChatSpeed
import dev.kaizen.app.net.ConversationSummary 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.net.WireMessage
import dev.kaizen.app.settings.SettingsScreen import dev.kaizen.app.settings.SettingsScreen
import dev.kaizen.app.settings.SettingsViewModel 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 androidx.lifecycle.compose.LifecycleResumeEffect
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -110,6 +114,33 @@ fun ChatScreen(
var loadError by remember { mutableStateOf<String?>(null) } 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) { LaunchedEffect(session.config?.baseUrl, session.config?.token) {
val cfg = session.config ?: return@LaunchedEffect val cfg = session.config ?: return@LaunchedEffect
loadError = null loadError = null
@ -169,6 +200,7 @@ fun ChatScreen(
role = if (m.role == "assistant") Role.Assistant else Role.User, role = if (m.role == "assistant") Role.Assistant else Role.User,
content = m.content, content = m.content,
wireId = m.id, wireId = m.id,
attachments = m.attachments,
), ),
) )
} }
@ -178,21 +210,23 @@ fun ChatScreen(
fun send(text: String) { fun send(text: String) {
val trimmed = text.trim() 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 val cfg = session.config ?: return
haptics.click() haptics.click()
// parentId chains the linear history: the user msg hangs off the last message.
val userParentId = messages.lastOrNull()?.wireId val userParentId = messages.lastOrNull()?.wireId
val userMsg = Message(nextId++, Role.User, trimmed) val userMsg = Message(nextId++, Role.User, trimmed, attachments = uploadedAtts)
messages.add(userMsg) messages.add(userMsg)
input = "" input = ""
pendingFiles.clear()
val assistantId = nextId++ val assistantId = nextId++
val assistantWireId = java.util.UUID.randomUUID().toString() val assistantWireId = java.util.UUID.randomUUID().toString()
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId)) messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId))
isStreaming = true isStreaming = true
val wasNewConversation = conversationId == null val wasNewConversation = conversationId == null
// Wire history = the visible conversation minus the streaming placeholder.
val history = messages val history = messages
.filter { it.id != assistantId } .filter { it.id != assistantId }
.map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) } .map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) }
@ -201,14 +235,14 @@ fun ChatScreen(
haptics.thinkingStart() haptics.thinkingStart()
var sawContent = false var sawContent = false
try { 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) { val convIdDeferred = if (conversationId == null) {
async { KaizenApi.createConversation(cfg.baseUrl, cfg.token) } async { KaizenApi.createConversation(cfg.baseUrl, cfg.token) }
} else null } 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 } val idx = messages.indexOfLast { it.id == assistantId }
if (idx < 0) return@collect if (idx < 0) return@collect
if (!sawContent && visible.isNotEmpty()) { if (!sawContent && visible.isNotEmpty()) {
@ -224,15 +258,17 @@ fun ChatScreen(
val finalContent = if (idx >= 0) messages[idx].content else "" val finalContent = if (idx >= 0) messages[idx].content else ""
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false) 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 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()) { if (convId != null && finalContent.isNotEmpty()) {
val saved = KaizenApi.saveMessages( val saved = KaizenApi.saveMessages(
cfg.baseUrl, cfg.token, convId, cfg.baseUrl, cfg.token, convId,
listOf( 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), SaveMessage(id = assistantWireId, parentId = userMsg.wireId, role = "assistant", content = finalContent, model = cfg.model),
), ),
) )
@ -425,6 +461,9 @@ fun ChatScreen(
onValueChange = { input = it }, onValueChange = { input = it },
onSend = { send(input) }, onSend = { send(input) },
enabled = !isStreaming, 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 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.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString 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.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.kaizen.app.haptics.rememberHaptics
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private sealed interface MdBlock { private sealed interface MdBlock {
data class Paragraph(val text: String) : 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 CodeFence(val lang: String, val code: String) : MdBlock
data class ListBlock(val items: List<ListItem>) : MdBlock data class ListBlock(val items: List<ListItem>) : MdBlock
data class Item(val text: String, val ordered: Boolean, val index: Int) : 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) 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) { while (i < lines.size) {
val line = lines[i] 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 // Code fence
if (line.trimStart().startsWith("```")) { if (line.trimStart().startsWith("```")) {
val lang = line.trimStart().removePrefix("```").trim() val lang = line.trimStart().removePrefix("```").trim()
@ -117,6 +161,8 @@ private fun parseBlocks(src: String): List<MdBlock> {
|| Regex("^#{1,3}\\s+").containsMatchIn(next) || Regex("^#{1,3}\\s+").containsMatchIn(next)
|| Regex("^\\s*[-*+]\\s+").containsMatchIn(next) || Regex("^\\s*[-*+]\\s+").containsMatchIn(next)
|| Regex("^\\s*\\d+[.)]\\s+").containsMatchIn(next) || Regex("^\\s*\\d+[.)]\\s+").containsMatchIn(next)
|| Regex("^\\s*([-*_])\\s*\\1\\s*\\1[\\s\\-*_]*$").matches(next)
|| Regex("^\\s*>\\s?").containsMatchIn(next)
) break ) break
paraLines.add(next) paraLines.add(next)
i++ i++
@ -126,11 +172,26 @@ private fun parseBlocks(src: String): List<MdBlock> {
return blocks 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 { return buildAnnotatedString {
var pos = 0 var pos = 0
val src = text val src = text
while (pos < src.length) { 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: `...` // Inline code: `...`
if (src[pos] == '`' && pos + 1 < src.length) { if (src[pos] == '`' && pos + 1 < src.length) {
val end = src.indexOf('`', pos + 1) 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)) .border(1.dp, codeBorder, RoundedCornerShape(10.dp))
) { ) {
Column { 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( Text(
block.lang, if (block.lang.isNotBlank()) block.lang else "code",
color = langBadgeColor, color = langBadgeColor,
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
modifier = Modifier.padding(start = 12.dp, top = 8.dp),
) )
Spacer(Modifier.weight(1f))
CopyButton(block.code)
} }
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.horizontalScroll(rememberScrollState()) .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(
text = highlightCode(block.code + if (isLast) cursor else "", block.lang, isDark), 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)) 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 -> { is MdBlock.Paragraph -> {
if (idx > 0) Spacer(Modifier.height(6.dp)) if (idx > 0) Spacer(Modifier.height(6.dp))
Text( 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.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
@ -29,6 +30,7 @@ import java.util.concurrent.TimeUnit
val messages: List<WireMessage>, val messages: List<WireMessage>,
val reasoningEffort: String? = null, val reasoningEffort: String? = null,
val preferThroughput: Boolean? = 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. */ /** 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 CreateConvoRequest(val title: String? = null)
@Serializable private data class CreatedConversation(val id: String) @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]. */ /** One persisted message from GET /api/v1/conversations/[id]. */
@Serializable @Serializable
data class StoredMessage( data class StoredMessage(
val id: String, val id: String,
val role: String, val role: String,
val content: String = "", val content: String = "",
val attachments: List<Attachment> = emptyList(),
) )
@Serializable private data class ConversationDetail(val messages: List<StoredMessage> = emptyList()) @Serializable private data class ConversationDetail(val messages: List<StoredMessage> = emptyList())
@ -74,6 +88,7 @@ data class SaveMessage(
val content: String, val content: String,
val kind: String = "chat", val kind: String = "chat",
val model: String? = null, val model: String? = null,
val attachments: List<Attachment>? = null,
) )
@Serializable private data class SaveMessagesRequest(val messages: List<SaveMessage>) @Serializable private data class SaveMessagesRequest(val messages: List<SaveMessage>)
@ -314,6 +329,7 @@ object KaizenApi {
model: String, model: String,
messages: List<WireMessage>, messages: List<WireMessage>,
speed: ChatSpeed = ChatSpeed.Normal, speed: ChatSpeed = ChatSpeed.Normal,
attachments: List<Attachment>? = null,
): Flow<String> = flow { ): Flow<String> = flow {
val payload = json.encodeToString( val payload = json.encodeToString(
ChatRequest( ChatRequest(
@ -321,6 +337,7 @@ object KaizenApi {
messages = messages, messages = messages,
reasoningEffort = speed.reasoningEffort, reasoningEffort = speed.reasoningEffort,
preferThroughput = if (speed.preferThroughput) true else null, preferThroughput = if (speed.preferThroughput) true else null,
attachments = attachments?.takeIf { it.isNotEmpty() },
), ),
).toRequestBody(jsonMedia) ).toRequestBody(jsonMedia)
val req = Request.Builder() val req = Request.Builder()
@ -354,6 +371,46 @@ object KaizenApi {
* handshake (~150-300ms on mobile) from the first real request after app * handshake (~150-300ms on mobile) from the first real request after app
* start or background resume. * 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) { suspend fun prewarm(baseUrl: String) = withContext(Dispatchers.IO) {
try { try {
val req = Request.Builder().url("$baseUrl/api/v1/meta").build() val req = Request.Builder().url("$baseUrl/api/v1/meta").build()