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:
parent
4616ef2ba1
commit
d8dcb25e3d
5 changed files with 566 additions and 69 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue