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"
|
applicationId = "dev.byteclaw.client"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 2
|
||||||
versionName = "0.1.0"
|
versionName = "0.2.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<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
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
package dev.byteclaw.client
|
package dev.byteclaw.client
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
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.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
|
@ -22,9 +28,33 @@ import dev.byteclaw.client.ui.SessionsScreen
|
||||||
import dev.byteclaw.client.ui.SettingsScreen
|
import dev.byteclaw.client.ui.SettingsScreen
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
if (Build.VERSION.SDK_INT >= 33 && savedInstanceState == null) notifPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
setContent {
|
setContent {
|
||||||
ByteClawTheme(dark = isSystemInDarkTheme()) {
|
ByteClawTheme(dark = isSystemInDarkTheme()) {
|
||||||
Surface {
|
Surface {
|
||||||
|
|
@ -38,13 +68,15 @@ class MainActivity : ComponentActivity() {
|
||||||
state, onSend = vm::send, onNewChat = vm::newChat, onDismissError = vm::dismissError,
|
state, onSend = vm::send, onNewChat = vm::newChat, onDismissError = vm::dismissError,
|
||||||
onSessions = { nav.navigate("sessions") }, onSettings = { nav.navigate("settings") }, onStop = vm::cancel,
|
onSessions = { nav.navigate("sessions") }, onSettings = { nav.navigate("settings") }, onStop = vm::cancel,
|
||||||
onSessionProvider = { vm.setSessionProvider(it) },
|
onSessionProvider = { vm.setSessionProvider(it) },
|
||||||
|
onRegenerate = vm::regenerate, onHide = vm::hideLine,
|
||||||
|
onVoice = { cb -> startVoice(cb) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable("sessions") {
|
composable("sessions") {
|
||||||
SessionsScreen(
|
SessionsScreen(
|
||||||
state, onRefresh = { vm.refreshSessions() },
|
state, onRefresh = { vm.refreshSessions() },
|
||||||
onOpen = { id -> vm.openSession(id); nav.popBackStack() },
|
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") {
|
composable("settings") {
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,11 @@ class ByteClawApi(private val settings: ServerSettings) {
|
||||||
call(Request.Builder().url(url("/api/sessions/$sessionId")).delete().build())
|
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 =
|
suspend fun providers(): ProvidersResponse =
|
||||||
json.decodeFromString(call(Request.Builder().url(url("/api/providers")).build()))
|
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".
|
* 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 wsUrl = url("/api/ws")
|
||||||
val ws = http.newWebSocket(Request.Builder().url(wsUrl).build(), object : WebSocketListener() {
|
val ws = http.newWebSocket(Request.Builder().url(wsUrl).build(), object : WebSocketListener() {
|
||||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
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) {
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,9 @@ data class OutgoingMessage(
|
||||||
val type: String = "message",
|
val type: String = "message",
|
||||||
@SerialName("session_id") val sessionId: String = "",
|
@SerialName("session_id") val sessionId: String = "",
|
||||||
val text: 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). */
|
/** 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
|
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(),
|
val provider: ProviderUi = ProviderUi(),
|
||||||
/** Provider override of the current session (null = global default). */
|
/** Provider override of the current session (null = global default). */
|
||||||
val sessionProvider: ActiveProvider? = null,
|
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. */
|
/** What the current chat effectively uses: session override, else the global active provider. */
|
||||||
val effectiveProvider: ActiveProvider? get() = sessionProvider ?: provider.active
|
val effectiveProvider: ActiveProvider? get() = sessionProvider ?: provider.active
|
||||||
|
|
@ -51,6 +53,21 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
private fun api() = ByteClawApi(_state.value.settings)
|
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) {
|
fun saveSettings(s: ServerSettings) {
|
||||||
store.save(s)
|
store.save(s)
|
||||||
_state.update { it.copy(settings = store.load(), error = null) }
|
_state.update { it.copy(settings = store.load(), error = null) }
|
||||||
|
|
@ -112,23 +129,23 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
val t = text.trim()
|
val t = text.trim()
|
||||||
if (t.isEmpty() || _state.value.busy) return
|
if (t.isEmpty() || _state.value.busy) return
|
||||||
_state.update { it.copy(lines = it.lines + ChatLine("user", t) + ChatLine("assistant", ""), busy = true, error = null) }
|
_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 {
|
job = viewModelScope.launch {
|
||||||
api().stream(_state.value.sessionId, t).collect { ev ->
|
api().stream(_state.value.sessionId, t, pending).collect { ev ->
|
||||||
when (ev.type) {
|
when (ev.type) {
|
||||||
"session" -> {
|
"session" -> _state.update { it.copy(sessionId = ev.sessionId, online = true) }
|
||||||
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) } } }
|
|
||||||
}
|
|
||||||
"delta" -> appendToLast(ev.text)
|
"delta" -> appendToLast(ev.text)
|
||||||
"tool_call" -> _state.update {
|
"tool_call" -> _state.update {
|
||||||
// keep a fresh assistant bubble after the tool line for the follow-up answer
|
// 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
|
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", ""))
|
it.copy(lines = base + ChatLine("tool", ev.args, ev.tool) + ChatLine("assistant", ""))
|
||||||
}
|
}
|
||||||
"done" -> _state.update { s ->
|
"done" -> {
|
||||||
s.copy(busy = false, lines = s.lines.filterNot { it.role == "assistant" && it.text.isEmpty() })
|
_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()
|
"cancelled" -> markCancelled()
|
||||||
"error" -> _state.update { s ->
|
"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. */
|
/** Stop button: cancel on the server (partial answer is saved there), fall back to closing the stream. */
|
||||||
fun cancel() {
|
fun cancel() {
|
||||||
if (!_state.value.busy) return
|
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
|
package dev.byteclaw.client.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.clickable
|
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.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
|
@ -42,6 +56,9 @@ fun ChatScreen(
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
onStop: () -> Unit = {},
|
onStop: () -> Unit = {},
|
||||||
onSessionProvider: (String) -> Unit = {},
|
onSessionProvider: (String) -> Unit = {},
|
||||||
|
onRegenerate: (Int) -> Unit = {},
|
||||||
|
onHide: (Int) -> Unit = {},
|
||||||
|
onVoice: (((String) -> Unit) -> Unit)? = null,
|
||||||
initialInput: String = "",
|
initialInput: String = "",
|
||||||
initialMenuOpen: Boolean = false,
|
initialMenuOpen: Boolean = false,
|
||||||
) {
|
) {
|
||||||
|
|
@ -55,7 +72,10 @@ fun ChatScreen(
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Column {
|
Column {
|
||||||
Text("ByteClaw")
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("ByteClaw")
|
||||||
|
ConnectionDot(state.online)
|
||||||
|
}
|
||||||
SessionProviderChip(state, onSessionProvider, initialMenuOpen)
|
SessionProviderChip(state, onSessionProvider, initialMenuOpen)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -80,7 +100,9 @@ fun ChatScreen(
|
||||||
) { Text(it, Modifier.padding(12.dp)) }
|
) { Text(it, Modifier.padding(12.dp)) }
|
||||||
}
|
}
|
||||||
LazyColumn(Modifier.weight(1f).fillMaxWidth(), state = listState, contentPadding = PaddingValues(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)) }
|
if (state.busy) item { LinearProgressIndicator(Modifier.fillMaxWidth().padding(top = 8.dp)) }
|
||||||
}
|
}
|
||||||
Row(Modifier.fillMaxWidth().padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.fillMaxWidth().padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
|
@ -97,6 +119,10 @@ fun ChatScreen(
|
||||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
),
|
),
|
||||||
) { Icon(Icons.Filled.Stop, "Antwort stoppen") }
|
) { 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 {
|
} else {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { onSend(input); input = "" },
|
onClick = { onSend(input); input = "" },
|
||||||
|
|
@ -109,7 +135,23 @@ fun ChatScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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"
|
val isUser = line.role == "user"
|
||||||
if (line.role == "tool") {
|
if (line.role == "tool") {
|
||||||
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
|
@ -120,15 +162,36 @@ private fun Bubble(line: ChatLine) {
|
||||||
}
|
}
|
||||||
return
|
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) {
|
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalAlignment = if (isUser) Alignment.End else Alignment.Start) {
|
||||||
Text(
|
Box {
|
||||||
line.text.ifEmpty { "…" },
|
val mod = Modifier.widthIn(max = 320.dp)
|
||||||
Modifier.widthIn(max = 320.dp)
|
|
||||||
.background(
|
.background(
|
||||||
if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||||
RoundedCornerShape(14.dp),
|
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) {
|
if (line.cancelled) {
|
||||||
Text("⏹ abgebrochen", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline,
|
Text("⏹ abgebrochen", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline,
|
||||||
modifier = Modifier.padding(start = 8.dp, top = 2.dp))
|
modifier = Modifier.padding(start = 8.dp, top = 2.dp))
|
||||||
|
|
@ -144,26 +207,55 @@ fun SessionsScreen(
|
||||||
onOpen: (String) -> Unit,
|
onOpen: (String) -> Unit,
|
||||||
onDelete: (String) -> Unit,
|
onDelete: (String) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onRename: (String, String) -> Unit = { _, _ -> },
|
||||||
|
initialQuery: String = "",
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(Unit) { onRefresh() }
|
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 = {
|
Scaffold(topBar = {
|
||||||
TopAppBar(title = { Text("Sessions") },
|
TopAppBar(title = { Text("Sessions") },
|
||||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück") } })
|
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück") } })
|
||||||
}) { pad ->
|
}) { pad ->
|
||||||
if (state.sessions.isEmpty()) {
|
Column(Modifier.padding(pad).fillMaxSize()) {
|
||||||
Box(Modifier.padding(pad).fillMaxSize(), contentAlignment = Alignment.Center) { Text(state.error ?: "Noch keine Sessions") }
|
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()) {
|
LazyColumn(Modifier.fillMaxSize()) {
|
||||||
items(state.sessions, key = { it.id }) { s ->
|
items(shown, key = { it.id }) { s ->
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(s.title.ifBlank { "(ohne Titel)" }, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
headlineContent = { Text(s.title.ifBlank { "(ohne Titel)" }, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||||
supportingContent = { Text(s.updatedAt.take(16).replace('T', ' ') + " UTC") },
|
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) },
|
modifier = Modifier.clickable { onOpen(s.id) },
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
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") {
|
@Test fun sessions() = snap("sessions") {
|
||||||
SessionsScreen(
|
SessionsScreen(
|
||||||
state = UiState(
|
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 |
|
|
@ -178,3 +178,45 @@ func TestManualSummarize(t *testing.T) {
|
||||||
t.Fatal(r.StatusCode)
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,27 @@ type chatReq struct {
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
SessionID string `json:"session_id"`
|
SessionID string `json:"session_id"`
|
||||||
Text string `json:"text"`
|
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.
|
// 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")
|
writeErr(w, http.StatusBadRequest, "text required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id, err := s.ensureSession(r.Context(), req.SessionID, req.Text)
|
id, err := s.startSession(r.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if req.SessionID == "" && req.Provider != "" {
|
||||||
|
providerErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
s.storeErr(w, err)
|
s.storeErr(w, err)
|
||||||
return
|
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"})
|
send(agent.Event{Type: "error", Text: "an answer is still streaming; cancel it first"})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
id, err := s.ensureSession(ctx, req.SessionID, req.Text)
|
id, err := s.startSession(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
<-busy
|
<-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
|
continue
|
||||||
}
|
}
|
||||||
send(agent.Event{Type: "session", Session: id})
|
send(agent.Event{Type: "session", Session: id})
|
||||||
|
|
|
||||||
BIN
preview/chat_markdown_dark.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
preview/chat_markdown_light.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
preview/icon.png
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 18 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 |
BIN
preview/sessions_search_dark.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
preview/sessions_search_light.png
Normal file
|
After Width: | Height: | Size: 22 KiB |