fix: track Android data package (was hidden by root .gitignore 'data/')
This commit is contained in:
parent
399bcd46fb
commit
310243840d
4 changed files with 197 additions and 3 deletions
63
android/app/src/main/java/dev/byteclaw/client/data/Models.kt
Normal file
63
android/app/src/main/java/dev/byteclaw/client/data/Models.kt
Normal file
|
|
@ -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<ToolCall> = 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
|
||||
}
|
||||
|
|
@ -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<Session> = json.decodeFromString(call(Request.Builder().url(url("/api/sessions")).build()))
|
||||
|
||||
suspend fun messages(sessionId: String): List<Message> =
|
||||
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<AgentEvent> = callbackFlow {
|
||||
val wsUrl = url("/api/ws")
|
||||
val ws = http.newWebSocket(Request.Builder().url(wsUrl).build(), object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(json.encodeToString(OutgoingMessage.serializer(), OutgoingMessage(sessionId = sessionId.orEmpty(), text = text)))
|
||||
}
|
||||
|
||||
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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue