diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index edad330..78c3d76 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index eb63783..03d216d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,15 @@ + + + + + + + + + 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") { diff --git a/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt b/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt index bd592a8..4ea46a0 100644 --- a/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt +++ b/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt @@ -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 = callbackFlow { + fun stream(sessionId: String?, text: String, provider: ActiveProvider? = null): Flow = 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) { diff --git a/android/app/src/main/java/dev/byteclaw/client/data/Models.kt b/android/app/src/main/java/dev/byteclaw/client/data/Models.kt index b01dd62..c9aebf0 100644 --- a/android/app/src/main/java/dev/byteclaw/client/data/Models.kt +++ b/android/app/src/main/java/dev/byteclaw/client/data/Models.kt @@ -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) diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt b/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt index cdd9058..398323a 100644 --- a/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt +++ b/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt @@ -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 diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/Markdown.kt b/android/app/src/main/java/dev/byteclaw/client/ui/Markdown.kt new file mode 100644 index 0000000..81d4128 --- /dev/null +++ b/android/app/src/main/java/dev/byteclaw/client/ui/Markdown.kt @@ -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 { + val out = mutableListOf() + 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()), + ) + } +} diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/Notifier.kt b/android/app/src/main/java/dev/byteclaw/client/ui/Notifier.kt new file mode 100644 index 0000000..61f25ee --- /dev/null +++ b/android/app/src/main/java/dev/byteclaw/client/ui/Notifier.kt @@ -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) } + } +} diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt b/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt index c290103..5d52401 100644 --- a/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt +++ b/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt @@ -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(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, query: String): List { + 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 } } +} diff --git a/android/app/src/test/java/dev/byteclaw/client/MarkdownTest.kt b/android/app/src/test/java/dev/byteclaw/client/MarkdownTest.kt new file mode 100644 index 0000000..b72bfb3 --- /dev/null +++ b/android/app/src/test/java/dev/byteclaw/client/MarkdownTest.kt @@ -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")) + } +} diff --git a/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt b/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt index 79b8f90..37d3920 100644 --- a/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt +++ b/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt @@ -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( diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chatMarkdown[0]_chat_markdown_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chatMarkdown[0]_chat_markdown_light.png new file mode 100644 index 0000000..5bf92c1 Binary files /dev/null and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chatMarkdown[0]_chat_markdown_light.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chatMarkdown[1]_chat_markdown_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chatMarkdown[1]_chat_markdown_dark.png new file mode 100644 index 0000000..6bcd98b Binary files /dev/null and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chatMarkdown[1]_chat_markdown_dark.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessionsSearch[0]_sessions_search_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessionsSearch[0]_sessions_search_light.png new file mode 100644 index 0000000..031ff4c Binary files /dev/null and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessionsSearch[0]_sessions_search_light.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessionsSearch[1]_sessions_search_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessionsSearch[1]_sessions_search_dark.png new file mode 100644 index 0000000..6e353c4 Binary files /dev/null and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessionsSearch[1]_sessions_search_dark.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessions[0]_sessions_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessions[0]_sessions_light.png index a374911..f5739e6 100644 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessions[0]_sessions_light.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessions[0]_sessions_light.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessions[1]_sessions_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessions[1]_sessions_dark.png index f5a79b6..58201fe 100644 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessions[1]_sessions_dark.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessions[1]_sessions_dark.png differ diff --git a/internal/server/extra_test.go b/internal/server/extra_test.go index 9b0ff14..37ad78a 100644 --- a/internal/server/extra_test.go +++ b/internal/server/extra_test.go @@ -178,3 +178,45 @@ func TestManualSummarize(t *testing.T) { t.Fatal(r.StatusCode) } } + +func TestWebSocketFirstMessageUsesSessionProvider(t *testing.T) { + ts, st := setupFull(t, echo{}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + c, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(ts.URL, "http")+"/api/ws", &websocket.DialOptions{HTTPHeader: map[string][]string{"Authorization": {"Bearer " + tok}}}) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + wsjson.Write(ctx, c, chatReq{Type: "message", Text: "hi", Provider: "dahl", Model: "zai-org/GLM-5.3-Flash"}) + var sid string + for { + var e agent.Event + if err := wsjson.Read(ctx, c, &e); err != nil { + t.Fatal(err) + } + if e.Type == "session" { + sid = e.Session + got, _ := st.GetSession(ctx, sid) + if got.Provider != "dahl" || got.Model != "zai-org/GLM-5.3-Flash" { + t.Fatalf("provider not set before first run: %+v", got) + } + } + if e.Type == "done" { + break + } + if e.Type == "error" { + t.Fatalf("%+v", e) + } + } + // unconfigured provider is rejected without creating a session + wsjson.Write(ctx, c, chatReq{Type: "message", Text: "x", Provider: "openrouter"}) + var e agent.Event + if err := wsjson.Read(ctx, c, &e); err != nil || e.Type != "error" || !strings.HasPrefix(e.Text, "provider:") { + t.Fatalf("%+v %v", e, err) + } + list, _ := st.ListSessions(ctx, 10) + if len(list) != 1 { + t.Fatalf("expected 1 session, got %d", len(list)) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index b8743b5..435b167 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -230,6 +230,27 @@ type chatReq struct { Type string `json:"type,omitempty"` SessionID string `json:"session_id"` Text string `json:"text"` + // Optional provider/model for a NEW session, so the very first answer + // already uses the per-session choice. Ignored for existing sessions. + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` +} + +// startSession is ensureSession plus applying an optional per-session provider +// to a freshly created session before the first agent run. +func (s *Server) startSession(ctx context.Context, req chatReq) (string, error) { + var a providers.Active + if req.SessionID == "" && req.Provider != "" && s.Providers != nil { + var err error + if a, err = s.Providers.Validate(req.Provider, req.Model); err != nil { + return "", err + } + } + id, err := s.ensureSession(ctx, req.SessionID, req.Text) + if err != nil || a.Provider == "" { + return id, err + } + return id, s.Store.SetSessionProvider(ctx, id, a.Provider, a.Model) } // ensureSession returns an existing session id or creates one titled after the message. @@ -255,8 +276,12 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusBadRequest, "text required") return } - id, err := s.ensureSession(r.Context(), req.SessionID, req.Text) + id, err := s.startSession(r.Context(), req) if err != nil { + if req.SessionID == "" && req.Provider != "" { + providerErr(w, err) + return + } s.storeErr(w, err) return } @@ -327,10 +352,14 @@ func (s *Server) ws(w http.ResponseWriter, r *http.Request) { send(agent.Event{Type: "error", Text: "an answer is still streaming; cancel it first"}) continue } - id, err := s.ensureSession(ctx, req.SessionID, req.Text) + id, err := s.startSession(ctx, req) if err != nil { <-busy - send(agent.Event{Type: "error", Text: "unknown session"}) + msg := "unknown session" + if req.SessionID == "" && req.Provider != "" { + msg = "provider: " + err.Error() + } + send(agent.Event{Type: "error", Text: msg}) continue } send(agent.Event{Type: "session", Session: id}) diff --git a/preview/chat_markdown_dark.png b/preview/chat_markdown_dark.png new file mode 100644 index 0000000..6bcd98b Binary files /dev/null and b/preview/chat_markdown_dark.png differ diff --git a/preview/chat_markdown_light.png b/preview/chat_markdown_light.png new file mode 100644 index 0000000..5bf92c1 Binary files /dev/null and b/preview/chat_markdown_light.png differ diff --git a/preview/icon.png b/preview/icon.png index c3afcb7..a5d4833 100644 Binary files a/preview/icon.png and b/preview/icon.png differ diff --git a/preview/sessions_dark.png b/preview/sessions_dark.png index f5a79b6..58201fe 100644 Binary files a/preview/sessions_dark.png and b/preview/sessions_dark.png differ diff --git a/preview/sessions_light.png b/preview/sessions_light.png index a374911..f5739e6 100644 Binary files a/preview/sessions_light.png and b/preview/sessions_light.png differ diff --git a/preview/sessions_search_dark.png b/preview/sessions_search_dark.png new file mode 100644 index 0000000..6e353c4 Binary files /dev/null and b/preview/sessions_search_dark.png differ diff --git a/preview/sessions_search_light.png b/preview/sessions_search_light.png new file mode 100644 index 0000000..031ff4c Binary files /dev/null and b/preview/sessions_search_light.png differ