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
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
data/
|
/data/
|
||||||
bin/
|
/bin/
|
||||||
dist/
|
/dist/
|
||||||
*.db
|
*.db
|
||||||
config.yaml
|
config.yaml
|
||||||
.env
|
.env
|
||||||
|
|
|
||||||
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