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