diff --git a/.gitignore b/.gitignore index 5131913..522fea4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ -data/ -bin/ -dist/ +/data/ +/bin/ +/dist/ *.db config.yaml .env 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 new file mode 100644 index 0000000..39865f6 --- /dev/null +++ b/android/app/src/main/java/dev/byteclaw/client/data/Models.kt @@ -0,0 +1,63 @@ +package dev.byteclaw.client.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Session( + val id: String, + val title: String = "", + @SerialName("updated_at") val updatedAt: String = "", +) + +@Serializable +data class ToolCall(val id: String = "", val name: String = "", val arguments: String = "") + +@Serializable +data class Message( + val id: Long = 0, + val role: String, + val content: String = "", + @SerialName("tool_calls") val toolCalls: List = emptyList(), + val name: String = "", +) + +/** Event streamed by the server over the WebSocket. */ +@Serializable +data class AgentEvent( + val type: String, + val text: String = "", + val tool: String = "", + val args: String = "", + @SerialName("call_id") val callId: String = "", + @SerialName("session_id") val sessionId: String = "", +) + +@Serializable +data class OutgoingMessage( + val type: String = "message", + @SerialName("session_id") val sessionId: String = "", + val text: String, +) + +/** GET /api/info: which LLM provider preset/model the server uses (no secrets). */ +@Serializable +data class ServerInfo( + val version: String = "", + val provider: String = "", + val model: String = "", + @SerialName("base_url") val baseUrl: String = "", +) + +/** Provider presets known to the server (configured server-side via llm.provider). */ +object ProviderPresets { + val names = mapOf( + "openai" to "OpenAI", + "openrouter" to "OpenRouter", + "ollama" to "Ollama", + "dahl" to "Dahl (dahl.global)", + "gonkarouter" to "GonkaRouter", + "custom" to "Eigener Endpunkt", + ) + fun label(name: String) = names[name] ?: name +} diff --git a/android/app/src/main/java/dev/byteclaw/client/data/OpenClawApi.kt b/android/app/src/main/java/dev/byteclaw/client/data/OpenClawApi.kt new file mode 100644 index 0000000..a2ba2d2 --- /dev/null +++ b/android/app/src/main/java/dev/byteclaw/client/data/OpenClawApi.kt @@ -0,0 +1,96 @@ +package dev.byteclaw.client.data + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import java.io.IOException +import java.util.concurrent.TimeUnit + +class ApiException(msg: String) : IOException(msg) + +/** REST + WebSocket client for the ByteClaw Go backend. TLS verification uses system defaults. */ +class ByteClawApi(private val settings: ServerSettings) { + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + + private val http = shared.newBuilder() + .addInterceptor { chain -> + chain.proceed(chain.request().newBuilder().header("Authorization", "Bearer ${settings.token}").build()) + }.build() + + private fun url(path: String) = (settings.serverUrl.trimEnd('/') + path).toHttpUrl() + + private suspend fun call(req: Request): String = withContext(Dispatchers.IO) { + http.newCall(req).execute().use { r: Response -> + val body = r.body?.string().orEmpty() + if (!r.isSuccessful) throw ApiException("HTTP ${r.code}: ${body.take(200)}") + body + } + } + + suspend fun health(): Boolean = withContext(Dispatchers.IO) { + http.newCall(Request.Builder().url(url("/healthz")).build()).execute().use { it.isSuccessful } + } + + suspend fun sessions(): List = json.decodeFromString(call(Request.Builder().url(url("/api/sessions")).build())) + + suspend fun messages(sessionId: String): List = + json.decodeFromString(call(Request.Builder().url(url("/api/sessions/$sessionId/messages")).build())) + + suspend fun deleteSession(sessionId: String) { + call(Request.Builder().url(url("/api/sessions/$sessionId")).delete().build()) + } + + /** + * Opens a WebSocket, sends one message and emits streamed events until "done"/"error". + */ + fun stream(sessionId: String?, text: String): 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))) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + val ev = runCatching { json.decodeFromString(AgentEvent.serializer(), text) }.getOrNull() ?: return + trySend(ev) + if (ev.type == "done" || ev.type == "error") { + webSocket.close(1000, null) + close() + } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + val msg = response?.let { "HTTP ${it.code}" } ?: (t.message ?: t.javaClass.simpleName) + trySend(AgentEvent(type = "error", text = msg)) + close() + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { close() } + }) + awaitClose { ws.cancel() } + } + + companion object { + private val shared = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.SECONDS) // streaming responses can be long + .pingInterval(20, TimeUnit.SECONDS) + .build() + + /** Only http(s) URLs are accepted; OkHttp maps them to ws(s) for WebSockets. */ + fun validateUrl(raw: String): String? { + val u = raw.trim() + if (!(u.startsWith("https://") || u.startsWith("http://"))) return "URL muss mit https:// oder http:// beginnen" + return runCatching { u.toHttpUrl(); null }.getOrElse { "Ungültige URL" } + } + } +} diff --git a/android/app/src/main/java/dev/byteclaw/client/data/SettingsStore.kt b/android/app/src/main/java/dev/byteclaw/client/data/SettingsStore.kt new file mode 100644 index 0000000..df170e4 --- /dev/null +++ b/android/app/src/main/java/dev/byteclaw/client/data/SettingsStore.kt @@ -0,0 +1,35 @@ +package dev.byteclaw.client.data + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +data class ServerSettings(val serverUrl: String = "", val token: String = "") { + val isConfigured get() = serverUrl.isNotBlank() && token.isNotBlank() +} + +/** Stores server URL and token encrypted with an Android Keystore-backed key. */ +class SettingsStore(context: Context) { + private val prefs: SharedPreferences = EncryptedSharedPreferences.create( + context, + "byteclaw_secure_prefs", + MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(), + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + fun load() = ServerSettings( + serverUrl = prefs.getString(KEY_URL, "") ?: "", + token = prefs.getString(KEY_TOKEN, "") ?: "", + ) + + fun save(s: ServerSettings) { + prefs.edit().putString(KEY_URL, s.serverUrl.trim().trimEnd('/')).putString(KEY_TOKEN, s.token.trim()).apply() + } + + private companion object { + const val KEY_URL = "server_url" + const val KEY_TOKEN = "token" + } +}