feat: ByteClaw 0.2.0 – Provider-Wahl ab erster Nachricht, Markdown/Code-Blöcke mit Kopieren, Nachrichten-Aktionen, Spracheingabe, Session-Suche/Umbenennen, Verbindungsstatus, Benachrichtigungen
|
|
@ -31,8 +31,8 @@ android {
|
|||
applicationId = "dev.byteclaw.client"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
versionCode = 2
|
||||
versionName = "0.2.0"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.speech.RecognizerIntent" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
|
||||
</intent>
|
||||
</queries>
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:label="@string/app_name"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
package dev.byteclaw.client
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.speech.RecognizerIntent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import dev.byteclaw.client.ui.Notifier
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
|
|
@ -22,9 +28,33 @@ import dev.byteclaw.client.ui.SessionsScreen
|
|||
import dev.byteclaw.client.ui.SettingsScreen
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private var voiceCallback: ((String) -> Unit)? = null
|
||||
private val voice = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
val text = r.data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)?.firstOrNull()
|
||||
if (!text.isNullOrBlank()) voiceCallback?.invoke(text)
|
||||
voiceCallback = null
|
||||
}
|
||||
private val notifPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
|
||||
/** Voice input via the system speech recognizer (no RECORD_AUDIO permission needed in the app). */
|
||||
private fun startVoice(onText: (String) -> Unit) {
|
||||
val i = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
|
||||
.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
||||
.putExtra(RecognizerIntent.EXTRA_PROMPT, "Sprich deine Nachricht…")
|
||||
voiceCallback = onText
|
||||
runCatching { voice.launch(i) }.onFailure {
|
||||
voiceCallback = null
|
||||
android.widget.Toast.makeText(this, "Keine Spracherkennung auf diesem Gerät", android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() { super.onStart(); Notifier.foreground = true }
|
||||
override fun onStop() { Notifier.foreground = false; super.onStop() }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
if (Build.VERSION.SDK_INT >= 33 && savedInstanceState == null) notifPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
setContent {
|
||||
ByteClawTheme(dark = isSystemInDarkTheme()) {
|
||||
Surface {
|
||||
|
|
@ -38,13 +68,15 @@ class MainActivity : ComponentActivity() {
|
|||
state, onSend = vm::send, onNewChat = vm::newChat, onDismissError = vm::dismissError,
|
||||
onSessions = { nav.navigate("sessions") }, onSettings = { nav.navigate("settings") }, onStop = vm::cancel,
|
||||
onSessionProvider = { vm.setSessionProvider(it) },
|
||||
onRegenerate = vm::regenerate, onHide = vm::hideLine,
|
||||
onVoice = { cb -> startVoice(cb) },
|
||||
)
|
||||
}
|
||||
composable("sessions") {
|
||||
SessionsScreen(
|
||||
state, onRefresh = { vm.refreshSessions() },
|
||||
onOpen = { id -> vm.openSession(id); nav.popBackStack() },
|
||||
onDelete = { id -> vm.deleteSession(id) }, onBack = { nav.popBackStack() },
|
||||
onDelete = { id -> vm.deleteSession(id) }, onRename = { id, t -> vm.renameSession(id, t) }, onBack = { nav.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable("settings") {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ class ByteClawApi(private val settings: ServerSettings) {
|
|||
call(Request.Builder().url(url("/api/sessions/$sessionId")).delete().build())
|
||||
}
|
||||
|
||||
suspend fun renameSession(sessionId: String, title: String) {
|
||||
val body = json.encodeToString(RenameRequest.serializer(), RenameRequest(title)).toRequestBody(JSON)
|
||||
call(Request.Builder().url(url("/api/sessions/$sessionId")).patch(body).build())
|
||||
}
|
||||
|
||||
suspend fun providers(): ProvidersResponse =
|
||||
json.decodeFromString(call(Request.Builder().url(url("/api/providers")).build()))
|
||||
|
||||
|
|
@ -81,11 +86,15 @@ class ByteClawApi(private val settings: ServerSettings) {
|
|||
/**
|
||||
* Opens a WebSocket, sends one message and emits streamed events until "done"/"error".
|
||||
*/
|
||||
fun stream(sessionId: String?, text: String): Flow<AgentEvent> = callbackFlow {
|
||||
fun stream(sessionId: String?, text: String, provider: ActiveProvider? = null): Flow<AgentEvent> = callbackFlow {
|
||||
val wsUrl = url("/api/ws")
|
||||
val ws = http.newWebSocket(Request.Builder().url(wsUrl).build(), object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(json.encodeToString(OutgoingMessage.serializer(), OutgoingMessage(sessionId = sessionId.orEmpty(), text = text)))
|
||||
webSocket.send(json.encodeToString(OutgoingMessage.serializer(), OutgoingMessage(
|
||||
sessionId = sessionId.orEmpty(), text = text,
|
||||
provider = provider?.provider?.takeIf { sessionId == null && it.isNotBlank() },
|
||||
model = provider?.model?.takeIf { sessionId == null && provider.provider.isNotBlank() && it.isNotBlank() },
|
||||
)))
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ data class OutgoingMessage(
|
|||
val type: String = "message",
|
||||
@SerialName("session_id") val sessionId: String = "",
|
||||
val text: String,
|
||||
/** Only for a new session: provider/model the first answer should already use. */
|
||||
val provider: String? = null,
|
||||
val model: String? = null,
|
||||
)
|
||||
|
||||
/** GET /api/info: which LLM provider preset/model the server uses (no secrets). */
|
||||
|
|
@ -86,3 +89,6 @@ object ProviderPresets {
|
|||
)
|
||||
fun label(name: String) = names[name] ?: name
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class RenameRequest(val title: String)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ data class UiState(
|
|||
val provider: ProviderUi = ProviderUi(),
|
||||
/** Provider override of the current session (null = global default). */
|
||||
val sessionProvider: ActiveProvider? = null,
|
||||
/** null = unknown, true/false = last health check result. */
|
||||
val online: Boolean? = null,
|
||||
) {
|
||||
/** What the current chat effectively uses: session override, else the global active provider. */
|
||||
val effectiveProvider: ActiveProvider? get() = sessionProvider ?: provider.active
|
||||
|
|
@ -51,6 +53,21 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
|
|||
|
||||
private fun api() = ByteClawApi(_state.value.settings)
|
||||
|
||||
init {
|
||||
// Lightweight connection monitor: health check every 15 s while the ViewModel lives.
|
||||
viewModelScope.launch {
|
||||
while (true) {
|
||||
if (_state.value.settings.isConfigured) {
|
||||
val ok = runCatching { api().health() }.getOrDefault(false)
|
||||
val was = _state.value.online
|
||||
_state.update { it.copy(online = ok) }
|
||||
if (ok && was == false && _state.value.provider.providers.isEmpty()) loadProviders()
|
||||
}
|
||||
kotlinx.coroutines.delay(if (_state.value.online == false) 5_000 else 15_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveSettings(s: ServerSettings) {
|
||||
store.save(s)
|
||||
_state.update { it.copy(settings = store.load(), error = null) }
|
||||
|
|
@ -112,23 +129,23 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
|
|||
val t = text.trim()
|
||||
if (t.isEmpty() || _state.value.busy) return
|
||||
_state.update { it.copy(lines = it.lines + ChatLine("user", t) + ChatLine("assistant", ""), busy = true, error = null) }
|
||||
// For a new chat the picked provider travels with the first message, so the server applies it before answering.
|
||||
val pending = _state.value.sessionProvider.takeIf { _state.value.sessionId == null }
|
||||
val started = System.currentTimeMillis()
|
||||
job = viewModelScope.launch {
|
||||
api().stream(_state.value.sessionId, t).collect { ev ->
|
||||
api().stream(_state.value.sessionId, t, pending).collect { ev ->
|
||||
when (ev.type) {
|
||||
"session" -> {
|
||||
val pending = _state.value.sessionProvider.takeIf { _state.value.sessionId == null }
|
||||
_state.update { it.copy(sessionId = ev.sessionId) }
|
||||
// The first answer already runs with the global provider; the pick applies from the next message on.
|
||||
pending?.let { p -> launch { runCatching { api().setSessionProvider(ev.sessionId, p.provider, p.model) } } }
|
||||
}
|
||||
"session" -> _state.update { it.copy(sessionId = ev.sessionId, online = true) }
|
||||
"delta" -> appendToLast(ev.text)
|
||||
"tool_call" -> _state.update {
|
||||
// keep a fresh assistant bubble after the tool line for the follow-up answer
|
||||
val base = if (it.lines.lastOrNull()?.let { l -> l.role == "assistant" && l.text.isEmpty() } == true) it.lines.dropLast(1) else it.lines
|
||||
it.copy(lines = base + ChatLine("tool", ev.args, ev.tool) + ChatLine("assistant", ""))
|
||||
}
|
||||
"done" -> _state.update { s ->
|
||||
s.copy(busy = false, lines = s.lines.filterNot { it.role == "assistant" && it.text.isEmpty() })
|
||||
"done" -> {
|
||||
_state.update { s -> s.copy(busy = false, lines = s.lines.filterNot { it.role == "assistant" && it.text.isEmpty() }) }
|
||||
Notifier.replyFinished(getApplication(), ev.text.ifBlank { _state.value.lines.lastOrNull { it.role == "assistant" }?.text.orEmpty() },
|
||||
System.currentTimeMillis() - started)
|
||||
}
|
||||
"cancelled" -> markCancelled()
|
||||
"error" -> _state.update { s ->
|
||||
|
|
@ -140,6 +157,23 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Sends the user question that led to the answer at [index] again (new answer is appended). */
|
||||
fun regenerate(index: Int) {
|
||||
val q = _state.value.lines.take(index + 1).lastOrNull { it.role == "user" }?.text ?: return
|
||||
send(q)
|
||||
}
|
||||
|
||||
/** Removes a line from the local view only (server history stays intact). */
|
||||
fun hideLine(index: Int) = _state.update { s -> s.copy(lines = s.lines.filterIndexed { i, _ -> i != index }) }
|
||||
|
||||
fun renameSession(id: String, title: String) = viewModelScope.launch {
|
||||
val t = title.trim()
|
||||
if (t.isEmpty()) return@launch
|
||||
runCatching { api().renameSession(id, t) }
|
||||
.onSuccess { _state.update { s -> s.copy(sessions = s.sessions.map { if (it.id == id) it.copy(title = t) else it }) } }
|
||||
.onFailure { e -> _state.update { it.copy(error = e.message) } }
|
||||
}
|
||||
|
||||
/** Stop button: cancel on the server (partial answer is saved there), fall back to closing the stream. */
|
||||
fun cancel() {
|
||||
if (!_state.value.busy) return
|
||||
|
|
|
|||
146
android/app/src/main/java/dev/byteclaw/client/ui/Markdown.kt
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package dev.byteclaw.client.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
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.unit.dp
|
||||
|
||||
/** Minimal Markdown model: enough for typical LLM answers (no tables/links/images). */
|
||||
sealed interface MdBlock {
|
||||
data class Paragraph(val text: String) : MdBlock
|
||||
data class Heading(val level: Int, val text: String) : MdBlock
|
||||
data class Bullet(val marker: String, val text: String) : MdBlock
|
||||
data class Code(val lang: String, val code: String) : MdBlock
|
||||
data class Quote(val text: String) : MdBlock
|
||||
}
|
||||
|
||||
object Markdown {
|
||||
private val heading = Regex("^(#{1,6})\\s+(.*)$")
|
||||
private val bullet = Regex("^\\s*([-*+]|\\d+[.)])\\s+(.*)$")
|
||||
|
||||
/** Splits text into blocks. An unclosed ``` fence (still streaming) is rendered as code anyway. */
|
||||
fun parse(src: String): List<MdBlock> {
|
||||
val out = mutableListOf<MdBlock>()
|
||||
val para = StringBuilder()
|
||||
fun flush() { if (para.isNotBlank()) out += MdBlock.Paragraph(para.toString().trim()); para.clear() }
|
||||
val lines = src.replace("\r\n", "\n").split('\n')
|
||||
var i = 0
|
||||
while (i < lines.size) {
|
||||
val l = lines[i]
|
||||
val t = l.trimStart()
|
||||
when {
|
||||
t.startsWith("```") -> {
|
||||
flush()
|
||||
val lang = t.removePrefix("```").trim()
|
||||
val code = StringBuilder()
|
||||
i++
|
||||
while (i < lines.size && !lines[i].trimStart().startsWith("```")) {
|
||||
if (code.isNotEmpty()) code.append('\n')
|
||||
code.append(lines[i]); i++
|
||||
}
|
||||
out += MdBlock.Code(lang, code.toString())
|
||||
}
|
||||
l.isBlank() -> flush()
|
||||
heading.matches(t) -> { flush(); val m = heading.find(t)!!; out += MdBlock.Heading(m.groupValues[1].length, m.groupValues[2].trim()) }
|
||||
bullet.matches(l) -> {
|
||||
flush(); val m = bullet.find(l)!!
|
||||
val mk = m.groupValues[1].let { if (it[0].isDigit()) it else "•" }
|
||||
out += MdBlock.Bullet(mk, m.groupValues[2])
|
||||
}
|
||||
t.startsWith(">") -> { flush(); out += MdBlock.Quote(t.removePrefix(">").trim()) }
|
||||
else -> { if (para.isNotEmpty()) para.append('\n'); para.append(l) }
|
||||
}
|
||||
i++
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
/** Inline: **bold**, *italic* / _italic_, `code`. Unmatched markers stay literal. */
|
||||
fun inline(text: String, codeBg: androidx.compose.ui.graphics.Color): AnnotatedString = buildAnnotatedString {
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
fun close(marker: String): Int = text.indexOf(marker, i + marker.length + 1)
|
||||
when {
|
||||
text.startsWith("`", i) && close("`") > 0 -> {
|
||||
val e = close("`")
|
||||
withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBg)) { append(text.substring(i + 1, e)) }
|
||||
i = e + 1
|
||||
}
|
||||
text.startsWith("**", i) && close("**") > i + 2 -> {
|
||||
val e = close("**")
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(text.substring(i + 2, e)) }
|
||||
i = e + 2
|
||||
}
|
||||
(text[i] == '*' || text[i] == '_') && i + 1 < text.length && !text[i + 1].isWhitespace() &&
|
||||
(i == 0 || !text[i - 1].isLetterOrDigit()) && close(text[i].toString()) > i + 1 -> {
|
||||
val e = close(text[i].toString())
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { append(text.substring(i + 1, e)) }
|
||||
i = e + 1
|
||||
}
|
||||
else -> { append(text[i]); i++ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MarkdownText(text: String, modifier: Modifier = Modifier) {
|
||||
val codeBg = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Markdown.parse(text).forEach { b ->
|
||||
when (b) {
|
||||
is MdBlock.Paragraph -> Text(Markdown.inline(b.text, codeBg))
|
||||
is MdBlock.Heading -> Text(
|
||||
Markdown.inline(b.text, codeBg), fontWeight = FontWeight.Bold,
|
||||
style = if (b.level <= 2) MaterialTheme.typography.titleMedium else MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
is MdBlock.Bullet -> Row {
|
||||
Text(b.marker, Modifier.width(22.dp))
|
||||
Text(Markdown.inline(b.text, codeBg))
|
||||
}
|
||||
is MdBlock.Quote -> Text(
|
||||
Markdown.inline(b.text, codeBg), fontStyle = FontStyle.Italic,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
is MdBlock.Code -> CodeBlock(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CodeBlock(b: MdBlock.Code) {
|
||||
val clip = LocalClipboardManager.current
|
||||
Column(
|
||||
Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface, RoundedCornerShape(8.dp)).padding(8.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(b.lang.ifBlank { "code" }, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = { clip.setText(AnnotatedString(b.code)) }, modifier = Modifier.size(28.dp)) {
|
||||
Icon(Icons.Filled.ContentCopy, "Code kopieren", Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
b.code, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall,
|
||||
softWrap = false, modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
)
|
||||
}
|
||||
}
|
||||
45
android/app/src/main/java/dev/byteclaw/client/ui/Notifier.kt
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package dev.byteclaw.client.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import dev.byteclaw.client.MainActivity
|
||||
import dev.byteclaw.client.R
|
||||
|
||||
/** Posts a notification when an answer finishes while the app is in the background. */
|
||||
object Notifier {
|
||||
@Volatile var foreground = false
|
||||
private const val CHANNEL = "replies"
|
||||
|
||||
fun replyFinished(ctx: Context, text: String, durationMs: Long) {
|
||||
if (foreground || durationMs < 3_000) return
|
||||
if (Build.VERSION.SDK_INT >= 33 &&
|
||||
ContextCompat.checkSelfPermission(ctx, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return
|
||||
val nm = ctx.getSystemService(NotificationManager::class.java)
|
||||
if (nm.getNotificationChannel(CHANNEL) == null) {
|
||||
nm.createNotificationChannel(NotificationChannel(CHANNEL, "Antworten", NotificationManager.IMPORTANCE_DEFAULT))
|
||||
}
|
||||
val open = PendingIntent.getActivity(
|
||||
ctx, 0, Intent(ctx, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
val preview = text.trim().take(300).ifEmpty { "Antwort ist fertig." }
|
||||
val n = NotificationCompat.Builder(ctx, CHANNEL)
|
||||
.setSmallIcon(R.drawable.ic_launcher_monochrome)
|
||||
.setContentTitle("ByteClaw hat geantwortet")
|
||||
.setContentText(preview)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(preview))
|
||||
.setContentIntent(open)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
runCatching { NotificationManagerCompat.from(ctx).notify(1, n) }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,21 @@
|
|||
package dev.byteclaw.client.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
|
|
@ -42,6 +56,9 @@ fun ChatScreen(
|
|||
onSettings: () -> Unit,
|
||||
onStop: () -> Unit = {},
|
||||
onSessionProvider: (String) -> Unit = {},
|
||||
onRegenerate: (Int) -> Unit = {},
|
||||
onHide: (Int) -> Unit = {},
|
||||
onVoice: (((String) -> Unit) -> Unit)? = null,
|
||||
initialInput: String = "",
|
||||
initialMenuOpen: Boolean = false,
|
||||
) {
|
||||
|
|
@ -55,7 +72,10 @@ fun ChatScreen(
|
|||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text("ByteClaw")
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("ByteClaw")
|
||||
ConnectionDot(state.online)
|
||||
}
|
||||
SessionProviderChip(state, onSessionProvider, initialMenuOpen)
|
||||
}
|
||||
},
|
||||
|
|
@ -80,7 +100,9 @@ fun ChatScreen(
|
|||
) { Text(it, Modifier.padding(12.dp)) }
|
||||
}
|
||||
LazyColumn(Modifier.weight(1f).fillMaxWidth(), state = listState, contentPadding = PaddingValues(12.dp)) {
|
||||
itemsIndexed(state.lines) { _, line -> Bubble(line) }
|
||||
itemsIndexed(state.lines) { i, line ->
|
||||
Bubble(line, actionsEnabled = !state.busy, onRegenerate = { onRegenerate(i) }, onHide = { onHide(i) })
|
||||
}
|
||||
if (state.busy) item { LinearProgressIndicator(Modifier.fillMaxWidth().padding(top = 8.dp)) }
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
|
|
@ -97,6 +119,10 @@ fun ChatScreen(
|
|||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
),
|
||||
) { Icon(Icons.Filled.Stop, "Antwort stoppen") }
|
||||
} else if (input.isBlank() && onVoice != null) {
|
||||
IconButton(onClick = { onVoice { spoken -> input = spoken } }, enabled = state.settings.isConfigured) {
|
||||
Icon(Icons.Filled.Mic, "Spracheingabe")
|
||||
}
|
||||
} else {
|
||||
IconButton(
|
||||
onClick = { onSend(input); input = "" },
|
||||
|
|
@ -109,7 +135,23 @@ fun ChatScreen(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun Bubble(line: ChatLine) {
|
||||
private fun ConnectionDot(online: Boolean?) {
|
||||
val color = when (online) {
|
||||
true -> Color(0xFF2E7D32)
|
||||
false -> MaterialTheme.colorScheme.error
|
||||
null -> return
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Box(Modifier.size(8.dp).background(color, CircleShape))
|
||||
if (online == false) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("offline – verbinde neu…", style = MaterialTheme.typography.labelSmall, color = color)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun Bubble(line: ChatLine, actionsEnabled: Boolean = true, onRegenerate: () -> Unit = {}, onHide: () -> Unit = {}) {
|
||||
val isUser = line.role == "user"
|
||||
if (line.role == "tool") {
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
|
|
@ -120,15 +162,36 @@ private fun Bubble(line: ChatLine) {
|
|||
}
|
||||
return
|
||||
}
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
val clip = LocalClipboardManager.current
|
||||
val ctx = LocalContext.current
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalAlignment = if (isUser) Alignment.End else Alignment.Start) {
|
||||
Text(
|
||||
line.text.ifEmpty { "…" },
|
||||
Modifier.widthIn(max = 320.dp)
|
||||
Box {
|
||||
val mod = Modifier.widthIn(max = 320.dp)
|
||||
.background(
|
||||
if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||
RoundedCornerShape(14.dp),
|
||||
).padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
)
|
||||
.combinedClickable(onClick = {}, onLongClick = { if (line.text.isNotEmpty()) menu = true })
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
if (isUser || line.text.isEmpty()) Text(line.text.ifEmpty { "…" }, mod) else MarkdownText(line.text, mod)
|
||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||
DropdownMenuItem(text = { Text("Kopieren") }, leadingIcon = { Icon(Icons.Filled.ContentCopy, null) },
|
||||
onClick = { clip.setText(AnnotatedString(line.text)); menu = false })
|
||||
DropdownMenuItem(text = { Text("Teilen") }, leadingIcon = { Icon(Icons.Filled.Share, null) }, onClick = {
|
||||
menu = false
|
||||
val send = android.content.Intent(android.content.Intent.ACTION_SEND).setType("text/plain")
|
||||
.putExtra(android.content.Intent.EXTRA_TEXT, line.text)
|
||||
runCatching { ctx.startActivity(android.content.Intent.createChooser(send, null)) }
|
||||
})
|
||||
if (!isUser) {
|
||||
DropdownMenuItem(text = { Text("Neu generieren") }, leadingIcon = { Icon(Icons.Filled.Refresh, null) },
|
||||
enabled = actionsEnabled, onClick = { menu = false; onRegenerate() })
|
||||
}
|
||||
DropdownMenuItem(text = { Text("Ausblenden") }, leadingIcon = { Icon(Icons.Filled.VisibilityOff, null) },
|
||||
enabled = actionsEnabled, onClick = { menu = false; onHide() })
|
||||
}
|
||||
}
|
||||
if (line.cancelled) {
|
||||
Text("⏹ abgebrochen", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.padding(start = 8.dp, top = 2.dp))
|
||||
|
|
@ -144,26 +207,55 @@ fun SessionsScreen(
|
|||
onOpen: (String) -> Unit,
|
||||
onDelete: (String) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onRename: (String, String) -> Unit = { _, _ -> },
|
||||
initialQuery: String = "",
|
||||
) {
|
||||
LaunchedEffect(Unit) { onRefresh() }
|
||||
var query by remember { mutableStateOf(initialQuery) }
|
||||
var renaming by remember { mutableStateOf<dev.byteclaw.client.data.Session?>(null) }
|
||||
val shown = filterSessions(state.sessions, query)
|
||||
renaming?.let { r ->
|
||||
var title by remember(r.id) { mutableStateOf(r.title) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { renaming = null },
|
||||
title = { Text("Session umbenennen") },
|
||||
text = { OutlinedTextField(title, { title = it }, singleLine = true, modifier = Modifier.fillMaxWidth()) },
|
||||
confirmButton = { TextButton(enabled = title.isNotBlank(), onClick = { onRename(r.id, title); renaming = null }) { Text("Speichern") } },
|
||||
dismissButton = { TextButton(onClick = { renaming = null }) { Text("Abbrechen") } },
|
||||
)
|
||||
}
|
||||
Scaffold(topBar = {
|
||||
TopAppBar(title = { Text("Sessions") },
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück") } })
|
||||
}) { pad ->
|
||||
if (state.sessions.isEmpty()) {
|
||||
Box(Modifier.padding(pad).fillMaxSize(), contentAlignment = Alignment.Center) { Text(state.error ?: "Noch keine Sessions") }
|
||||
Column(Modifier.padding(pad).fillMaxSize()) {
|
||||
OutlinedTextField(
|
||||
query, { query = it }, Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
placeholder = { Text("Sessions durchsuchen") }, singleLine = true,
|
||||
leadingIcon = { Icon(Icons.Filled.Search, null) },
|
||||
)
|
||||
if (shown.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(state.error ?: if (query.isBlank()) "Noch keine Sessions" else "Keine Treffer")
|
||||
}
|
||||
}
|
||||
LazyColumn(Modifier.padding(pad).fillMaxSize()) {
|
||||
items(state.sessions, key = { it.id }) { s ->
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
items(shown, key = { it.id }) { s ->
|
||||
ListItem(
|
||||
headlineContent = { Text(s.title.ifBlank { "(ohne Titel)" }, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
supportingContent = { Text(s.updatedAt.take(16).replace('T', ' ') + " UTC") },
|
||||
trailingContent = { IconButton(onClick = { onDelete(s.id) }) { Icon(Icons.Filled.Delete, "Löschen") } },
|
||||
trailingContent = {
|
||||
Row {
|
||||
IconButton(onClick = { renaming = s }) { Icon(Icons.Filled.Edit, "Umbenennen") }
|
||||
IconButton(onClick = { onDelete(s.id) }) { Icon(Icons.Filled.Delete, "Löschen") }
|
||||
}
|
||||
},
|
||||
modifier = Modifier.clickable { onOpen(s.id) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -311,3 +403,10 @@ private fun SessionProviderChip(state: UiState, onPick: (String) -> Unit, initia
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Case-insensitive title search; all words must match. */
|
||||
fun filterSessions(list: List<dev.byteclaw.client.data.Session>, query: String): List<dev.byteclaw.client.data.Session> {
|
||||
val words = query.trim().lowercase().split(Regex("\\s+")).filter { it.isNotEmpty() }
|
||||
if (words.isEmpty()) return list
|
||||
return list.filter { s -> val t = s.title.lowercase(); words.all { it in t } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
package dev.byteclaw.client
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import dev.byteclaw.client.data.OutgoingMessage
|
||||
import dev.byteclaw.client.data.Session
|
||||
import dev.byteclaw.client.ui.Markdown
|
||||
import dev.byteclaw.client.ui.MdBlock
|
||||
import dev.byteclaw.client.ui.filterSessions
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MarkdownTest {
|
||||
@Test fun parsesBlocks() {
|
||||
val b = Markdown.parse("# Titel\nText **fett**\n\n- a\n2. b\n> zitat\n```kotlin\nval x = 1\n\nprintln(x)\n```\nEnde")
|
||||
assertEquals(MdBlock.Heading(1, "Titel"), b[0])
|
||||
assertEquals(MdBlock.Paragraph("Text **fett**"), b[1])
|
||||
assertEquals(MdBlock.Bullet("•", "a"), b[2])
|
||||
assertEquals(MdBlock.Bullet("2.", "b"), b[3])
|
||||
assertEquals(MdBlock.Quote("zitat"), b[4])
|
||||
assertEquals(MdBlock.Code("kotlin", "val x = 1\n\nprintln(x)"), b[5])
|
||||
assertEquals(MdBlock.Paragraph("Ende"), b[6])
|
||||
}
|
||||
|
||||
@Test fun unclosedFenceWhileStreaming() {
|
||||
val b = Markdown.parse("Hier:\n```py\nprint(1)")
|
||||
assertEquals(MdBlock.Code("py", "print(1)"), b.last())
|
||||
}
|
||||
|
||||
@Test fun inlineStylesStripMarkers() {
|
||||
val a = Markdown.inline("a **b** *c* `d` x*y 2*3 **offen", Color.Gray)
|
||||
assertEquals("a b c d x*y 2*3 **offen", a.text)
|
||||
assertEquals(3, a.spanStyles.size)
|
||||
}
|
||||
|
||||
@Test fun sessionSearch() {
|
||||
val l = listOf(Session("1", "Go Datei lesen"), Session("2", "Einkauf"), Session("3", "go generics"))
|
||||
assertEquals(listOf("1", "3"), filterSessions(l, " GO ").map { it.id })
|
||||
assertEquals(listOf("1"), filterSessions(l, "go lesen").map { it.id })
|
||||
assertEquals(3, filterSessions(l, "").size)
|
||||
}
|
||||
|
||||
@Test fun firstMessageCarriesProvider() {
|
||||
val j = Json { explicitNulls = false }
|
||||
val s = j.encodeToString(OutgoingMessage.serializer(), OutgoingMessage(text = "hi", provider = "dahl", model = "m"))
|
||||
assertTrue(s.contains("\"provider\":\"dahl\"") && s.contains("\"model\":\"m\""))
|
||||
val s2 = j.encodeToString(OutgoingMessage.serializer(), OutgoingMessage(sessionId = "x", text = "hi"))
|
||||
assertFalse(s2.contains("provider"))
|
||||
}
|
||||
}
|
||||
|
|
@ -80,6 +80,33 @@ class PreviewScreenshots(private val dark: Boolean) {
|
|||
)
|
||||
}
|
||||
|
||||
@Test fun chatMarkdown() = snap("chat_markdown") {
|
||||
ChatScreen(
|
||||
state = UiState(
|
||||
settings = settings, sessionId = "demo", online = true,
|
||||
lines = listOf(
|
||||
ChatLine("user", "[DEMO] Wie lese ich in Go eine Datei zeilenweise?"),
|
||||
ChatLine("assistant", "## Zeilenweise lesen\nAm einfachsten mit **bufio.Scanner**:\n\n```go\nf, _ := os.Open(\"notes.txt\")\ndefer f.Close()\ns := bufio.NewScanner(f)\nfor s.Scan() {\n fmt.Println(s.Text())\n}\n```\n- prüfe danach `s.Err()`\n- für sehr lange Zeilen *Buffer* vergrößern"),
|
||||
),
|
||||
),
|
||||
onSend = {}, onNewChat = {}, onDismissError = {}, onSessions = {}, onSettings = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun sessionsSearch() = snap("sessions_search") {
|
||||
SessionsScreen(
|
||||
state = UiState(
|
||||
settings = settings,
|
||||
sessions = listOf(
|
||||
Session("1", "[DEMO] Go-Datei zeilenweise lesen", "2026-10-25T03:32:00Z"),
|
||||
Session("2", "[DEMO] Einkaufsliste für das Wochenende", "2026-10-24T18:05:00Z"),
|
||||
Session("3", "[DEMO] Go Generics Beispiel", "2026-10-23T09:41:00Z"),
|
||||
),
|
||||
),
|
||||
onRefresh = {}, onOpen = {}, onDelete = {}, onBack = {}, initialQuery = "go",
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun sessions() = snap("sessions") {
|
||||
SessionsScreen(
|
||||
state = UiState(
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 45 KiB |