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:
commit
75e0f25def
26 changed files with 2510 additions and 0 deletions
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal 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
116
README.md
Normal 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
74
app/build.gradle.kts
Normal 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")
|
||||
}
|
||||
24
app/src/main/AndroidManifest.xml
Normal file
24
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
330
app/src/main/java/de/derittyp/hermesconduit/AppViewModel.kt
Normal file
330
app/src/main/java/de/derittyp/hermesconduit/AppViewModel.kt
Normal 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() }
|
||||
|
|
@ -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()
|
||||
}
|
||||
116
app/src/main/java/de/derittyp/hermesconduit/MainActivity.kt
Normal file
116
app/src/main/java/de/derittyp/hermesconduit/MainActivity.kt
Normal 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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
117
app/src/main/java/de/derittyp/hermesconduit/core/AuthClient.kt
Normal file
117
app/src/main/java/de/derittyp/hermesconduit/core/AuthClient.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
366
app/src/main/java/de/derittyp/hermesconduit/core/HermesClient.kt
Normal file
366
app/src/main/java/de/derittyp/hermesconduit/core/HermesClient.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
67
app/src/main/java/de/derittyp/hermesconduit/core/Json.kt
Normal file
67
app/src/main/java/de/derittyp/hermesconduit/core/Json.kt
Normal 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
|
||||
}
|
||||
}
|
||||
52
app/src/main/java/de/derittyp/hermesconduit/core/Models.kt
Normal file
52
app/src/main/java/de/derittyp/hermesconduit/core/Models.kt
Normal 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")
|
||||
240
app/src/main/java/de/derittyp/hermesconduit/ui/ChatScreen.kt
Normal file
240
app/src/main/java/de/derittyp/hermesconduit/ui/ChatScreen.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
124
app/src/main/java/de/derittyp/hermesconduit/ui/LoginScreen.kt
Normal file
124
app/src/main/java/de/derittyp/hermesconduit/ui/LoginScreen.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
193
app/src/main/java/de/derittyp/hermesconduit/ui/SessionsScreen.kt
Normal file
193
app/src/main/java/de/derittyp/hermesconduit/ui/SessionsScreen.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
12
app/src/main/res/drawable/ic_launcher.xml
Normal file
12
app/src/main/res/drawable/ic_launcher.xml
Normal 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>
|
||||
6
app/src/main/res/values/themes.xml
Normal file
6
app/src/main/res/values/themes.xml
Normal 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>
|
||||
|
|
@ -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
6
build.gradle.kts
Normal 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
6
gradle.properties
Normal 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
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
252
gradlew
vendored
Executable 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
94
gradlew.bat
vendored
Normal 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
16
settings.gradle.kts
Normal 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")
|
||||
Loading…
Reference in a new issue