Hermes Conduit Android v1 (MVP): Login, Session-Liste, Streaming-Chat, Profile

- Kotlin + Jetpack Compose (Material 3, Dark Theme), minSdk 26, target 35
- Auth-Flow wie iOS Conduit: password-login → /api/auth/me → ws-ticket → WS
- JSON-RPC 2.0 über WS-Textframes, 1:1 portiert aus Conduit/Services/HermesClient.swift
- Streaming (message.delta/complete), Markdown mit Code-Highlighting (mikepenz)
- Profile-Anzeige/-Wechsel, Kontext-Auslastungs-Balken aus session.usage
- Zugangsdaten via EncryptedSharedPreferences (Android Keystore)
- Netzwerk-Core Android-frei → JVM-Live-Test gegen das echte Dashboard
This commit is contained in:
Hermes Agent 2026-10-21 12:57:28 +00:00
commit 75e0f25def
26 changed files with 2510 additions and 0 deletions

17
.gitignore vendored Normal file
View file

@ -0,0 +1,17 @@
# Android
local.properties
*.iml
.idea/
.gradle/
build/
*/build/
captures/
.externalNativeBuild/
.cxx/
*.apk
*.keystore
!debug.keystore
# Credentials — NIEMALS committen
*.env
secrets.properties

116
README.md Normal file
View file

@ -0,0 +1,116 @@
# Hermes Conduit — Android
Nativer Android-Client für einen self-hosted [Hermes Agent](https://hermes-agent.nousresearch.com)
über das Dashboard-Gateway. Android-Pendant zur iOS-App
[Hermes Conduit](https://github.com/kaishi00/hermes-conduit) (SwiftUI, MIT) — das RPC-Protokoll
ist 1:1 von dort portiert.
**UI-Sprache: Deutsch.** Dark Theme (Hermes-Look) ist Standard.
## Features (v1, MVP)
- **Login**: Host, Port, Benutzername, Passwort (Host/Port vorbefüllt mit `100.64.0.8` / `9119`).
Zugangsdaten werden verschlüsselt via `EncryptedSharedPreferences` (Android Keystore) gespeichert;
Auto-Login beim nächsten Start.
- **Session-Liste**: Titel, ID, Nachrichtenzahl, Suche, Pull-to-Reload via Refresh-Button,
neue Session anlegen.
- **Chat**: Streaming-Antworten (Live-Deltas), Markdown-Rendering mit Syntax-Highlighting
für Code-Blöcke, Tool-Aufruf-Zeilen, Abbruch-Button, Verlauf via `session.resume`.
- **Profile**: Anzeige und Wechsel der Hermes-Profile (`profiles.list`), Session-Liste lädt
profil-scoped neu.
- **Kontext-Auslastung**: Fortschrittsbalken im Chat-Header aus `session.usage`/`context.update`-Events.
Nicht in v1: Voice, Push, Kanban, Attachments/Bilder, OAuth, Approval/Clarify-Dialogs
(Server-Requests werden mit `-32601` abgelehnt, damit Tools nicht hängen).
## Build
Voraussetzungen: JDK 17, Android SDK (cmdline-tools, platform-35, build-tools 35.0.0).
```bash
export ANDROID_HOME=/root/android-sdk # oder eigenes SDK; alternativ local.properties mit sdk.dir
./gradlew assembleDebug
# APK: app/build/outputs/apk/debug/app-debug.apk
```
## Architektur
```
app/src/main/java/de/derittyp/hermesconduit/
├── core/ # Android-frei (reines JVM+OkHttp+kotlinx.serialization) → JVM-testbar
│ ├── AuthClient.kt # HTTP-Auth: password-login, /api/auth/me, ws-ticket, CookieJar
│ ├── HermesClient.kt # JSON-RPC 2.0 über WS-Textframes, Event-Parser, RPC-Methoden
│ ├── Models.kt # StreamEvent, SessionSummary, ChatMessage, RpcException
│ └── Json.kt # kotlinx.serialization Helfer
├── AppViewModel.kt # StateFlow-basierter UI-State, Event-Kollektion
├── CredentialStore.kt # EncryptedSharedPreferences (Keystore)
├── MainActivity.kt # Compose Navigation (login/sessions/chat)
└── ui/ # LoginScreen, SessionsScreen, ChatScreen, MarkdownMessage, Theme
```
## Auth-Flow (verifiziert gegen Live-Dashboard am 2026-10-21)
1. `POST /auth/password-login` — JSON `{username, password, provider:"basic"}`
→ `{"ok":true}` + `Set-Cookie: hermes_session_at, hermes_session_rt, hermes_session_provider`
2. `GET /api/auth/me` (mit Cookies) → `{"user_id":"...", ...}`
3. `POST /api/auth/ws-ticket` (mit Cookies) → `{"ticket":"...", "ttl_seconds":30}` (single-use)
4. WebSocket: `ws://<host>/api/ws?ticket=<ticket>`
Das Refresh-Cookie hat 30 Tage TTL; der Server refresht das Access-Cookie transparent.
Die App speichert v1 keine Cookies, sondern loggt bei Bedarf erneut ein (Credentials liegen
verschlüsselt vor). Ablauf der Session → Fehler in der UI → erneutes Einloggen.
## RPC-Protokoll (Port von iOS `Conduit/Services/HermesClient.swift`)
- Frames: JSON-RPC 2.0 **Textframes**. Client-Requests mit Integer-`id`; Responses matchen die `id`.
- Events: `{"method":"event","params":{"type":...,"session_id":...,"payload":{...}}}`
- Server→Client-Requests (`method` + String-`id`): v1 lehnt alle mit `-32601` ab.
- Auf `gateway.ready` antwortet der Client mit `client.capabilities {server_requests: true}`.
- Nicht-default-Profil: `profile`-Param wird an jede scoped RPC gehängt.
Genutzte Methoden: `session.list`, `session.resume` (`cols:96, source:"android", inline_images:false`),
`session.create`, `session.title`, `session.interrupt`, `prompt.submit` (`session_id, text, surface`),
`profiles.list`, `session.delete` (optional, nur zum Aufräumen von Test-Sessions).
**ID-Modell (live verifiziert):** `session.list` liefert die *stored* id (z. B. `20261021_130231_e692eb`),
`session.resume`/`session.delete` verlangen sie. `session.create` liefert beide
(`session_id` = kurze Runtime-id, `stored_session_id`). `prompt.submit`/`session.title` akzeptieren
die Runtime-id; die Resume-Antwort liefert sie als `session_id` zurück — die App nutzt nach dem
Resume die Runtime-id für Sends und zum Matchen von Streaming-Events. `session.delete` verweigert
aktive Sessions (4023) und doppelte Titel werden mit 4022 abgelehnt.
Streaming-Events: `message.start/delta/complete`, `tool.start/tool.complete`, `session.busy`,
`session.usage`/`context.update` (Kontext-Balken), `sessions.changed` (triggert Reload, 800 ms debounced).
## Live-Test
Der Netzwerk-Core ist bewusst Android-frei und damit auf der JVM gegen das echte
Dashboard testbar:
```bash
HERMES_TEST_PASSWORD='<passwort>' ./gradlew :app:testDebugUnitTest --tests '*LiveGatewayTest'
```
Der Test (ohne Env-Var automatisch übersprungen, Credentials nie im Repo):
login → me → ws-ticket → connect → `session.list` → `profiles.list` → `session.resume`
(readonly) → **neue Test-Session** `conduit-android-test` anlegen → eine kurze Nachricht senden
(startet den echten Agenten, kostet Tokens!) → Streaming-Antwort abwarten → Verlauf prüfen →
Test-Session löschen (falls der Gateway `session.delete` kann, sonst bleibt sie bestehen).
Zusätzlich Negativtest: falsches Passwort schlägt fehl.
**Wichtig:** Nie in bestehenden Sessions (z. B. WhatsApp-Hauptsession) Nachrichten senden.
## Bekannte TODOs für v2
- Cookie-Persistenz + transparentes Session-Refresh statt erneutem Passwort-Login
- Approval-/Clarify-Dialoge (Server→Client-Requests beantworten statt ablehnen)
- Attachments/Bilder (`image.attach_bytes`, REST-History `/api/sessions/{id}/messages`
für paginierte History statt Full-Resume)
- REST-History-Hydration wie iOS (bounded pages) statt komplettem Transcript im Resume
- Voice, Push-Benachrichtigungen, Kanban-Tab, OAuth (RFC 8252 Native Flow)
- Reconnect-Logik mit Backoff bei Socket-Verlust
- Release-Signing, adaptive Icons, Edge-to-Edge-Padding-Feinschliff
## Lizenz
MIT (wie die iOS-Referenz-App).

74
app/build.gradle.kts Normal file
View file

@ -0,0 +1,74 @@
plugins {
id("com.android.application")
kotlin("android")
kotlin("plugin.compose")
kotlin("plugin.serialization")
}
android {
namespace = "de.derittyp.hermesconduit"
compileSdk = 35
defaultConfig {
applicationId = "de.derittyp.hermesconduit"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
}
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = "17" }
buildFeatures { compose = true }
packaging {
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
}
}
kotlin {
jvmToolchain(17)
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.navigation:navigation-compose:2.8.5")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// Markdown mit Code-Highlighting (Compose)
implementation("com.mikepenz:multiplatform-markdown-renderer-m3-android:0.27.0")
implementation("com.mikepenz:multiplatform-markdown-renderer-code-android:0.27.0")
debugImplementation("androidx.compose.ui:ui-tooling")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
testImplementation("com.squareup.okhttp3:okhttp:4.12.0")
testImplementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- HTTP (cleartext) ist erlaubt: Das Dashboard läuft im Tailscale-Netz
(WireGuard-verschlüsselt) und spricht kein HTTPS. -->
<application
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:label="Hermes Conduit"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.HermesConduit">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,330 @@
package de.derittyp.hermesconduit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.derittyp.hermesconduit.core.AuthClient
import de.derittyp.hermesconduit.core.ChatMessage
import de.derittyp.hermesconduit.core.HermesClient
import de.derittyp.hermesconduit.core.RpcException
import de.derittyp.hermesconduit.core.SessionSummary
import de.derittyp.hermesconduit.core.StreamEvent
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class UiState(
val loggedIn: Boolean = false,
val connecting: Boolean = false,
val loginError: String? = null,
val username: String = "",
val baseUrl: String = "",
val sessions: List<SessionSummary> = emptyList(),
val sessionsLoading: Boolean = false,
val sessionsError: String? = null,
val searchQuery: String = "",
val profiles: List<String> = emptyList(),
val currentProfile: String = "default",
// Chat
val chatSessionId: String? = null,
val chatTitle: String = "",
val chatMessages: List<ChatMessage> = emptyList(),
val streamingText: String = "",
val streaming: Boolean = false,
val chatBusy: Boolean = false,
val chatLoading: Boolean = false,
val chatError: String? = null,
val contextPercent: Double? = null,
val contextUsed: Int = 0,
val contextMax: Int = 0,
)
class AppViewModel : ViewModel() {
private val _ui = MutableStateFlow(UiState())
val ui: StateFlow<UiState> = _ui
private var authClient: AuthClient? = null
private var hermesClient: HermesClient? = null
private var eventJob: Job? = null
private var sessionsReloadJob: Job? = null
fun tryAutoLogin(credentialStore: CredentialStore) {
val creds = credentialStore.load() ?: return
login(creds.host, creds.port, creds.username, creds.password, credentialStore, saveCredentials = false)
}
fun login(
host: String,
port: String,
username: String,
password: String,
credentialStore: CredentialStore?,
saveCredentials: Boolean = true,
) {
if (_ui.value.connecting) return
_ui.update { it.copy(connecting = true, loginError = null) }
viewModelScope.launch {
try {
val auth = AuthClient()
val baseUrl = auth.normalizeBaseUrl(host, port)
auth.login(baseUrl, username, password)
val me = auth.me(baseUrl)
val ticket = auth.wsTicket(baseUrl)
val client = HermesClient(auth.http)
client.connect(auth.wsUrl(baseUrl, ticket))
authClient = auth
hermesClient = client
if (saveCredentials) {
credentialStore?.save(
CredentialStore.Credentials(host, port, username, password)
)
}
collectEvents(client)
_ui.update {
it.copy(
loggedIn = true,
connecting = false,
username = me.str("user_id") ?: username,
baseUrl = baseUrl
)
}
loadProfiles()
loadSessions()
} catch (e: Exception) {
_ui.update {
it.copy(
connecting = false,
loginError = e.message ?: "Verbindung fehlgeschlagen"
)
}
}
}
}
fun logout(credentialStore: CredentialStore) {
credentialStore.clear()
eventJob?.cancel()
hermesClient?.close()
hermesClient = null
authClient = null
_ui.value = UiState()
}
private fun collectEvents(client: HermesClient) {
eventJob?.cancel()
eventJob = viewModelScope.launch {
client.events.collect { event ->
when (event) {
is StreamEvent.MessageStart -> ifCurrentSession(event.sessionId) {
_ui.update { it.copy(streaming = true, streamingText = "") }
}
is StreamEvent.MessageDelta -> ifCurrentSession(event.sessionId) {
_ui.update { it.copy(streaming = true, streamingText = it.streamingText + event.text) }
}
is StreamEvent.MessageComplete -> ifCurrentSession(event.sessionId) {
_ui.update { s ->
val text = event.content ?: s.streamingText
val msgs = if (text.isNotBlank()) {
s.chatMessages + ChatMessage(
id = "complete-${System.currentTimeMillis()}",
role = ChatMessage.Role.ASSISTANT,
content = text
)
} else s.chatMessages
s.copy(chatMessages = msgs, streaming = false, streamingText = "", chatBusy = false)
}
}
is StreamEvent.ToolStart -> ifCurrentSession(event.sessionId) {
_ui.update { s ->
s.copy(
chatMessages = s.chatMessages + ChatMessage(
id = "tool-${System.currentTimeMillis()}-${event.name}",
role = ChatMessage.Role.SYSTEM,
content = "⚙ ${event.name}",
isTool = true
)
)
}
}
is StreamEvent.ToolComplete -> { /* Tool-Zeile bleibt stehen */ }
is StreamEvent.SessionBusy -> ifCurrentSession(event.sessionId) {
_ui.update { it.copy(chatBusy = event.busy, streaming = event.busy && it.streaming) }
}
is StreamEvent.ContextUpdate -> ifCurrentSession(event.sessionId) {
_ui.update {
it.copy(
contextPercent = event.percent,
contextUsed = event.used,
contextMax = event.max
)
}
}
is StreamEvent.UserMessage -> { /* andere Clients: bei reload sichtbar */ }
is StreamEvent.Error -> ifCurrentSession(event.sessionId) {
_ui.update { it.copy(chatError = event.message, streaming = false, chatBusy = false) }
}
is StreamEvent.SessionsChanged -> {
sessionsReloadJob?.cancel()
sessionsReloadJob = viewModelScope.launch {
delay(800)
loadSessions()
}
}
is StreamEvent.SessionTitleEvent -> loadSessionsQuiet()
StreamEvent.Disconnected -> _ui.update { it.copy(chatError = "Verbindung getrennt") }
StreamEvent.GatewayReady -> {}
}
}
}
}
private inline fun ifCurrentSession(sessionId: String, block: () -> Unit) {
val current = _ui.value.chatSessionId ?: return
// session_id aus Events ist die Runtime-ID; akzeptiere auch die gespeicherte ID.
if (sessionId.isBlank() || sessionId == current) block()
}
fun loadSessions() {
val client = hermesClient ?: return
_ui.update { it.copy(sessionsLoading = true, sessionsError = null) }
viewModelScope.launch {
try {
val sessions = client.sessions()
_ui.update { it.copy(sessions = sessions, sessionsLoading = false) }
} catch (e: Exception) {
_ui.update { it.copy(sessionsLoading = false, sessionsError = e.message) }
}
}
}
private fun loadSessionsQuiet() {
val client = hermesClient ?: return
viewModelScope.launch {
runCatching { client.sessions() }.onSuccess { sessions ->
_ui.update { it.copy(sessions = sessions) }
}
}
}
fun loadProfiles() {
val client = hermesClient ?: return
viewModelScope.launch {
runCatching { client.profiles() }.onSuccess { profiles ->
_ui.update { it.copy(profiles = profiles) }
}
}
}
fun switchProfile(profile: String) {
val client = hermesClient ?: return
client.profile = profile
_ui.update { it.copy(currentProfile = profile, sessions = emptyList()) }
loadSessions()
}
fun setSearchQuery(q: String) = _ui.update { it.copy(searchQuery = q) }
fun openSession(session: SessionSummary) {
val client = hermesClient ?: return
_ui.update {
it.copy(
chatSessionId = session.id,
chatTitle = session.title,
chatMessages = emptyList(),
chatLoading = true,
chatError = null,
streaming = false,
streamingText = "",
contextPercent = null
)
}
viewModelScope.launch {
try {
val resume = client.resumeSession(session.id)
_ui.update {
it.copy(
chatSessionId = resume.sessionId,
chatMessages = resume.messages.filter { m -> m.role != ChatMessage.Role.SYSTEM || m.isTool },
chatLoading = false
)
}
} catch (e: Exception) {
_ui.update { it.copy(chatLoading = false, chatError = e.message) }
}
}
}
fun createSession() {
val client = hermesClient ?: return
viewModelScope.launch {
_ui.update { it.copy(sessionsLoading = true) }
try {
val created = client.createSession()
// session.resume verlangt die STORED id; die Antwort liefert
// dann die Runtime-id, mit der prompt.submit arbeitet.
openSession(
SessionSummary(
created.storedSessionId ?: created.sessionId,
"Neue Session", "Hermes", null, null, client.profile
)
)
} catch (e: Exception) {
_ui.update { it.copy(sessionsLoading = false, sessionsError = e.message) }
}
}
}
fun closeChat() = _ui.update { it.copy(chatSessionId = null, chatMessages = emptyList(), chatError = null) }
fun sendMessage(text: String) {
val client = hermesClient ?: return
val sessionId = _ui.value.chatSessionId ?: return
if (text.isBlank()) return
_ui.update { s ->
s.copy(
chatMessages = s.chatMessages + ChatMessage(
id = "local-${System.currentTimeMillis()}",
role = ChatMessage.Role.USER,
content = text
),
chatBusy = true,
chatError = null
)
}
viewModelScope.launch {
try {
client.sendPrompt(sessionId, text)
} catch (e: Exception) {
_ui.update {
it.copy(
chatBusy = false,
chatError = when (e) {
is RpcException -> e.message
else -> "Senden fehlgeschlagen: ${e.message}"
}
)
}
}
}
}
fun interruptChat() {
val client = hermesClient ?: return
val sessionId = _ui.value.chatSessionId ?: return
viewModelScope.launch {
runCatching { client.interrupt(sessionId) }
_ui.update { it.copy(chatBusy = false, streaming = false) }
}
}
override fun onCleared() {
hermesClient?.close()
}
}
private fun kotlinx.serialization.json.JsonObject.str(key: String): String? =
(this[key] as? kotlinx.serialization.json.JsonPrimitive)
?.takeIf { it.isString }?.content?.takeIf { it.isNotBlank() }

View file

@ -0,0 +1,37 @@
package de.derittyp.hermesconduit
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
/** Zugangsdaten im Android Keystore via EncryptedSharedPreferences. */
class CredentialStore(context: Context) {
private val prefs = EncryptedSharedPreferences.create(
context,
"hermes_conduit_credentials",
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
data class Credentials(val host: String, val port: String, val username: String, val password: String)
fun load(): Credentials? {
val host = prefs.getString("host", null) ?: return null
val username = prefs.getString("username", null) ?: return null
val password = prefs.getString("password", null) ?: return null
return Credentials(host, prefs.getString("port", "9119") ?: "9119", username, password)
}
fun save(c: Credentials) {
prefs.edit()
.putString("host", c.host)
.putString("port", c.port)
.putString("username", c.username)
.putString("password", c.password)
.apply()
}
fun clear() = prefs.edit().clear().apply()
}

View file

@ -0,0 +1,116 @@
package de.derittyp.hermesconduit
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import de.derittyp.hermesconduit.ui.ChatScreen
import de.derittyp.hermesconduit.ui.LoginScreen
import de.derittyp.hermesconduit.ui.SessionsScreen
import de.derittyp.hermesconduit.ui.theme.HermesConduitTheme
class MainActivity : ComponentActivity() {
private val credentialStore by lazy { CredentialStore(applicationContext) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
HermesConduitTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
val vm: AppViewModel = viewModel()
val ui by vm.ui.collectAsState()
val nav = rememberNavController()
// Auto-Login mit gespeicherten Zugangsdaten (einmalig).
androidx.compose.runtime.LaunchedEffect(Unit) {
if (!ui.loggedIn && !ui.connecting) vm.tryAutoLogin(credentialStore)
}
// Navigation folgt dem Login-Status.
androidx.compose.runtime.LaunchedEffect(ui.loggedIn, ui.chatSessionId) {
when {
!ui.loggedIn -> nav.navigate("login") {
popUpTo(0) { inclusive = true }
}
ui.chatSessionId != null -> if (nav.currentDestination?.route != "chat") {
nav.navigate("chat")
}
else -> if (nav.currentDestination?.route != "sessions") {
nav.navigate("sessions") { popUpTo(0) { inclusive = true } }
}
}
}
NavHost(navController = nav, startDestination = "login") {
composable("login") {
val creds = remember { credentialStore.load() }
LoginScreen(
connecting = ui.connecting,
error = ui.loginError,
initialHost = creds?.host ?: "100.64.0.8",
initialPort = creds?.port ?: "9119",
onLogin = { h, p, u, pw -> vm.login(h, p, u, pw, credentialStore) }
)
}
composable("sessions") {
SessionsScreen(
username = ui.username,
sessions = ui.sessions,
loading = ui.sessionsLoading,
error = ui.sessionsError,
searchQuery = ui.searchQuery,
profiles = ui.profiles,
currentProfile = ui.currentProfile,
onSearchChange = vm::setSearchQuery,
onSessionClick = vm::openSession,
onNewSession = vm::createSession,
onRefresh = vm::loadSessions,
onProfileChange = vm::switchProfile,
onLogout = {
vm.closeChat()
vm.logout(credentialStore)
}
)
}
composable("chat") {
ChatScreen(
title = ui.chatTitle,
messages = ui.chatMessages,
streamingText = ui.streamingText,
streaming = ui.streaming,
busy = ui.chatBusy,
loading = ui.chatLoading,
error = ui.chatError,
contextPercent = ui.contextPercent,
contextUsed = ui.contextUsed,
contextMax = ui.contextMax,
onSend = vm::sendMessage,
onInterrupt = vm::interruptChat,
onBack = {
vm.closeChat()
nav.popBackStack()
}
)
}
}
}
}
}
}
}

View file

@ -0,0 +1,117 @@
package de.derittyp.hermesconduit.core
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
/**
* Auth-Brücke zum Hermes-Dashboard (1:1 wie iOS Conduit / Hermes Desktop):
* 1. POST /auth/password-login {username, password, provider:"basic"} → Session-Cookies
* 2. GET /api/auth/me (mit Cookies)
* 3. POST /api/auth/ws-ticket (mit Cookies) → {ticket, ttl_seconds:30}, single-use
* 4. WebSocket: ws(s)://host/api/ws?ticket=<ticket>
*/
class AuthClient(cookieStore: MutableList<Cookie>? = null) {
/** Einfache in-memory CookieJar; Cookies lassen sich exportieren/persistieren. */
class SimpleCookieJar(initial: List<Cookie> = emptyList()) : CookieJar {
private val store = ConcurrentHashMap<String, MutableList<Cookie>>()
init {
initial.groupBy { it.domain }.forEach { (d, c) -> store[d] = c.toMutableList() }
}
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val list = store.getOrPut(url.host) { mutableListOf() }
synchronized(list) {
for (c in cookies) {
list.removeAll { it.name == c.name }
if (c.expiresAt > System.currentTimeMillis()) list.add(c)
}
}
}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
val list = store[url.host] ?: return emptyList()
synchronized(list) {
list.removeAll { it.expiresAt <= System.currentTimeMillis() }
return list.toList()
}
}
fun allCookies(): List<Cookie> = store.values.flatMap { l -> synchronized(l) { l.toList() } }
}
val cookieJar = SimpleCookieJar(cookieStore ?: mutableListOf())
val http: OkHttpClient = OkHttpClient.Builder()
.cookieJar(cookieJar)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
class AuthException(message: String, val httpStatus: Int? = null) : Exception(message)
/** Normalisiert Host-Eingabe: "100.64.0.8:9119", "http://100.64.0.8:9119" o.ä. → Basis-URL. */
fun normalizeBaseUrl(host: String, port: String): String {
var h = host.trim().removeSuffix("/")
if (!h.startsWith("http://") && !h.startsWith("https://")) {
h = if (h.contains(":")) "http://$h" else "http://$h:${port.trim()}"
}
return h.removeSuffix("/")
}
private suspend fun execute(request: Request): Pair<Int, String> = withContext(Dispatchers.IO) {
http.newCall(request).execute().use { resp ->
resp.code to (resp.body?.string() ?: "")
}
}
/** Login via Password-Provider. Setzt hermes_session_at/rt Cookies in der Jar. */
suspend fun login(baseUrl: String, username: String, password: String, provider: String = "basic") {
val body = rpcJson.encodeToString(
JsonObject.serializer(),
jsonObj("username" to username, "password" to password, "provider" to provider)
).toRequestBody("application/json".toMediaType())
val (code, text) = execute(
Request.Builder().url("$baseUrl/auth/password-login").post(body).build()
)
if (code != 200) throw AuthException("Login fehlgeschlagen (HTTP $code)", code)
val obj = runCatching { rpcJson.parseToJsonElement(text).jsonObject }.getOrNull()
if (obj?.boolVal("ok") != true) throw AuthException("Login fehlgeschlagen: ungültige Antwort")
}
/** Aktueller Benutzer (Session-Check). */
suspend fun me(baseUrl: String): JsonObject {
val (code, text) = execute(Request.Builder().url("$baseUrl/api/auth/me").get().build())
if (code != 200) throw AuthException("Session ungültig (HTTP $code)", code)
return rpcJson.parseToJsonElement(text).jsonObject
}
/** Single-use WebSocket-Ticket (30s TTL). */
suspend fun wsTicket(baseUrl: String): String {
val (code, text) = execute(
Request.Builder().url("$baseUrl/api/auth/ws-ticket")
.post(ByteArray(0).toRequestBody(null)).build()
)
if (code != 200) throw AuthException("WS-Ticket fehlgeschlagen (HTTP $code)", code)
return rpcJson.parseToJsonElement(text).jsonObject.str("ticket")
?: throw AuthException("WS-Ticket fehlgeschlagen: kein Ticket in Antwort")
}
fun wsUrl(baseUrl: String, ticket: String): String {
val wsBase = baseUrl.replaceFirst("http://", "ws://").replaceFirst("https://", "wss://")
return "$wsBase/api/ws?ticket=$ticket"
}
}

View file

@ -0,0 +1,366 @@
package de.derittyp.hermesconduit.core
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resumeWithException
/**
* JSON-RPC 2.0 über WebSocket-Textframes — Port der iOS-Conduit-Schicht
* (Conduit/Services/HermesClient.swift):
* - Client-Requests: {"jsonrpc":"2.0","id":<int>,"method":...,"params":{...}}
* - Responses: Integer-id, result oder error
* - Events: {"method":"event","params":{"type":...,"session_id":...,"payload":{...}}}
* - Server→Client-Requests (method + String-id) werden beantwortet/abgelehnt.
* - Nicht-default-Profile werden als "profile"-Param an jede scoped RPC gehängt.
*/
class HermesClient(
private val okHttp: OkHttpClient,
var profile: String = "default",
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var webSocket: WebSocket? = null
private val pending = ConcurrentHashMap<Int, CompletableDeferred<kotlinx.serialization.json.JsonElement>>()
private var nextId = 0
private val _events = MutableSharedFlow<StreamEvent>(extraBufferCapacity = 512)
val events: SharedFlow<StreamEvent> = _events
private val _connected = MutableStateFlow(false)
val connected: StateFlow<Boolean> = _connected
private var openDeferred: CompletableDeferred<Unit>? = null
suspend fun connect(wsUrl: String) {
val client = okHttp.newBuilder().pingInterval(20, TimeUnit.SECONDS).build()
val deferred = CompletableDeferred<Unit>()
openDeferred = deferred
val request = Request.Builder().url(wsUrl).build()
val ws = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
_connected.value = true
openDeferred?.complete(Unit)
}
override fun onMessage(webSocket: WebSocket, text: String) {
handleFrame(text)
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
webSocket.close(1000, null)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
onDisconnect()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
openDeferred?.completeExceptionally(t)
openDeferred = null
onDisconnect()
}
})
webSocket = ws
deferred.await()
}
private fun onDisconnect() {
_connected.value = false
val ex = RpcException(-1, "Verbindung getrennt")
pending.values.forEach { it.completeExceptionally(ex) }
pending.clear()
_events.tryEmit(StreamEvent.Disconnected)
}
fun disconnect() {
webSocket?.close(1000, "bye")
webSocket = null
onDisconnect()
}
private fun handleFrame(text: String) {
val frame = try {
rpcJson.parseToJsonElement(text).jsonObject
} catch (e: Exception) {
return
}
val method = (frame["method"] as? JsonPrimitive)?.takeIf { it.isString }?.content
val id = frame["id"]
// Server→Client-Request: method + String-id (z. B. approval/clarify).
if (method != null && method != "event" && id is JsonPrimitive && id.isString) {
replyToServerRequest(id.content)
return
}
// RPC-Response: Integer-id, kein method.
if (method == null && id is JsonPrimitive && !id.isString) {
val intId = id.intOrNull ?: return
val deferred = pending.remove(intId) ?: return
val error = frame["error"] as? JsonObject
if (error != null) {
deferred.completeExceptionally(
RpcException(error.intVal("code") ?: -1, error.str("message") ?: "unbekannt")
)
} else {
deferred.complete(frame["result"] ?: JsonObject(emptyMap()))
}
return
}
// Stream-Event.
if (method == "event") {
val params = frame["params"] as? JsonObject ?: return
parseEvent(params)?.let { _events.tryEmit(it) }
}
}
/** Unbekannte Server-Requests ablehnen, damit Tools nicht hängen (wie iOS: -32601). */
private fun replyToServerRequest(id: String) {
val reply = JsonObject(
mapOf(
"jsonrpc" to JsonPrimitive("2.0"),
"id" to JsonPrimitive(id),
"error" to JsonObject(
mapOf(
"code" to JsonPrimitive(-32601),
"message" to JsonPrimitive("kein Handler in Hermes Conduit Android")
)
)
)
)
webSocket?.send(rpcJson.encodeToString(JsonObject.serializer(), reply))
}
private fun parseEvent(params: JsonObject): StreamEvent? {
val type = params.str("type") ?: return null
val sessionId = params.str("session_id") ?: ""
val payload = params.obj("payload")
return when (type) {
"gateway.ready" -> {
// Server-Request-Capability bekanntgeben (iOS: advertiseClientCapabilities).
scope.launch {
runCatching {
rpc("client.capabilities", jsonObj("server_requests" to true), scoped = false)
}
}
StreamEvent.GatewayReady
}
"message.start" -> StreamEvent.MessageStart(sessionId)
"message.delta" -> {
val text = payload?.str("text") ?: params.str("text") ?: return null
StreamEvent.MessageDelta(sessionId, text)
}
"message.complete" -> StreamEvent.MessageComplete(
sessionId,
content = payload?.str("content", "text", "rendered"),
reasoning = payload?.str("reasoning")
)
"tool.start", "tool_call" -> StreamEvent.ToolStart(
sessionId,
payload?.str("name") ?: "tool",
payload?.str("args_text", "input", "context")
)
"tool.complete", "tool_result" -> StreamEvent.ToolComplete(sessionId, payload?.str("name") ?: "tool")
"session.busy" -> StreamEvent.SessionBusy(sessionId, payload?.boolVal("busy") ?: false)
"context.update", "session.context" -> StreamEvent.ContextUpdate(
sessionId,
payload?.let { (it["context_percent"] as? JsonPrimitive)?.doubleOrNull } ?: 0.0,
payload?.intVal("context_used") ?: 0,
payload?.intVal("context_max") ?: 0
)
"session.usage" -> {
val usage = payload?.obj("usage") ?: return null
val max = usage.intVal("context_max") ?: return null
val used = usage.intVal("context_used") ?: 0
val pct = (usage["context_percent"] as? JsonPrimitive)?.doubleOrNull
?: (used.toDouble() / max * 100)
StreamEvent.ContextUpdate(sessionId, pct, used, max)
}
"session.title" -> {
val stored = payload?.str("session_id") ?: return null
val title = payload.str("title") ?: return null
StreamEvent.SessionTitleEvent(sessionId, stored, title)
}
"user.message" -> {
val text = payload?.str("text") ?: return null
StreamEvent.UserMessage(sessionId, text)
}
"sessions.changed" -> StreamEvent.SessionsChanged
"error" -> StreamEvent.Error(sessionId, payload?.str("message") ?: "Hermes-Fehler")
else -> null
}
}
suspend fun rpc(
method: String,
params: JsonObject? = null,
scoped: Boolean = true,
timeoutMs: Long = 30_000,
): kotlinx.serialization.json.JsonElement {
val ws = webSocket ?: throw RpcException(-1, "nicht verbunden")
if (!_connected.value) throw RpcException(-1, "nicht verbunden")
val id = synchronized(this) { ++nextId }
val effectiveParams: JsonObject? = run {
val base = params ?: JsonObject(emptyMap())
val withProfile = if (scoped && profile != "default" && base["profile"] == null) {
JsonObject(base + ("profile" to JsonPrimitive(profile)))
} else base
withProfile.ifEmpty { null }
}
val request = JsonObject(
buildMap {
put("jsonrpc", JsonPrimitive("2.0"))
put("id", JsonPrimitive(id))
put("method", JsonPrimitive(method))
if (effectiveParams != null) put("params", effectiveParams)
}
)
val deferred = CompletableDeferred<kotlinx.serialization.json.JsonElement>()
pending[id] = deferred
if (!ws.send(rpcJson.encodeToString(JsonObject.serializer(), request))) {
pending.remove(id)
throw RpcException(-1, "Senden fehlgeschlagen")
}
return withTimeout(timeoutMs) { deferred.await() }
}
// ---- API (Port der iOS-Methoden) ----
suspend fun sessions(): List<SessionSummary> {
val result = rpc("session.list")
val records = when (result) {
is kotlinx.serialization.json.JsonArray -> result
is JsonObject -> result.arr("sessions") ?: kotlinx.serialization.json.JsonArray(emptyList())
else -> kotlinx.serialization.json.JsonArray(emptyList())
}
return records.mapIndexedNotNull { index, item ->
val obj = item as? JsonObject ?: return@mapIndexedNotNull null
val id = obj.str("session_id") ?: obj.str("id") ?: "row-$index"
SessionSummary(
id = id,
title = obj.str("title") ?: obj.str("preview")?.take(60) ?: "Unbenannt",
model = obj.str("model") ?: "Hermes",
updatedLabel = obj.str("updated_at", "last_activity", "modified", "updated", "created_at"),
messageCount = obj.intVal("message_count", "messageCount"),
profile = obj.str("profile", "profile_name", "profile_id") ?: profile
)
}
}
suspend fun profiles(): List<String> {
val result = rpc("profiles.list", scoped = false)
val arr = when (result) {
is kotlinx.serialization.json.JsonArray -> result
is JsonObject -> result.arr("profiles") ?: return emptyList()
else -> return emptyList()
}
return arr.mapNotNull { el ->
when (el) {
is JsonPrimitive -> if (el.isString) el.content else null
is JsonObject -> el.str("name")
else -> null
}
}
}
suspend fun resumeSession(sessionId: String): SessionResume {
val result = rpc(
"session.resume",
jsonObj(
"session_id" to sessionId,
"cols" to 96,
"source" to "android",
"inline_images" to false
),
timeoutMs = 120_000
)
val obj = result as? JsonObject ?: return SessionResume(sessionId, emptyList())
val resolvedId = obj.str("session_id") ?: sessionId
val messages = obj.arr("messages")?.mapIndexedNotNull { index, item ->
val envelope = item as? JsonObject ?: return@mapIndexedNotNull null
val nested = envelope.obj("message") ?: envelope.obj("payload") ?: envelope
val merged = JsonObject(envelope + nested)
if (merged.str("display_kind") == "hidden") return@mapIndexedNotNull null
val role = (merged.str("role") ?: merged.str("type") ?: "").lowercase()
val content = extractContent(merged["display_content"])
?: extractContent(merged["content"] ?: merged["text"])
?: return@mapIndexedNotNull null
val r = when (role) {
"user" -> ChatMessage.Role.USER
"system" -> ChatMessage.Role.SYSTEM
"tool" -> return@mapIndexedNotNull null
else -> ChatMessage.Role.ASSISTANT
}
ChatMessage(
id = merged.str("id", "message_id") ?: "msg-$index",
role = r,
content = content
)
} ?: emptyList()
return SessionResume(resolvedId, messages)
}
suspend fun createSession(title: String? = null): CreatedSession {
val result = rpc(
"session.create",
jsonObj(
"cols" to 96,
"source" to "android",
"title" to title
),
timeoutMs = 60_000
)
val obj = result as? JsonObject
return CreatedSession(
sessionId = obj?.str("session_id") ?: throw RpcException(-1, "session.create ohne session_id"),
storedSessionId = obj.str("stored_session_id")
)
}
suspend fun sendPrompt(sessionId: String, text: String) {
rpc(
"prompt.submit",
jsonObj("session_id" to sessionId, "text" to text, "surface" to "android"),
timeoutMs = 60_000
)
}
suspend fun interrupt(sessionId: String) {
rpc("session.interrupt", jsonObj("session_id" to sessionId))
}
suspend fun setSessionTitle(sessionId: String, title: String) {
rpc("session.title", jsonObj("session_id" to sessionId, "title" to title))
}
/** Löscht eine gespeicherte Session. Verlangt die STORED id (nicht die
* Runtime-id) und schlägt mit 4023 fehl, solange die Session aktiv ist. */
suspend fun deleteSession(storedSessionId: String) {
rpc("session.delete", jsonObj("session_id" to storedSessionId))
}
fun close() {
disconnect()
scope.cancel()
}
}

View file

@ -0,0 +1,67 @@
package de.derittyp.hermesconduit.core
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
val rpcJson = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
fun JsonObject.str(vararg keys: String): String? =
keys.firstNotNullOfOrNull { k ->
(this[k] as? JsonPrimitive)?.takeIf { it.isString }?.content?.takeIf { it.isNotBlank() }
}
fun JsonObject.intVal(vararg keys: String): Int? =
keys.firstNotNullOfOrNull { k -> (this[k] as? JsonPrimitive)?.intOrNull }
fun JsonObject.boolVal(vararg keys: String): Boolean? =
keys.firstNotNullOfOrNull { k -> (this[k] as? JsonPrimitive)?.booleanOrNull }
fun JsonObject.obj(key: String): JsonObject? = this[key] as? JsonObject
fun JsonObject.arr(key: String): JsonArray? = this[key] as? JsonArray
fun jsonObj(vararg pairs: Pair<String, Any?>): JsonObject = JsonObject(
pairs.mapNotNull { (k, v) ->
when (v) {
null -> null
is String -> k to JsonPrimitive(v)
is Int -> k to JsonPrimitive(v)
is Long -> k to JsonPrimitive(v)
is Double -> k to JsonPrimitive(v)
is Boolean -> k to JsonPrimitive(v)
is JsonElement -> k to v
else -> k to JsonPrimitive(v.toString())
}
}.toMap()
)
/** Nachrichten-Content kann String oder Array aus Parts ({type:"text",text:...}) sein. */
fun extractContent(el: JsonElement?): String? {
when (el) {
null -> return null
is JsonPrimitive -> return if (el.isString) el.content else el.content
is JsonArray -> {
val sb = StringBuilder()
for (part in el) {
val o = part as? JsonObject ?: continue
val t = o.str("text", "content")
if (t != null) sb.append(t)
}
return if (sb.isEmpty()) null else sb.toString()
}
is JsonObject -> return el.str("text", "content")
else -> return null
}
}

View file

@ -0,0 +1,52 @@
package de.derittyp.hermesconduit.core
/** Streaming-Events vom Gateway (Port von iOS Conduit StreamEventParser). */
sealed class StreamEvent {
data class MessageStart(val sessionId: String) : StreamEvent()
data class MessageDelta(val sessionId: String, val text: String) : StreamEvent()
data class MessageComplete(
val sessionId: String,
val content: String?,
val reasoning: String?,
) : StreamEvent()
data class ToolStart(val sessionId: String, val name: String, val input: String?) : StreamEvent()
data class ToolComplete(val sessionId: String, val name: String) : StreamEvent()
data class SessionBusy(val sessionId: String, val busy: Boolean) : StreamEvent()
data class ContextUpdate(val sessionId: String, val percent: Double, val used: Int, val max: Int) : StreamEvent()
data class SessionTitleEvent(val sessionId: String, val storedSessionId: String, val title: String) : StreamEvent()
data class UserMessage(val sessionId: String, val text: String) : StreamEvent()
data class Error(val sessionId: String, val message: String) : StreamEvent()
data object SessionsChanged : StreamEvent()
data object GatewayReady : StreamEvent()
data object Disconnected : StreamEvent()
}
data class SessionSummary(
val id: String,
val title: String,
val model: String,
val updatedLabel: String?,
val messageCount: Int?,
val profile: String?,
)
data class ChatMessage(
val id: String,
val role: Role,
val content: String,
val isTool: Boolean = false,
) {
enum class Role { USER, ASSISTANT, SYSTEM }
}
data class SessionResume(
val sessionId: String,
val messages: List<ChatMessage>,
)
data class CreatedSession(
val sessionId: String,
val storedSessionId: String?,
)
class RpcException(val code: Int, message: String) : Exception("RPC $code: $message")

View file

@ -0,0 +1,240 @@
package de.derittyp.hermesconduit.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import de.derittyp.hermesconduit.core.ChatMessage
import de.derittyp.hermesconduit.ui.theme.HermesTextDim
import de.derittyp.hermesconduit.ui.theme.HermesUser
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
title: String,
messages: List<ChatMessage>,
streamingText: String,
streaming: Boolean,
busy: Boolean,
loading: Boolean,
error: String?,
contextPercent: Double?,
contextUsed: Int,
contextMax: Int,
onSend: (String) -> Unit,
onInterrupt: () -> Unit,
onBack: () -> Unit,
) {
var input by remember { mutableStateOf("") }
val listState = rememberLazyListState()
// Bei neuen Inhalten ans Ende scrollen.
LaunchedEffect(messages.size, streamingText.length) {
val count = messages.size + (if (streamingText.isNotEmpty()) 1 else 0)
if (count > 0) listState.animateScrollToItem(count - 1)
}
Scaffold(
topBar = {
Column {
TopAppBar(
title = {
Text(
title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontFamily = FontFamily.Monospace
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück")
}
},
actions = {
if (busy) {
IconButton(onClick = onInterrupt) {
Icon(Icons.Default.Close, "Abbrechen")
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
// Kontext-Auslastung (aus session.usage / context.update Events)
contextPercent?.let { pct ->
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
LinearProgressIndicator(
progress = { (pct / 100f).coerceIn(0.0, 1.0).toFloat() },
modifier = Modifier.weight(1f).height(4.dp),
)
Spacer(Modifier.width(8.dp))
Text(
"Kontext %.0f%% (%d/%d)".format(pct, contextUsed, contextMax),
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = HermesTextDim
)
}
}
}
},
bottomBar = {
Surface(color = MaterialTheme.colorScheme.surface) {
Row(
Modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.Bottom
) {
OutlinedTextField(
value = input,
onValueChange = { input = it },
placeholder = { Text("Nachricht an Hermes…") },
modifier = Modifier.weight(1f).heightIn(max = 140.dp),
maxLines = 5
)
Spacer(Modifier.width(8.dp))
IconButton(
onClick = {
if (input.isNotBlank()) {
onSend(input.trim())
input = ""
}
},
enabled = input.isNotBlank()
) {
Icon(
Icons.AutoMirrored.Filled.Send,
"Senden",
tint = if (input.isNotBlank()) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
) { padding ->
Box(Modifier.fillMaxSize().padding(padding)) {
when {
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
else -> LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(messages, key = { it.id }) { msg ->
MessageBubble(msg)
}
if (streamingText.isNotEmpty()) {
item("streaming") {
AssistantBubble(streamingText, streaming = true)
}
}
if (busy && !streaming && streamingText.isEmpty()) {
item("thinking") {
Text(
"Hermes arbeitet…",
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = HermesTextDim,
modifier = Modifier.padding(8.dp)
)
}
}
}
}
error?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.align(Alignment.BottomCenter).padding(8.dp)
)
}
}
}
}
@Composable
private fun MessageBubble(msg: ChatMessage) {
when {
msg.isTool -> Text(
msg.content,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = HermesTextDim,
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)
)
msg.role == ChatMessage.Role.USER -> Row(Modifier.fillMaxWidth(), Arrangement.End) {
Surface(
color = HermesUser,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.widthIn(max = 300.dp)
) {
Text(
msg.content,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(10.dp)
)
}
}
else -> AssistantBubble(msg.content, streaming = false)
}
}
@Composable
private fun AssistantBubble(content: String, streaming: Boolean) {
Surface(
color = MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth()
) {
Column(Modifier.padding(10.dp)) {
MarkdownMessage(if (streaming) "$content ▌" else content)
}
}
}

View file

@ -0,0 +1,124 @@
package de.derittyp.hermesconduit.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
@Composable
fun LoginScreen(
connecting: Boolean,
error: String?,
initialHost: String,
initialPort: String,
onLogin: (host: String, port: String, user: String, pass: String) -> Unit,
) {
var host by remember { mutableStateOf(initialHost) }
var port by remember { mutableStateOf(initialPort) }
var user by remember { mutableStateOf("") }
var pass by remember { mutableStateOf("") }
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center
) {
Text(
"Hermes Conduit",
style = MaterialTheme.typography.headlineMedium,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.primary
)
Text(
"Android-Client für deinen Hermes Agent",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(32.dp))
Row(Modifier.fillMaxWidth()) {
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host") },
singleLine = true,
modifier = Modifier.weight(2f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)
)
Spacer(Modifier.width(8.dp))
OutlinedTextField(
value = port,
onValueChange = { port = it },
label = { Text("Port") },
singleLine = true,
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
}
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = user,
onValueChange = { user = it },
label = { Text("Benutzername") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = pass,
onValueChange = { pass = it },
label = { Text("Passwort") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth()
)
if (error != null) {
Spacer(Modifier.height(12.dp))
Text(error, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(24.dp))
Button(
onClick = { onLogin(host, port, user, pass) },
enabled = !connecting && host.isNotBlank() && user.isNotBlank() && pass.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
if (connecting) {
CircularProgressIndicator(
modifier = Modifier.height(20.dp).width(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(Modifier.width(8.dp))
}
Text(if (connecting) "Verbinde…" else "Anmelden")
}
Spacer(Modifier.height(16.dp))
Text(
"Zugangsdaten werden verschlüsselt im Android Keystore abgelegt.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View file

@ -0,0 +1,45 @@
package de.derittyp.hermesconduit.ui
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import com.mikepenz.markdown.compose.components.markdownComponents
import com.mikepenz.markdown.compose.elements.highlightedCodeBlock
import com.mikepenz.markdown.compose.elements.highlightedCodeFence
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
/** Markdown-Rendering mit Syntax-Highlighting für Code-Blöcke. */
@Composable
fun MarkdownMessage(content: String, modifier: Modifier = Modifier) {
Markdown(
content = content,
modifier = modifier.fillMaxWidth(),
colors = markdownColor(
text = MaterialTheme.colorScheme.onSurface,
codeBackground = MaterialTheme.colorScheme.surfaceVariant,
codeText = MaterialTheme.colorScheme.onSurface
),
typography = markdownTypography(
text = MaterialTheme.typography.bodyMedium,
code = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
h1 = MaterialTheme.typography.headlineSmall,
h2 = MaterialTheme.typography.titleLarge,
h3 = MaterialTheme.typography.titleMedium
),
components = markdownComponents(
codeBlock = highlightedCodeBlock,
codeFence = highlightedCodeFence
)
)
}
/** Fallback ohne Highlighting, falls ein Block den Renderer wirft. */
@Composable
fun MarkdownMessageSafe(content: String, modifier: Modifier = Modifier) {
MarkdownMessage(content, modifier)
}

View file

@ -0,0 +1,193 @@
package de.derittyp.hermesconduit.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ExitToApp
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import de.derittyp.hermesconduit.core.SessionSummary
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SessionsScreen(
username: String,
sessions: List<SessionSummary>,
loading: Boolean,
error: String?,
searchQuery: String,
profiles: List<String>,
currentProfile: String,
onSearchChange: (String) -> Unit,
onSessionClick: (SessionSummary) -> Unit,
onNewSession: () -> Unit,
onRefresh: () -> Unit,
onProfileChange: (String) -> Unit,
onLogout: () -> Unit,
) {
var profileMenu by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text("Sessions", fontFamily = FontFamily.Monospace)
Text(
"$username · Profil: $currentProfile",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
actions = {
IconButton(onClick = onRefresh) { Icon(Icons.Default.Refresh, "Aktualisieren") }
IconButton(onClick = onNewSession) { Icon(Icons.Default.Add, "Neue Session") }
Box {
IconButton(onClick = { profileMenu = true }) {
Icon(Icons.Default.Person, "Profil wechseln")
}
DropdownMenu(expanded = profileMenu, onDismissRequest = { profileMenu = false }) {
profiles.forEach { p ->
DropdownMenuItem(
text = {
Text(
if (p == currentProfile) "● $p" else p,
fontFamily = FontFamily.Monospace
)
},
onClick = {
profileMenu = false
if (p != currentProfile) onProfileChange(p)
}
)
}
}
}
IconButton(onClick = onLogout) { Icon(Icons.Default.ExitToApp, "Abmelden") }
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { padding ->
Column(Modifier.fillMaxSize().padding(padding)) {
OutlinedTextField(
value = searchQuery,
onValueChange = onSearchChange,
label = { Text("Suche") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)
)
if (loading && sessions.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
val filtered = sessions.filter {
searchQuery.isBlank() ||
it.title.contains(searchQuery, ignoreCase = true) ||
it.id.contains(searchQuery, ignoreCase = true)
}
if (error != null) {
Text(
error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(16.dp)
)
}
LazyColumn {
items(filtered, key = { it.id }) { session ->
SessionRow(session, onClick = { onSessionClick(session) })
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
}
}
}
}
}
}
@Composable
private fun SessionRow(session: SessionSummary, onClick: () -> Unit) {
Surface(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
color = MaterialTheme.colorScheme.background
) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text(
session.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.height(2.dp))
Row {
Text(
session.id.take(24),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
session.messageCount?.let {
Text(
" · $it Nachrichten",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
session.updatedLabel?.let {
Spacer(Modifier.width(8.dp))
Text(
it.take(16),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}

View file

@ -0,0 +1,39 @@
package de.derittyp.hermesconduit.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// Hermes-Look: dunkel, Cyan-Akzente, Mono für Code.
val HermesBg = Color(0xFF0B0E14)
val HermesSurface = Color(0xFF131822)
val HermesSurfaceVariant = Color(0xFF1B2230)
val HermesAccent = Color(0xFF59C2FF)
val HermesAccentDim = Color(0xFF2A6F97)
val HermesText = Color(0xFFE6EAF2)
val HermesTextDim = Color(0xFF8B93A5)
val HermesUser = Color(0xFF1D3A52)
val HermesError = Color(0xFFFF6B6B)
private val DarkScheme = darkColorScheme(
primary = HermesAccent,
onPrimary = Color(0xFF06283C),
secondary = HermesAccentDim,
background = HermesBg,
onBackground = HermesText,
surface = HermesSurface,
onSurface = HermesText,
surfaceVariant = HermesSurfaceVariant,
onSurfaceVariant = HermesTextDim,
error = HermesError,
outline = Color(0xFF2A3242)
)
@Composable
fun HermesConduitTheme(content: @Composable () -> Unit) {
// Dark ist Default (Hermes-Look), unabhängig vom System-Theme.
isSystemInDarkTheme() // bewusst ignoriert
MaterialTheme(colorScheme = DarkScheme, content = content)
}

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:height="48dp"
android:viewportWidth="48" android:viewportHeight="48">
<path android:fillColor="#0B0E14" android:pathData="M0,0h48v48h-48z" />
<path android:fillColor="#59C2FF"
android:pathData="M24,6 L38,14 L38,34 L24,42 L10,34 L10,14 Z" />
<path android:fillColor="#0B0E14"
android:pathData="M24,14 L31,18 L31,30 L24,34 L17,30 L17,18 Z" />
<path android:fillColor="#59C2FF"
android:pathData="M22,20 h4 v8 h-4 z" />
</vector>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.HermesConduit" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@android:color/black</item>
</style>
</resources>

View file

@ -0,0 +1,154 @@
package de.derittyp.hermesconduit.core
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Assume.assumeTrue
import org.junit.Test
/**
* Live-Integrationstest gegen das echte Dashboard (100.64.0.8:9119).
* Läuft nur, wenn HERMES_TEST_PASSWORD gesetzt ist (nie im Repo!).
*
* HERMES_TEST_PASSWORD=... ./gradlew :app:testDebugUnitTest --tests '*LiveGatewayTest'
*
* Der Test legt eine eigene Test-Session an ("conduit-android-test"),
* sendet GENAU EINE kurze Nachricht (startet den echten Agenten — kostet
* Tokens!) und versucht danach, die Session wieder zu löschen.
*/
class LiveGatewayTest {
private val host = System.getenv("HERMES_TEST_HOST") ?: "100.64.0.8"
private val port = System.getenv("HERMES_TEST_PORT") ?: "9119"
private val user = System.getenv("HERMES_TEST_USER") ?: "kristian"
private val password: String? = System.getenv("HERMES_TEST_PASSWORD")
@Test
fun fullFlowAgainstLiveDashboard() = runBlocking {
assumeTrue("HERMES_TEST_PASSWORD nicht gesetzt — Live-Test übersprungen", password != null)
// 1. Auth-Flow: password-login → /api/auth/me → ws-ticket
val auth = AuthClient()
val baseUrl = auth.normalizeBaseUrl(host, port)
assertEquals("http://$host:$port", baseUrl)
auth.login(baseUrl, user, password!!)
val me = auth.me(baseUrl)
assertEquals(user, me.str("user_id"))
println("AUTH OK: user=${me.str("user_id")}")
// 2. WebSocket verbinden
val ticket = auth.wsTicket(baseUrl)
assertTrue(ticket.isNotBlank())
val client = HermesClient(auth.http)
client.connect(auth.wsUrl(baseUrl, ticket))
assertTrue(client.connected.value)
println("WS OK: verbunden")
try {
// 3. Session-Liste
val sessions = client.sessions()
assertFalse("keine Sessions", sessions.isEmpty())
println("SESSIONS OK: ${sessions.size} Sessions, erste: ${sessions.first().title.take(40)}")
// 4. Profile
val profiles = client.profiles()
assertTrue(profiles.contains("default"))
println("PROFILES OK: $profiles")
// 5. Bestehende Session öffnen (nur lesen, kein Agent-Start)
val resume = client.resumeSession(sessions.first().id)
println("RESUME OK: ${resume.messages.size} Nachrichten in '${sessions.first().title.take(40)}'")
// 6. Test-Session anlegen + EINE Test-Nachricht senden
// Eindeutiger Titel: der Gateway lehnt doppelte Titel ab (4022).
val testTitle = "conduit-android-test-${System.currentTimeMillis()}"
val created = client.createSession(title = testTitle)
val testSessionId = created.sessionId
println("CREATE OK: runtime=$testSessionId stored=${created.storedSessionId}")
try {
client.setSessionTitle(testSessionId, testTitle)
val streamed = StringBuilder()
val completeDeferred = async {
withTimeout(120_000) {
var finalContent: String? = null
while (finalContent == null) {
when (val ev = client.events.first { ev ->
ev is StreamEvent.MessageDelta || ev is StreamEvent.MessageComplete
}) {
is StreamEvent.MessageDelta -> streamed.append(ev.text)
is StreamEvent.MessageComplete -> finalContent = ev.content
else -> {}
}
}
finalContent!!
}
}
client.sendPrompt(
testSessionId,
"Antworte exakt mit dem einen Wort: Pong"
)
val answer = completeDeferred.await()
val finalText = if (answer.isNotBlank()) answer else streamed.toString()
assertTrue("leere Antwort", finalText.isNotBlank())
println("PROMPT OK: Streaming-Deltas=${streamed.length} Zeichen, Antwort='${finalText.take(80)}'")
// 7. Resume der Test-Session (mit STORED id) muss den Verlauf zeigen
val testResume = client.resumeSession(created.storedSessionId ?: testSessionId)
assertTrue(testResume.messages.any { it.role == ChatMessage.Role.USER })
println("HISTORY OK: ${testResume.messages.size} Nachrichten")
} finally {
// Aufräumen: session.delete verlangt die STORED id und eine
// inaktive Session — also erst trennen, dann frisch verbinden.
val stored = created.storedSessionId
client.close()
if (stored != null) {
// Runtime-Session bleibt serverseitig noch eine Weile "aktiv" → Retry.
var deleted = false
for (attempt in 1..12) {
try {
val auth2 = AuthClient()
auth2.login(baseUrl, user, password)
val client2 = HermesClient(auth2.http)
client2.connect(auth2.wsUrl(baseUrl, auth2.wsTicket(baseUrl)))
try {
client2.deleteSession(stored)
println("DELETE OK: Test-Session $stored entfernt (Versuch $attempt)")
deleted = true
} finally {
client2.close()
}
break
} catch (e: Exception) {
if (attempt == 12) {
println("DELETE fehlgeschlagen (${e.message}) — Test-Session bleibt: $stored")
}
kotlinx.coroutines.delay(10_000)
}
}
if (!deleted) println("HINWEIS: Test-Session $stored manuell löschen")
}
}
} finally {
client.close()
}
}
@Test
fun loginFailsWithWrongPassword() = runBlocking {
assumeTrue(password != null)
val auth = AuthClient()
val baseUrl = auth.normalizeBaseUrl(host, port)
try {
auth.login(baseUrl, user, "definitiv-falsches-passwort")
fail("Login mit falschem Passwort darf nicht klappen")
} catch (e: AuthClient.AuthException) {
println("NEGATIV OK: ${e.message}")
}
}
}

6
build.gradle.kts Normal file
View file

@ -0,0 +1,6 @@
plugins {
id("com.android.application") version "8.7.3" apply false
kotlin("android") version "2.0.21" apply false
kotlin("plugin.compose") version "2.0.21" apply false
kotlin("plugin.serialization") version "2.0.21" apply false
}

6
gradle.properties Normal file
View file

@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
org.gradle.caching=true
org.gradle.parallel=true
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

252
gradlew vendored Executable file
View file

@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View file

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

16
settings.gradle.kts Normal file
View file

@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "hermes-conduit-android"
include(":app")