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.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)) {
|
||||||
|
Column {
|
||||||
|
if (hasAttachments) {
|
||||||
|
AttachmentGrid(message.attachments)
|
||||||
|
if (message.content.isNotBlank() || message.thinking) Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
if (message.thinking) {
|
if (message.thinking) {
|
||||||
TypingDots()
|
TypingDots()
|
||||||
} else {
|
} else {
|
||||||
MarkdownText(
|
MarkdownText(text = message.content, streaming = message.streaming)
|
||||||
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,18 +455,38 @@ 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),
|
) {
|
||||||
|
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) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
Modifier.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
GhostIconButton(Icons.Rounded.Add, "Anhang")
|
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(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
|
|
@ -441,7 +508,10 @@ fun ChatInput(
|
||||||
}
|
}
|
||||||
GhostIconButton(Icons.Rounded.Call, "Anruf")
|
GhostIconButton(Icons.Rounded.Call, "Anruf")
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
SendButton(enabled = enabled && value.isNotBlank(), onClick = onSend)
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue