Initial: keryx-bff project files
This commit is contained in:
parent
f2544cca15
commit
5b37b5982d
2 changed files with 1528 additions and 0 deletions
898
DESIGN.md
Normal file
898
DESIGN.md
Normal file
|
|
@ -0,0 +1,898 @@
|
|||
# Keryx — iOS/iPad Hermes Client Design Document
|
||||
|
||||
**Project:** Keryx (iOS/iPad native Hermes client)
|
||||
**BFF:** Keryx BFF (`keryx-bff`, Python FastAPI + Strawberry GraphQL)
|
||||
**Status:** v3 — iOS app refined ✅
|
||||
**Date:** 2026-07-03
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#1-overview)
|
||||
2. [Architecture](#2-architecture)
|
||||
3. [iOS App](#3-ios-app)
|
||||
4. [BFF (Backend For Frontend)](#4-bff-backend-for-frontend)
|
||||
5. [API Contract](#5-api-contract)
|
||||
6. [Data Storage](#6-data-storage)
|
||||
7. [Security](#7-security)
|
||||
8. [Deployment](#8-deployment)
|
||||
9. [MVP Feature Matrix](#9-mvp-feature-matrix)
|
||||
10. [Post-MVP Roadmap](#10-post-mvp-roadmap)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Keryx is a native iOS/iPadOS app that replaces the Hermes WebUI. It connects to a user's self-hosted Hermes Agent instance via a Python BFF service. The BFF owns the app experience — it provides page-shaped responses (feed of UI components) rather than raw data. The iOS app is a native component renderer using TCA for state management.
|
||||
|
||||
### Key Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Minimum iOS version | **iOS 20 beta** | Strict Swift 6 concurrency, latest SwiftUI, on-device ML |
|
||||
| App architecture | **TCA (The Composable Architecture)** | Central store for multi-session state, SSE event→action mapping, testability |
|
||||
| App state management | **Feature-scoped reducers** compose into `AppReducer` | Each screen independent, parent routes navigation |
|
||||
| GraphQL client | **None — raw `URLSession` + `Codable`** | No Apollo overhead; AI generates DTOs from schema |
|
||||
| SSE streaming | `Effect.run` + `for try await` | Idiomatic TCA pattern, native URLSession bytes |
|
||||
| SSE parser | **Inline, ~50 lines** | Zero dependencies, no external package |
|
||||
| Page rendering | **Per-screen `ForEach`** | Each module owns its rendering; no shared FeedView switch |
|
||||
| UI models | **DTO → Mapper → Canonical per module** | Schema changes only touch DTO + Mapper, View never references DTO |
|
||||
| BFF language | **Python (FastAPI + Strawberry)** | Same language as Hermes, GraphQL for version-safe queries |
|
||||
| API style | **Hybrid GraphQL + SSE** | GraphQL for page data, SSE for chat streaming |
|
||||
| Chat protocol | **Hermes `/api/sessions/{id}/chat/stream`** | Native session-managed streaming |
|
||||
| SSE events | `component`, `chunk`, `component_patch`, `component_complete`, `done` | Maps 1:1 to feed array mutations |
|
||||
| Streaming transport | **SSE** | Works through proxies, native URLSession support |
|
||||
| Auth | **Bearer token in iOS Keychain** | Matches Hermes API Server auth model |
|
||||
| Connection setup | **Native SwiftUI screen + QR code scan** | VisionKit camera, QR encodes URL + API key |
|
||||
| QR generation | **`scripts/generate-setup-qr.py`** in BFF repo | Run by user or Hermes agent on the server |
|
||||
| Deep links | **Internal `keryx://` only** | All links generated by BFF; external link mapping is post-MVP |
|
||||
| Pagination | **None — load full lists** | 82 sessions currently; ~3 MB even for 10,000 sessions |
|
||||
| Deploy model | **systemd user service** | Matches Hermes gateway deployment pattern |
|
||||
| Dependencies | **uv** for Python, **loguru** for logging | Modern, fast, clean |
|
||||
| File staging cleanup | **Lifespan scheduled task** — daily, delete files >30 days | Automatic |
|
||||
| Conversation mappings | **Never deleted** | Simpler lifecycle, Hermes manages sessions |
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ GraphQL + SSE ┌─────────────────┐ HTTP ┌─────────────────┐
|
||||
│ │──────────────────→│ │──────────────→│ │
|
||||
│ iOS / iPad │ app_data, pages │ Keryx BFF │ Hermes API │ Hermes Agent │
|
||||
│ (SwiftUI) │←──────────────────│ (FastAPI) │←──────────────│ (API Server) │
|
||||
│ + TCA │ SSE feed stream │ Strawberry GQL │ SSE stream │ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│
|
||||
│ filesystem
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Workspace dir │
|
||||
│ (read-only) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### Key Design Principles
|
||||
|
||||
- **The BFF owns the app experience** — titles, labels, layout, navigation, empty states all come from the server
|
||||
- **The iOS app is a component renderer** — it receives typed UI components (`session_card`, `message_bubble`, `tool_call_card`, etc.) and renders them natively
|
||||
- **Version-safe via GraphQL** — old apps never query for component types they can't render
|
||||
- **Feed array pattern** — every page is `{header, items[], footer}` where `items` is a heterogeneous array of typed components
|
||||
- **Canonical UI models per module** — DTOs from GraphQL are mapped to module-local canonical types; schema changes only affect DTO + Mapper
|
||||
|
||||
---
|
||||
|
||||
## 3. iOS App
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Architecture | **TCA** (`swift-composable-architecture`, `swift-dependencies`) |
|
||||
| UI Framework | **SwiftUI** (iOS 20 SDK) |
|
||||
| Navigation | `NavigationSplitView` (adaptive: iPhone push, iPad multi-column) |
|
||||
| Networking | **Raw `URLSession`** + `Codable` for GraphQL (no Apollo) |
|
||||
| SSE reader | **Inline parser** (~50 lines), `URLSession.bytes` async sequence |
|
||||
| State management | **Feature-scoped `@Reducer`** composable in `AppReducer` |
|
||||
| Local storage | `Keychain` (credentials), `UserDefaults` (preferences) |
|
||||
| Audio | `AVSpeechRecognizer` (on-device STT) |
|
||||
| Camera / QR | `VisionKit.DataScannerViewController` (iOS 20 native) |
|
||||
| File picker | `UIDocumentPickerViewController` / `FileDocument` |
|
||||
| Drag & drop | `DropDelegate` on compose area (iPad) |
|
||||
| Markdown rendering | Custom renderer per component type |
|
||||
| Deep links | `keryx://` URL scheme, parsed in `AppReducer` |
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
Keryx/
|
||||
├── KeryxApp.swift # @main, store init
|
||||
├── App/
|
||||
│ ├── Reducers/
|
||||
│ │ └── AppReducer.swift # Root reducer, navigation, deep links
|
||||
│ ├── Views/
|
||||
│ │ ├── MainSplitView.swift # NavigationSplitView + tab routing
|
||||
│ │ └── DeepLinkHandler.swift # keryx:// URL parsing + dispatch
|
||||
│ └── Models/
|
||||
│ └── AppState.swift # Root app state, composed from modules
|
||||
│
|
||||
├── Shared/ # Cross-cutting, no screen-specific logic
|
||||
│ ├── Networking/
|
||||
│ │ ├── BFFClient.swift # URLSession + GraphQL query runner
|
||||
│ │ ├── SSEReader.swift # ~50 line SSE parser
|
||||
│ │ ├── AuthMiddleware.swift # Bearer token injection
|
||||
│ │ └── DTOs/ # Raw GraphQL response types
|
||||
│ │ ├── AppDataDTO.swift
|
||||
│ │ ├── PageDTO.swift
|
||||
│ │ └── SessionDTO.swift
|
||||
│ ├── Models/
|
||||
│ │ ├── FeedComponent.swift # Canonical UI enum (shared by all modules)
|
||||
│ │ ├── DeepLink.swift # Parsed deep link enum
|
||||
│ │ └── AppData.swift # Canonical app data model
|
||||
│ ├── Services/
|
||||
│ │ ├── KeychainService.swift
|
||||
│ │ └── AudioService.swift
|
||||
│ └── Views/
|
||||
│ ├── CodeBlockView.swift
|
||||
│ ├── EmptyStateView.swift
|
||||
│ └── ErrorBannerView.swift
|
||||
│
|
||||
├── Sessions/ # Module: sessions list
|
||||
│ ├── Reducers/
|
||||
│ │ └── SessionsListReducer.swift
|
||||
│ ├── Views/
|
||||
│ │ └── SessionCardView.swift # Renders session_card component
|
||||
│ ├── Models/
|
||||
│ │ └── SessionCard.swift # Canonical UI model
|
||||
│ └── Services/
|
||||
│ └── SessionsMapper.swift # DTO → SessionCard
|
||||
│
|
||||
├── Chat/ # Module: chat view
|
||||
│ ├── Reducers/
|
||||
│ │ └── ChatReducer.swift # SSE event → feed state
|
||||
│ ├── Views/
|
||||
│ │ ├── ChatView.swift # ForEach over feed, includes compose bar
|
||||
│ │ ├── MessageBubbleView.swift
|
||||
│ │ ├── ThinkingBlockView.swift
|
||||
│ │ ├── ToolCallCardView.swift
|
||||
│ │ └── ComposeBarView.swift
|
||||
│ ├── Models/
|
||||
│ │ ├── ChatState.swift # Canonical chat state
|
||||
│ │ └── SSEEvent.swift # Parsed SSE event (component, chunk, etc.)
|
||||
│ └── Services/
|
||||
│ └── ChatMapper.swift # SSE event → FeedComponent
|
||||
│
|
||||
├── Workspace/ # Module: file browser
|
||||
│ ├── Reducers/
|
||||
│ │ └── WorkspaceReducer.swift
|
||||
│ ├── Views/
|
||||
│ │ └── FileRowView.swift # Renders file_row component
|
||||
│ ├── Models/
|
||||
│ │ └── FileNode.swift # Canonical file model
|
||||
│ └── Services/
|
||||
│ └── WorkspaceMapper.swift # DTO → FileNode
|
||||
│
|
||||
├── Settings/ # Module: connection config
|
||||
│ ├── Reducers/
|
||||
│ │ └── SettingsReducer.swift
|
||||
│ ├── Views/
|
||||
│ │ ├── SetupView.swift # First-launch connection screen
|
||||
│ │ └── QRScannerView.swift # VisionKit camera scanner
|
||||
│ └── Models/
|
||||
│ └── ConnectionConfig.swift # Canonical config model
|
||||
│
|
||||
└── Resources/
|
||||
├── Assets.xcassets
|
||||
└── Info.plist
|
||||
```
|
||||
|
||||
### Module Rules
|
||||
|
||||
| Rule | Why |
|
||||
|---|---|
|
||||
| `Shared/Networking/DTOs/` contain raw GraphQL types | Single point of change when schema evolves |
|
||||
| Each module has own `Models/` with canonical types | No module imports another module's models |
|
||||
| Each module has `Services/*Mapper.swift` | Mapping DTO → canonical model is explicit and testable |
|
||||
| `FeedComponent.swift` lives in `Shared/Models/` | All modules produce feed items — one canonical enum |
|
||||
| Mapper never touches a View | Separation of concerns |
|
||||
| Reducer only operates on canonical models | Schema change = DTO + Mapper update only, reducer tests stay green |
|
||||
| Per-screen `ForEach` loops | Each screen owns rendering independently; no shared `FeedView` |
|
||||
|
||||
### TCA Architecture
|
||||
|
||||
The `AppReducer` composes feature-scoped reducers and handles navigation via `StackState`:
|
||||
|
||||
```swift
|
||||
@Reducer
|
||||
struct AppReducer {
|
||||
@ObservableState
|
||||
struct State: Equatable {
|
||||
var path = StackState<Path.State>()
|
||||
var sessionsList = SessionsListReducer.State()
|
||||
var settings = SettingsReducer.State()
|
||||
}
|
||||
|
||||
enum Action {
|
||||
case path(StackActionOf<Path>)
|
||||
case sessionsList(SessionsListReducer.Action)
|
||||
case settings(SettingsReducer.Action)
|
||||
case deepLink(URL)
|
||||
}
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Scope(state: \.sessionsList, action: \.sessionsList) {
|
||||
SessionsListReducer()
|
||||
}
|
||||
Scope(state: \.settings, action: \.settings) {
|
||||
SettingsReducer()
|
||||
}
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .deepLink(let url):
|
||||
// Parse keryx:// → route navigation or dispatch action
|
||||
return .none
|
||||
// ...
|
||||
}
|
||||
}
|
||||
.forEach(\.path, action: \.path)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each feature reducer (e.g. `ChatReducer`) receives SSE events through TCA's `Effect.run`:
|
||||
|
||||
```swift
|
||||
case .sendMessage(let text):
|
||||
return .run { [sessionId = state.sessionId] send in
|
||||
let stream = try await bffClient.streamChat(sessionId, text)
|
||||
for try await event in stream {
|
||||
switch event {
|
||||
case .component(let type, let id, let data):
|
||||
await send(.feedEvent(.componentReceived(type, id, data)))
|
||||
case .chunk(let componentId, let text):
|
||||
await send(.feedEvent(.chunkReceived(componentId, text)))
|
||||
case .componentPatch(let id, let data):
|
||||
await send(.feedEvent(.patchReceived(id, data)))
|
||||
case .componentComplete(let id, let usage):
|
||||
await send(.feedEvent(.completeReceived(id, usage)))
|
||||
case .done:
|
||||
await send(.feedEvent(.streamComplete))
|
||||
case .error(let code, let message):
|
||||
await send(.feedEvent(.streamError(code, message)))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Connection Setup Flow
|
||||
|
||||
**First launch:**
|
||||
```
|
||||
1. App launches → no stored credentials → show SetupView (native SwiftUI)
|
||||
2. User enters BFF URL + API Key manually, OR taps "Scan QR"
|
||||
3. QR scan: VisionKit camera reads keryx:// URL → decodes JSON → fills fields
|
||||
4. Tap "Connect" → POST to BFF /v1/ios/health to validate
|
||||
5. Success → save to Keychain → fetch app_data → render main app
|
||||
6. Failure → show error, fields stay filled, user can retry
|
||||
```
|
||||
|
||||
**Subsequent launches & foregrounds:**
|
||||
```
|
||||
1. Read credentials from Keychain
|
||||
2. Fetch /v1/ios/app_data
|
||||
3. If 401 → show SetupView (credentials expired)
|
||||
4. If 200 → render main app from app_data
|
||||
```
|
||||
|
||||
SetupView is the only hardcoded screen in the app. Every other screen comes from BFF endpoints.
|
||||
|
||||
### Deep Link Routing
|
||||
|
||||
All navigation is driven by `keryx://` deep links. Links come from BFF responses (`header.actions`, component `actions`, `footer`). The `AppReducer` parses and routes them:
|
||||
|
||||
```swift
|
||||
case .deepLink(let url):
|
||||
switch url.toDeepLink() {
|
||||
case .chat(let id):
|
||||
state.path.append(.chat(ChatReducer.State(sessionId: id)))
|
||||
case .deleteSession(let id):
|
||||
return .send(.sessionsList(.deleteSession(id)))
|
||||
case .newChat:
|
||||
return .run { send in
|
||||
let session = try await bffClient.createSession()
|
||||
await send(.deepLink(URL(string: "keryx://chat/\(session.id)")!))
|
||||
}
|
||||
case .sessions:
|
||||
state.path.removeAll()
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Deep link registry (see `DOMAIN.md` for full list): `keryx://sessions`, `keryx://new-chat`, `keryx://chat/{id}`, `keryx://delete/{id}`, `keryx://rename/{id}`, `keryx://workspace`, `keryx://workspace/{path}`, `keryx://settings`.
|
||||
|
||||
### Per-Screen Rendering
|
||||
|
||||
Each screen module renders its own feed via a direct `ForEach` loop — no shared `FeedView` switch:
|
||||
|
||||
```swift
|
||||
// SessionsListView (in Sessions/Views/)
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(store.items, id: \.id) { component in
|
||||
SessionCardView(component: component)
|
||||
}
|
||||
if let emptyState = store.emptyState {
|
||||
EmptyStateView(emptyState: emptyState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ChatView (in Chat/Views/)
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(store.feed, id: \.id) { component in
|
||||
switch component.type {
|
||||
case .messageBubble:
|
||||
MessageBubbleView(component: component)
|
||||
case .thinkingBlock:
|
||||
ThinkingBlockView(component: component)
|
||||
case .toolCallCard:
|
||||
ToolCallCardView(component: component)
|
||||
case .codeBlock:
|
||||
CodeBlockView(component: component)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
ComposeBarView(...)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Unknown component types are silently skipped via `default: EmptyView()`.
|
||||
|
||||
### SSE Reader (Inline Parser)
|
||||
|
||||
The SSE parser is ~50 lines, lives in `Shared/Networking/SSEReader.swift`:
|
||||
|
||||
```swift
|
||||
struct SSEEvent: Equatable {
|
||||
enum Name: String {
|
||||
case component, chunk, componentPatch = "component_patch",
|
||||
componentComplete = "component_complete", done, error
|
||||
}
|
||||
let name: Name
|
||||
let data: Data
|
||||
}
|
||||
|
||||
extension AsyncSequence where Element == UInt8 {
|
||||
func sseEvents() -> AsyncStream<SSEEvent> {
|
||||
AsyncStream { continuation in
|
||||
// Split on "\n\n", parse "event:" and "data:" lines
|
||||
// Decode data JSON
|
||||
// continuation.yield(SSEEvent(name: ..., data: ...))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Usage in `ChatReducer`:
|
||||
|
||||
```swift
|
||||
let stream = try await session.bytes(for: request)
|
||||
.sseEvents()
|
||||
for try await event in stream { ... }
|
||||
```
|
||||
|
||||
### Key Screens
|
||||
|
||||
| Screen | Source | Component Types | Module |
|
||||
|---|---|---|---|
|
||||
| **Connection Setup** | First launch (native) | N/A — hardcoded fields + QR scanner | `Settings/` |
|
||||
| **Sessions List** | `app_data` → `page(id: "sessions")` | `session_card` | `Sessions/` |
|
||||
| **Chat View** | `keryx://chat/{id}` deep link | `message_bubble`, `thinking_block`, `tool_call_card`, `code_block` + `compose_bar` footer | `Chat/` |
|
||||
| **Workspace Files** | Tab link → `page(id: "workspace")` | `file_row` | `Workspace/` |
|
||||
| **Settings** | Tab link | Server-driven (post-MVP native) | `Settings/` |
|
||||
|
||||
---
|
||||
|
||||
## 4. BFF (Backend For Frontend)
|
||||
|
||||
### Identity
|
||||
|
||||
- **Name:** `keryx-bff`
|
||||
- **Stack:** Python 3.12+, FastAPI + Strawberry GraphQL + uvicorn
|
||||
- **Config file:** `~/.keryx/config.yaml`
|
||||
- **Service file:** `~/.config/systemd/user/keryx-bff.service`
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
keryx-bff/
|
||||
├── pyproject.toml
|
||||
├── README.md
|
||||
├── scripts/
|
||||
│ └── generate-setup-qr.py # Reads config.yaml → QR code PNG
|
||||
├── src/
|
||||
│ ├── server.py # FastAPI app, lifespan, router/GraphQL includes
|
||||
│ ├── core/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── config.py # Pydantic config model (load YAML)
|
||||
│ │ ├── auth.py # Bearer token middleware
|
||||
│ │ ├── lifespan.py # App startup/shutdown lifecycle
|
||||
│ │ ├── db.py # SQLite for keryx.db (WAL mode)
|
||||
│ │ └── exceptions.py # Global exception handlers
|
||||
│ ├── models/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── graphql.py # Strawberry schema: Query, Mutation
|
||||
│ │ ├── app_data.py # AppData, Navigation, Tab, FeatureFlags
|
||||
│ │ ├── page.py # Page, Header, Footer, FeedComponent union
|
||||
│ │ ├── components.py # SessionCard, MessageBubble, ToolCallCard, etc.
|
||||
│ │ ├── chat.py # Chat request + SSE event models
|
||||
│ │ ├── workspace.py # FileNode, FileContent
|
||||
│ │ └── health.py # HealthResponse
|
||||
│ ├── routes/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── app_data.py # GET /v1/ios/app_data
|
||||
│ │ ├── chat_stream.py # POST /v1/ios/chat/{id}/stream (SSE)
|
||||
│ │ ├── attach.py # POST /v1/ios/chat/{id}/attach
|
||||
│ │ └── workspace.py # GET /v1/ios/workspace/*
|
||||
│ ├── services/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── hermes_client.py # httpx async client for Hermes API
|
||||
│ │ ├── page_builder.py # Build feed arrays from Hermes data
|
||||
│ │ └── staging.py # File upload staging + cleanup schedule
|
||||
│ └── lib/
|
||||
│ ├── __init__.py
|
||||
│ └── deep_link.py # Link builder helpers
|
||||
└── tests/
|
||||
├── conftest.py
|
||||
├── test_app_data.py
|
||||
├── test_chat_stream.py
|
||||
├── test_attach.py
|
||||
├── test_workspace.py
|
||||
└── test_graphql.py
|
||||
```
|
||||
|
||||
### Dependencies (`pyproject.toml`)
|
||||
|
||||
```toml
|
||||
[project]
|
||||
name = "keryx-bff"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"strawberry-graphql>=0.260",
|
||||
"strawberry-graphql[fastapi]",
|
||||
"uvicorn[standard]",
|
||||
"pydantic>=2.0",
|
||||
"pydantic-settings",
|
||||
"httpx>=0.28",
|
||||
"python-multipart",
|
||||
"pyyaml",
|
||||
"loguru",
|
||||
"aiosqlite",
|
||||
"qrcode", # for generate-setup-qr.py
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
dev-dependencies = [
|
||||
"pytest>=8",
|
||||
"pytest-asyncio",
|
||||
"httpx",
|
||||
]
|
||||
```
|
||||
|
||||
### Config (`~/.keryx/config.yaml`)
|
||||
|
||||
```yaml
|
||||
app:
|
||||
name: "Keryx"
|
||||
version: "0.1.0"
|
||||
supported_app_versions: "^1.0.0"
|
||||
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8643
|
||||
api_key: "keryx-bff-change-me"
|
||||
|
||||
hermes:
|
||||
api_url: "http://127.0.0.1:8642"
|
||||
api_key: "hermes-api-server-key"
|
||||
workspace_path: "/home/david/workspace"
|
||||
|
||||
bff:
|
||||
staging_dir: "/tmp/keryx"
|
||||
db_path: "/home/david/.keryx/keryx.db"
|
||||
max_upload_size_mb: 50
|
||||
staging_cleanup_days: 30
|
||||
```
|
||||
|
||||
### BFF SQLite DB (`keryx.db`)
|
||||
|
||||
```sql
|
||||
-- Minimal. Only for push tokens (post-MVP).
|
||||
PRAGMA journal_mode=WAL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_token TEXT NOT NULL,
|
||||
device_name TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
last_seen_at REAL NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### GraphQL Schema (Strawberry)
|
||||
|
||||
```graphql
|
||||
type Query {
|
||||
appData: AppData!
|
||||
page(id: ID!, sessionId: ID): Page!
|
||||
session(id: ID!): SessionInfo
|
||||
}
|
||||
|
||||
type AppData {
|
||||
app: AppInfo!
|
||||
navigation: Navigation!
|
||||
defaults: Defaults!
|
||||
features: FeatureFlags!
|
||||
}
|
||||
|
||||
type AppInfo {
|
||||
name: String!
|
||||
version: String!
|
||||
minSupportedVersion: String!
|
||||
supportedVersions: String!
|
||||
forceUpdateUrl: String
|
||||
}
|
||||
|
||||
type Navigation {
|
||||
primaryTab: Tab!
|
||||
tabs: [Tab!]!
|
||||
}
|
||||
|
||||
type Tab {
|
||||
id: String!
|
||||
icon: String!
|
||||
label: String!
|
||||
link: String!
|
||||
}
|
||||
|
||||
type Page {
|
||||
id: String!
|
||||
header: Header
|
||||
emptyState: EmptyState
|
||||
items: [FeedComponent!]!
|
||||
footer: Footer # only compose_bar or null
|
||||
}
|
||||
|
||||
type Header {
|
||||
title: String
|
||||
subtitle: String
|
||||
actions: [LinkAction!]
|
||||
}
|
||||
|
||||
type EmptyState {
|
||||
icon: String
|
||||
title: String!
|
||||
subtitle: String
|
||||
action: LinkAction
|
||||
}
|
||||
|
||||
union FeedComponent = SessionCard | MessageBubble | ThinkingBlock
|
||||
| ToolCallCard | FileRow | CodeBlock
|
||||
| Divider | ComposeBar
|
||||
|
||||
union Footer = ComposeBarLoader
|
||||
|
||||
type SessionCard {
|
||||
id: String!
|
||||
title: String
|
||||
modelBadge: String
|
||||
messageCount: Int!
|
||||
timestamp: String!
|
||||
preview: String
|
||||
actions: [Action!]!
|
||||
}
|
||||
|
||||
type MessageBubble {
|
||||
id: String!
|
||||
role: MessageRole!
|
||||
text: String
|
||||
streaming: Boolean!
|
||||
timestamp: Float
|
||||
attachments: [Attachment!]
|
||||
}
|
||||
|
||||
# ... etc for each component type
|
||||
```
|
||||
|
||||
**Note:** No `cursor` or `limit` parameters — all lists load in full.
|
||||
|
||||
### QR Code Generation (`scripts/generate-setup-qr.py`)
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a QR code for Keryx setup.
|
||||
|
||||
Reads the BFF config and outputs a QR code PNG that the iOS app
|
||||
can scan from the SetupView to auto-configure the connection.
|
||||
|
||||
Usage:
|
||||
python scripts/generate-setup-qr.py [--output setup.png]
|
||||
"""
|
||||
import json, qrcode, yaml
|
||||
from pathlib import Path
|
||||
|
||||
config_path = Path.home() / ".keryx" / "config.yaml"
|
||||
config = yaml.safe_load(config_path.read_text())
|
||||
|
||||
payload = {
|
||||
"v": 1,
|
||||
"url": f"https://{config['server']['host']}:{config['server']['port']}",
|
||||
"key": config["server"]["api_key"],
|
||||
}
|
||||
|
||||
img = qrcode.make(json.dumps(payload))
|
||||
img.save("keryx-setup.png")
|
||||
print("QR code saved to keryx-setup.png")
|
||||
```
|
||||
|
||||
### Build Phases
|
||||
|
||||
| Phase | Modules | Milestone |
|
||||
|---|---|---|
|
||||
| **1. Core** | `config.py`, `auth.py`, `lifespan.py`, `server.py` | BFF starts, responds to health |
|
||||
| **2. app_data** | `app_data.py` route + `graphql.py` Query | iOS app fetches startup config |
|
||||
| **3. GraphQL pages** | `page_builder.py`, component models, sessions | Sessions list renders via GraphQL |
|
||||
| **4. SSE streaming** | `chat_stream.py`, `hermes_client.py` | Chat works with streaming components |
|
||||
| **5. File ops** | `attach.py`, `staging.py`, workspace | File upload + workspace browsing |
|
||||
| **6. Polish** | Error handling, logging, tests, systemd, QR script | Production-ready |
|
||||
|
||||
---
|
||||
|
||||
## 5. API Contract
|
||||
|
||||
### `GET /v1/ios/app_data`
|
||||
|
||||
Called on every app launch and foreground. Returns the full startup config.
|
||||
|
||||
### `POST /v1/ios/chat/{session_id}/stream`
|
||||
|
||||
Sends a message and returns an SSE stream of feed component events.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"message": "What files are in my project?",
|
||||
"model": "deepseek-v4-flash",
|
||||
"attachments": [
|
||||
{"staging_path": "/tmp/keryx/abc123/report.pdf", "name": "report.pdf"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**SSE Response (stream):**
|
||||
```
|
||||
event: component
|
||||
data: {"type": "message_bubble", "id": "msg_1", "data": {"role": "user", "text": "What files are in my project?"}}
|
||||
|
||||
event: component
|
||||
data: {"type": "tool_call_card", "id": "tc_1", "data": {"tool": "terminal", "command": "ls -la", "status": "running"}, "streaming": true}
|
||||
|
||||
event: component_patch
|
||||
data: {"id": "tc_1", "data": {"status": "completed", "output": "src/\ntests/\nREADME.md"}}
|
||||
|
||||
event: component
|
||||
data: {"type": "message_bubble", "id": "msg_2", "data": {"role": "assistant", "text": "", "code_blocks": []}, "streaming": true}
|
||||
|
||||
event: chunk
|
||||
data: {"component_id": "msg_2", "text": "Your project"}
|
||||
|
||||
event: chunk
|
||||
data: {"component_id": "msg_2", "text": " contains a FastAPI"}
|
||||
|
||||
event: chunk
|
||||
data: {"component_id": "msg_2", "text": " server."}
|
||||
|
||||
event: component_complete
|
||||
data: {"id": "msg_2", "usage": {"input_tokens": 16267, "output_tokens": 20}}
|
||||
|
||||
event: done
|
||||
data: {}
|
||||
```
|
||||
|
||||
### `POST /v1/ios/chat/{session_id}/attach`
|
||||
|
||||
Upload a file for the chat session.
|
||||
|
||||
**Request:** `multipart/form-data` — field `file`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"staging_path": "/tmp/keryx/abc123/report.pdf",
|
||||
"name": "report.pdf",
|
||||
"size": 245760,
|
||||
"mime_type": "application/pdf"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Data Storage
|
||||
|
||||
### iOS Device (Keychain)
|
||||
|
||||
| Item | Key |
|
||||
|---|---|
|
||||
| BFF URL | `keryx_bff_url` |
|
||||
| API Key | `keryx_api_key` |
|
||||
|
||||
### iOS Device (UserDefaults)
|
||||
|
||||
| Key | Type | Default |
|
||||
|---|---|---|
|
||||
| `last_session_id` | String | nil |
|
||||
| `last_active_model` | String | nil (use BFF default) |
|
||||
|
||||
### BFF SQLite (`keryx.db`)
|
||||
|
||||
Just `push_tokens` (post-MVP).
|
||||
|
||||
---
|
||||
|
||||
## 7. Security
|
||||
|
||||
| Concern | Measure |
|
||||
|---|---|
|
||||
| Auth in transit | HTTPS required. HTTP returns 426 Upgrade Required. |
|
||||
| Auth at rest | API key in iOS Keychain (Secure Enclave) |
|
||||
| BFF auth | Bearer token middleware on all endpoints |
|
||||
| File upload safety | MIME validation, 50 MB cap, `..` path traversal blocked |
|
||||
| Workspace safety | `..` / absolute paths blocked. Reads under configured root only |
|
||||
| Hermes API key | Stored in BFF config, never exposed to iOS app |
|
||||
| GraphQL depth limiting | Max query depth enforced (default: 5 levels) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Deployment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Hermes Agent with API Server enabled (`API_SERVER_ENABLED=true`)
|
||||
- Python 3.12+
|
||||
- `uv`
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Create project
|
||||
mkdir -p ~/apps/keryx-bff && cd ~/apps/keryx-bff
|
||||
|
||||
# Install deps
|
||||
uv sync
|
||||
|
||||
# Create config
|
||||
mkdir -p ~/.keryx
|
||||
# ... write config.yaml ...
|
||||
|
||||
# Create staging dir
|
||||
mkdir -p /tmp/keryx
|
||||
|
||||
# Generate QR code (optional — for iOS app setup)
|
||||
python scripts/generate-setup-qr.py
|
||||
```
|
||||
|
||||
### systemd Service (`~/.config/systemd/user/keryx-bff.service`)
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Keryx BFF – Hermes iOS/iPad Backend For Frontend
|
||||
After=hermes-gateway.service
|
||||
BindsTo=hermes-gateway.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%h/apps/keryx-bff/.venv/bin/uvicorn src.server:app --host 0.0.0.0 --port 8643
|
||||
WorkingDirectory=%h/apps/keryx-bff
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=KERYX_CONFIG=%h/.keryx/config.yaml
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now keryx-bff
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. MVP Feature Matrix
|
||||
|
||||
### BFF (Python / FastAPI)
|
||||
|
||||
| Feature | Status |
|
||||
|---|---|
|
||||
| `GET /v1/ios/app_data` on startup/foreground | ✅ |
|
||||
| GraphQL `Query.appData` | ✅ |
|
||||
| GraphQL `Query.page` for sessions (full list, no pagination) | ✅ |
|
||||
| GraphQL `Query.page` for workspace (full tree, no pagination) | ✅ |
|
||||
| GraphQL `Query.page` for chat (historical messages) | ✅ |
|
||||
| SSE streaming via `POST /v1/ios/chat/{id}/stream` | ✅ |
|
||||
| SSE events: `component`, `chunk`, `component_patch`, `component_complete`, `done` | ✅ |
|
||||
| Feed component: `session_card` | ✅ |
|
||||
| Feed component: `message_bubble` | ✅ |
|
||||
| Feed component: `thinking_block` | ✅ |
|
||||
| Feed component: `tool_call_card` | ✅ |
|
||||
| Feed component: `file_row` | ✅ |
|
||||
| Feed component: `code_block` | ✅ |
|
||||
| Feed component: `divider` | ✅ |
|
||||
| Feed component: `compose_bar` | ✅ |
|
||||
| Footer type: `compose_bar` | ✅ |
|
||||
| File attach upload (multipart, staging, MIME validation, 50 MB cap) | ✅ |
|
||||
| Workspace file tree (full, no pagination) | ✅ |
|
||||
| Workspace file read (`..` traversal blocked) | ✅ |
|
||||
| File staging 30-day cleanup (lifespan scheduled task) | ✅ |
|
||||
| Bearer auth middleware | ✅ |
|
||||
| Hermes API health check on startup | ✅ |
|
||||
| `scripts/generate-setup-qr.py` | ✅ |
|
||||
| loguru logging | ✅ |
|
||||
| uv dependency management | ✅ |
|
||||
| systemd service deployment | ✅ |
|
||||
| Tests: health, app_data, chat stream, attach, workspace, GraphQL | ✅ |
|
||||
|
||||
### iOS App (Swift / TCA)
|
||||
|
||||
| Feature | Status |
|
||||
|---|---|
|
||||
| TCA architecture with feature-scoped reducers | ✅ |
|
||||
| Modular project structure (Shared, Sessions, Chat, Workspace, Settings) | ✅ |
|
||||
| DTO → Mapper → Canonical pipeline per module | ✅ |
|
||||
| Per-screen `ForEach` rendering (no shared FeedView) | ✅ |
|
||||
| Inline SSE parser (~50 lines) | ✅ |
|
||||
| SSE → TCA via `Effect.run` + `for try await` | ✅ |
|
||||
| `keryx://` deep link routing through `AppReducer` | ✅ |
|
||||
| Connection setup screen (native SwiftUI, URL + API key) | ✅ |
|
||||
| QR code scanner (VisionKit DataScannerViewController) | ✅ |
|
||||
| Keychain credential storage | ✅ |
|
||||
| Bearer token injection on all requests | ✅ |
|
||||
| Sessions list (fetch full, render session_cards) | ✅ |
|
||||
| Chat view: send message, receive SSE stream, render feed | ✅ |
|
||||
| Chat view: append component, chunk text, patch metadata, complete | ✅ |
|
||||
| Chat view: `compose_bar` in footer | ✅ |
|
||||
| File attach (picker + upload) | ✅ |
|
||||
| iPad drag & drop on compose area | ✅ |
|
||||
| Voice input (on-device AVSpeechRecognizer) | ✅ |
|
||||
| Model selector in chat (list from BFF) | ✅ |
|
||||
| Workspace file browser (full tree, tap to preview) | ✅ |
|
||||
| NavigationSplitView (iPad multi-column, iPhone push) | ✅ |
|
||||
| Pull-to-refresh sessions list | ✅ |
|
||||
| Swipe to delete sessions | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 10. Post-MVP Roadmap
|
||||
|
||||
- Push notifications (APNs)
|
||||
- Multiple Hermes server profiles
|
||||
- Message search within sessions
|
||||
- Message editing / deletion
|
||||
- Image preview inline in chat
|
||||
- Offline message queue
|
||||
- Dark mode testing
|
||||
- Share extension (share URLs/files from other apps)
|
||||
- Keyboard shortcuts (iPad)
|
||||
- Multi-window (Stage Manager)
|
||||
- Local model fallback (on-device ML)
|
||||
- External deep link mapping (e.g. from notifications)
|
||||
630
DOMAIN.md
Normal file
630
DOMAIN.md
Normal file
|
|
@ -0,0 +1,630 @@
|
|||
# Keryx — Domain Language & Conventions
|
||||
|
||||
**Purpose:** Define every term, convention, and naming rule used across Keryx (iOS app + BFF). The iOS app design, code, UI labels, and deep links must match this document exactly. If a term appears in the UI, a GraphQL query, or a deep link, it is defined here.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Core Concepts](#1-core-concepts)
|
||||
2. [UI Component Taxonomy](#2-ui-component-taxonomy)
|
||||
3. [Page Structure](#3-page-structure)
|
||||
4. [Navigation & Deep Links](#4-navigation--deep-links)
|
||||
5. [SSE Streaming Events](#5-sse-streaming-events)
|
||||
6. [GraphQL Conventions](#6-graphql-conventions)
|
||||
7. [UI Labels & Copy](#7-ui-labels--copy)
|
||||
8. [Iconography](#8-iconography)
|
||||
9. [State Terminology](#9-state-terminology)
|
||||
10. [Conventions](#10-conventions)
|
||||
11. [Appendix: Hermes API Mapping](#11-appendix-hermes-api-mapping)
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Concepts
|
||||
|
||||
### Keryx
|
||||
The iOS/iPadOS app. Named after the *keryx* (κήρυξ) — the ancient Greek herald who precedes the main messenger (Hermes). The app is the herald that announces what Hermes has to say.
|
||||
|
||||
### Keryx BFF
|
||||
The Python FastAPI + Strawberry GraphQL backend that the iOS app communicates with. Owns the app experience — drives content, layout, labels, and navigation. Not a thin proxy.
|
||||
|
||||
### Hermes Agent
|
||||
The underlying AI agent framework running on the user's server. Keryx never talks to Hermes directly; all communication goes through the Keryx BFF.
|
||||
|
||||
### Session
|
||||
A single conversation with Hermes. Has an ID, a model, a start time, messages, and token usage. Sessions are created and managed by the Hermes API server. Keryx references them by their Hermes session ID.
|
||||
|
||||
### Conversation
|
||||
Synonym for **Session** in user-facing contexts. In code and APIs, use `session`. In UI labels, prefer "Conversation" or "Chat".
|
||||
|
||||
### Message
|
||||
A single turn in a session. Has a role (`user` or `assistant`), content, and a timestamp. Messages are never deleted by Keryx.
|
||||
|
||||
### Component
|
||||
A single piece of UI rendered inside a page feed. Every component has a `type`, an `id`, `data`, and `actions`. See [UI Component Taxonomy](#2-ui-component-taxonomy) for the full list.
|
||||
|
||||
### Feed
|
||||
The ordered array of `items` within a `page`. The iOS app renders components in array order, left-to-right, top-to-bottom.
|
||||
|
||||
### Page
|
||||
A full-screen view presented by the iOS app. Every page has the structure `{header, items[], footer}`. The BFF builds pages; the iOS app renders them.
|
||||
|
||||
### Workspace
|
||||
A directory on the Hermes server containing project files the user interacts with. Defined by a single root path in the BFF config. Read-only.
|
||||
|
||||
### Deep Link
|
||||
A `keryx://` URL that the iOS app uses for all navigation and actions. No screen transitions happen without a deep link. See [Navigation & Deep Links](#4-navigation--deep-links).
|
||||
|
||||
---
|
||||
|
||||
## 2. UI Component Taxonomy
|
||||
|
||||
Every component has this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "component_type_name",
|
||||
"id": "unique_identifier",
|
||||
"data": { ... },
|
||||
"actions": [
|
||||
{"type": "tap|swipe_left|swipe_right", "label": "Action", "style": "normal|destructive", "link": "keryx://..."}
|
||||
],
|
||||
"streaming": false
|
||||
}
|
||||
```
|
||||
|
||||
`streaming` is `true` when the component is still receiving data via the SSE stream. The iOS app may show a shimmer/typing indicator for streaming components.
|
||||
|
||||
### 2.1 `session_card`
|
||||
|
||||
A session/conversation in the sessions list.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | String | ✅ | Hermes session ID |
|
||||
| `title` | String? | ❌ | Session title. `null` if untitled. |
|
||||
| `model_badge` | String? | ❌ | Model name shown as a badge (e.g. "deepseek-v4-flash") |
|
||||
| `message_count` | Int | ✅ | Total messages in the session |
|
||||
| `timestamp` | String | ✅ | Relative time string (e.g. "2m ago", "Yesterday") — generated by BFF |
|
||||
| `preview` | String? | ❌ | First ~80 chars of the session for preview |
|
||||
|
||||
**Display rules:**
|
||||
- If `title` is `null`, show the `preview` as the primary text (truncated to 1 line)
|
||||
- If `title` is set, show `title` as primary, `preview` as secondary
|
||||
- `model_badge` shown as a rounded pill
|
||||
- `timestamp` right-aligned
|
||||
- Swipe left reveals "Delete" (destructive)
|
||||
|
||||
### 2.2 `message_bubble`
|
||||
|
||||
A single message in a conversation.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | String | ✅ | Message ID from Hermes |
|
||||
| `role` | Enum | ✅ | `"user"` or `"assistant"` |
|
||||
| `text` | String? | ❌ | Message content. May be empty during streaming. |
|
||||
| `streaming` | Bool | ✅ | `true` while assistant is still generating |
|
||||
| `timestamp` | Float? | ❌ | Unix timestamp |
|
||||
| `model` | String? | ❌ | Model used for this response (assistant only) |
|
||||
| `attachments` | [Attachment]? | ❌ | Files attached to this message |
|
||||
|
||||
**`Attachment`:**
|
||||
```json
|
||||
{
|
||||
"name": "report.pdf",
|
||||
"size": 245760,
|
||||
"mime_type": "application/pdf"
|
||||
}
|
||||
```
|
||||
|
||||
**Display rules:**
|
||||
- User messages: right-aligned, tinted background
|
||||
- Assistant messages: left-aligned, default background
|
||||
- `model` shown as a small label above assistant messages
|
||||
- Streaming messages show a blinking cursor at end of text
|
||||
|
||||
### 2.3 `thinking_block`
|
||||
|
||||
Collapsible reasoning from the assistant.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | String | ✅ | Unique ID |
|
||||
| `text` | String | ✅ | Reasoning content |
|
||||
| `collapsed_by_default` | Bool | ❌ | Default: `true` |
|
||||
|
||||
**Display rules:**
|
||||
- Tappable header "Thinking..."
|
||||
- Expand/collapse animation
|
||||
- Grey/dim text when collapsed
|
||||
- Header shows token count if available
|
||||
|
||||
### 2.4 `tool_call_card`
|
||||
|
||||
A tool invocation by Hermes (terminal, web search, file read, etc.).
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | String | ✅ | Unique ID |
|
||||
| `tool` | String | ✅ | Tool name (e.g. "terminal", "web_search") |
|
||||
| `command` | String? | ❌ | The command/query |
|
||||
| `status` | Enum | ✅ | `"running"`, `"completed"`, `"failed"` |
|
||||
| `output` | String? | ❌ | Tool output (truncated) |
|
||||
| `duration_ms` | Int? | ❌ | Execution time |
|
||||
|
||||
**Display rules:**
|
||||
- Show tool icon + name as a compact card
|
||||
- Show command as monospaced text
|
||||
- Status indicator: spinner (running), checkmark (completed), X (failed)
|
||||
- Tappable to expand full output (post-MVP)
|
||||
|
||||
### 2.5 `file_row`
|
||||
|
||||
A file or directory in the workspace browser.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | String | ✅ | File path (used as identifier) |
|
||||
| `name` | String | ✅ | File/directory name |
|
||||
| `is_directory` | Bool | ✅ | True if directory |
|
||||
| `size` | Int? | ❌ | File size in bytes (null for directories) |
|
||||
| `mime_type` | String? | ❌ | MIME type for files |
|
||||
| `modified_at` | String? | ❌ | Relative time string |
|
||||
| `children` | [FileRow]? | ❌ | When depth > 0, nested files |
|
||||
|
||||
**Display rules:**
|
||||
- Directory icon (folder) or file icon based on type
|
||||
- Tap on directory → navigates into it
|
||||
- Tap on file → opens preview (post-MVP: in-app viewer)
|
||||
- Indentation for nested children
|
||||
|
||||
### 2.6 `code_block`
|
||||
|
||||
A rendered code snippet inside a message_bubble. When Hermes returns markdown with code fences, the BFF extracts them into standalone components.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | String | ✅ | Unique ID |
|
||||
| `language` | String? | ❌ | Language identifier (e.g. "python", "json") |
|
||||
| `code` | String | ✅ | The code content |
|
||||
| `streaming` | Bool | ✅ | `true` while still receiving code |
|
||||
|
||||
**Display rules:**
|
||||
- Monospaced font, syntax highlighting (SF Mono)
|
||||
- Language badge in top-right
|
||||
- Copy button in top-right (copy to clipboard)
|
||||
- Scrollable horizontal for long lines
|
||||
|
||||
### 2.7 `divider`
|
||||
|
||||
A visual separator.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | String | ✅ | Unique ID |
|
||||
| `label` | String? | ❌ | Optional text in the divider |
|
||||
|
||||
**Display rules:**
|
||||
- Thin horizontal line
|
||||
- If `label` present, text centered in the line
|
||||
|
||||
### 2.8 `compose_bar`
|
||||
|
||||
The chat input area. Only appears in the `footer` of a chat page, never in `items`.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | String | ✅ | Always `"compose"` |
|
||||
| `placeholder` | String | ✅ | Placeholder text (e.g. "Ask something...") |
|
||||
| `actions` | ComposeActions | ✅ | Deep links for send, attach, voice |
|
||||
| `disabled` | Bool | ✅ | `true` while a message is being sent/streaming |
|
||||
|
||||
**`ComposeActions`:**
|
||||
```json
|
||||
{
|
||||
"send": "keryx://chat/{id}/send",
|
||||
"attach": "keryx://chat/{id}/attach",
|
||||
"voice": "keryx://chat/{id}/voice"
|
||||
}
|
||||
```
|
||||
|
||||
**Display rules:**
|
||||
- Fixed at bottom of screen
|
||||
- Text field + send button + attach button + voice button
|
||||
- Disabled state: greyed out, non-interactive
|
||||
- Shows attachment chip when a file is staged
|
||||
|
||||
---
|
||||
|
||||
## 3. Page Structure
|
||||
|
||||
Every page response has this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"page": {
|
||||
"id": "string",
|
||||
"header": { ... } | null,
|
||||
"items": [ ... ],
|
||||
"footer": { ... } | null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Header
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Conversations",
|
||||
"subtitle": "82 total",
|
||||
"actions": [
|
||||
{"icon": "plus", "label": "New Chat", "link": "keryx://new-chat"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `title` | String? | ❌ | Main header text |
|
||||
| `subtitle` | String? | ❌ | Smaller text below title |
|
||||
| `actions` | [LinkAction]? | ❌ | Right-side action buttons |
|
||||
|
||||
### Footer
|
||||
|
||||
The footer is either `null` (no footer) or contains the compose bar for chat pages. There is no pagination — all lists load in full.
|
||||
|
||||
```json
|
||||
// Chat page footer
|
||||
{
|
||||
"type": "compose_bar",
|
||||
"placeholder": "Ask something...",
|
||||
"actions": {
|
||||
"send": "keryx://chat/{id}/send",
|
||||
"attach": "keryx://chat/{id}/attach",
|
||||
"voice": "keryx://chat/{id}/voice"
|
||||
},
|
||||
"disabled": false
|
||||
}
|
||||
```
|
||||
|
||||
| Type | Description |
|
||||
|---|---|
|
||||
| `compose_bar` | Chat input area (chat page only) |
|
||||
| `null` | No footer (settings page, workspace, sessions list) |
|
||||
|
||||
### Empty State
|
||||
|
||||
When `items` is empty, the BFF may include an `empty_state` field at the page level (not inside `header`):
|
||||
|
||||
```json
|
||||
{
|
||||
"page": {
|
||||
"id": "sessions",
|
||||
"header": {"title": "Conversations"},
|
||||
"empty_state": {
|
||||
"icon": "message",
|
||||
"title": "No conversations yet",
|
||||
"subtitle": "Start a new chat to begin",
|
||||
"action": {"label": "Start Chat", "link": "keryx://new-chat"}
|
||||
},
|
||||
"items": [],
|
||||
"footer": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The iOS app shows the empty state view when `items.length === 0` and `empty_state` is present.
|
||||
|
||||
---
|
||||
|
||||
## 4. Navigation & Deep Links
|
||||
|
||||
All navigation is driven by `keryx://` deep links. The iOS app registers the `keryx` URL scheme and routes links to screens. No screen transitions are hardcoded.
|
||||
|
||||
### Link Format
|
||||
|
||||
```
|
||||
keryx://<action>/<param>?query=val
|
||||
```
|
||||
|
||||
### Deep Link Registry
|
||||
|
||||
| Link | Action | Description |
|
||||
|---|---|---|
|
||||
| `keryx://sessions` | Present | Sessions list page |
|
||||
| `keryx://new-chat` | Present | Create new session → navigate to chat |
|
||||
| `keryx://chat/{session_id}` | Present | Open chat view for an existing session |
|
||||
| `keryx://chat/{session_id}/send` | Action | Send message (POST) — triggered by compose bar |
|
||||
| `keryx://chat/{session_id}/attach` | Action | Attach file (POST) — triggered by compose bar |
|
||||
| `keryx://chat/{session_id}/voice` | Action | Start voice input — triggered by compose bar |
|
||||
| `keryx://delete/{session_id}` | Action | Delete a session |
|
||||
| `keryx://rename/{session_id}` | Action | Rename a session |
|
||||
| `keryx://workspace` | Present | Workspace file browser page |
|
||||
| `keryx://workspace/{path}` | Present | Navigate into a directory in workspace |
|
||||
| `keryx://workspace/read/{path}` | Present | Preview a workspace file |
|
||||
| `keryx://settings` | Present | Settings page |
|
||||
| `keryx://release-notes` | Present | Release notes / changelog |
|
||||
| `keryx://{anything}` | Fallback | Open in system browser or show error |
|
||||
|
||||
### Navigation Rules
|
||||
|
||||
1. **Present** links push a new page onto the navigation stack (iPhone) or select a column (iPad)
|
||||
2. **Action** links trigger a side effect (POST, DELETE) and are handled without navigation
|
||||
3. The app never constructs URLs dynamically — all links come from BFF responses
|
||||
4. Unknown link schemes are silently dropped (app doesn't crash)
|
||||
|
||||
---
|
||||
|
||||
## 5. SSE Streaming Events
|
||||
|
||||
The SSE protocol for `POST /v1/ios/chat/{session_id}/stream`.
|
||||
|
||||
### Event: `component`
|
||||
|
||||
Sent when a new UI component should be appended to the feed.
|
||||
|
||||
```json
|
||||
event: component
|
||||
data: {"type": "message_bubble", "id": "msg_1", "data": {"role": "user", "text": "Hello"}}
|
||||
```
|
||||
|
||||
**iOS action:** Append a new `FeedComponent` to `items[]`. Set `streaming: true` if the component may receive further updates.
|
||||
|
||||
### Event: `chunk`
|
||||
|
||||
Sent when text content should be appended to an existing component.
|
||||
|
||||
```json
|
||||
event: chunk
|
||||
data: {"component_id": "msg_2", "text": "def fibonacci"}
|
||||
```
|
||||
|
||||
**iOS action:** Find component by `component_id` in `items[]`. Append `text` to its `data.text` field. Re-render in place.
|
||||
|
||||
### Event: `component_patch`
|
||||
|
||||
Sent when a component's metadata should be updated (e.g. tool status change).
|
||||
|
||||
```json
|
||||
event: component_patch
|
||||
data: {"id": "tc_1", "data": {"status": "completed", "output": "src/\ntests/"}}
|
||||
```
|
||||
|
||||
**iOS action:** Find component by `id`. Deep-merge `data` into the component's existing `data`. Fields not in `data` are preserved.
|
||||
|
||||
### Event: `component_complete`
|
||||
|
||||
Sent when a component is finalised (no more updates).
|
||||
|
||||
```json
|
||||
event: component_complete
|
||||
data: {"id": "msg_2", "usage": {"input_tokens": 16267, "output_tokens": 20}}
|
||||
```
|
||||
|
||||
**iOS action:** Find component by `id`. Set `streaming: false`. Merge any additional `data` fields (e.g. `usage`).
|
||||
|
||||
### Event: `done`
|
||||
|
||||
Sent when the stream is complete.
|
||||
|
||||
```json
|
||||
event: done
|
||||
data: {}
|
||||
```
|
||||
|
||||
**iOS action:** Close SSE connection. Re-enable the send button. The `compose_bar` in the footer should set `disabled: false`.
|
||||
|
||||
### Error Handling
|
||||
|
||||
If the BFF encounters an error mid-stream:
|
||||
|
||||
```json
|
||||
event: error
|
||||
data: {"code": "hermes_unreachable", "message": "Hermes API server is not responding"}
|
||||
```
|
||||
|
||||
**iOS action:** Show inline error banner at the bottom of the feed. The stream is not closed — the user can retry.
|
||||
|
||||
---
|
||||
|
||||
## 6. GraphQL Conventions
|
||||
|
||||
### Naming
|
||||
|
||||
| Convention | Rule | Example |
|
||||
|---|---|---|
|
||||
| Queries | `camelCase` | `appData`, `sessionList` |
|
||||
| Mutations | `camelCase`, verb-first | `sendMessage`, `deleteSession` |
|
||||
| Types | `PascalCase` | `AppData`, `SessionCard` |
|
||||
| Fields | `camelCase` | `modelBadge`, `messageCount` |
|
||||
| Enums | `PascalCase` | `MessageRole`, `ToolStatus` |
|
||||
| Enum values | `SCREAMING_SNAKE_CASE` | `USER`, `ASSISTANT`, `COMPLETED` |
|
||||
|
||||
### Union Types for Feed Components
|
||||
|
||||
The `FeedComponent` union type uses Strawberry's `@strawberry.type` with `@strawberry.union`. The iOS app receives the `__typename` field to determine which fragment to use:
|
||||
|
||||
```graphql
|
||||
query SessionList {
|
||||
page(id: "sessions") {
|
||||
items {
|
||||
__typename
|
||||
... on SessionCard {
|
||||
id title modelBadge messageCount timestamp preview
|
||||
}
|
||||
... on Divider {
|
||||
id label
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pagination
|
||||
|
||||
No pagination. All lists load in full — sessions, workspace trees, and chat message histories. The page query returns every item at once.
|
||||
|
||||
---
|
||||
|
||||
## 7. UI Labels & Copy
|
||||
|
||||
### Standard Labels
|
||||
|
||||
| Context | Label | Notes |
|
||||
|---|---|---|
|
||||
| App display name | **Keryx** | Not "Keryx iOS" or "Keryx Client" |
|
||||
| Tab: sessions | **Chats** | User-friendly term |
|
||||
| Tab: workspace | **Files** | |
|
||||
| Tab: settings | **Settings** | |
|
||||
| New chat button | **New Chat** | |
|
||||
| Delete action | **Delete** | Destructive |
|
||||
| Rename action | **Rename** | |
|
||||
| Send button | *(None — icon only)* | Paper plane icon |
|
||||
| Attach button | *(None — icon only)* | Paperclip icon |
|
||||
| Voice button | *(None — icon only)* | Microphone icon |
|
||||
| Empty sessions | **No conversations yet** | |
|
||||
| Empty workspace | **No files yet** | |
|
||||
| Model selector | *(None — label varies)* | e.g. "DeepSeek V4 Flash" |
|
||||
| Streaming indicator | *(None — visual only)* | Blinking cursor / shimmer |
|
||||
| Connection: loading | **Connecting...** | |
|
||||
| Connection: error | **Could not connect** | With retry button |
|
||||
| Session: untitled | *(Show first 80 chars of preview)* | Not "Untitled" |
|
||||
| Delete confirmation | **Delete conversation?** | Action sheet |
|
||||
|
||||
### Tone & Voice
|
||||
|
||||
- **Friendly but direct** — the app is a tool, not a companion
|
||||
- **Minimal labels** — every visible word serves a purpose
|
||||
- **No "wizard" or "magic" language** — Hermes is an agent, not a magician
|
||||
- **Use "Chat", not "Chat with Hermes" or "Conversation with AI"**
|
||||
- **Use "Files", not "Workspace Files" or "Project Files"**
|
||||
|
||||
---
|
||||
|
||||
## 8. Iconography
|
||||
|
||||
### SF Symbols Mappings
|
||||
|
||||
| Context | SF Symbol Name | Notes |
|
||||
|---|---|---|
|
||||
| Tab: Chats | `message.fill` | |
|
||||
| Tab: Files | `folder.fill` | |
|
||||
| Tab: Settings | `gearshape.fill` | |
|
||||
| New chat | `plus` | |
|
||||
| Send | `paperplane.fill` | |
|
||||
| Attach | `paperclip` | |
|
||||
| Voice | `mic.fill` | |
|
||||
| Delete | `trash.fill` | |
|
||||
| Rename | `pencil` | |
|
||||
| Copy code | `doc.on.doc` | |
|
||||
| Expand | `chevron.down` | |
|
||||
| Collapse | `chevron.right` | |
|
||||
| Directory | `folder` | Non-filled for file rows |
|
||||
| File (generic) | `doc` | |
|
||||
| File (code) | `doc.text` | |
|
||||
| File (image) | `photo` | |
|
||||
| File (PDF) | `doc.richtext` | |
|
||||
| Empty state | `tray` | |
|
||||
| Success | `checkmark.circle.fill` | Green |
|
||||
| Error | `xmark.circle.fill` | Red |
|
||||
| Pending | `ellipsis` | Animated |
|
||||
|
||||
---
|
||||
|
||||
## 9. State Terminology
|
||||
|
||||
### Connection States
|
||||
|
||||
| State | BFF `app_data` field | iOS App Behaviour |
|
||||
|---|---|---|
|
||||
| **Connected** | `server.hermes_connected: true` | Normal operation |
|
||||
| **Hermes Unreachable** | `server.hermes_connected: false` | Show banner "Hermes is not responding. Responses may be delayed." |
|
||||
| **BFF Error** | 500 / timeout | Show error screen with retry |
|
||||
| **Version Mismatch** | `app.force_update_url` not null | Show force-update screen until app is updated |
|
||||
|
||||
### Session States
|
||||
|
||||
| State | BFF Signal | iOS App Behaviour |
|
||||
|---|---|---|
|
||||
| **Loading** | N/A (page fetch in progress) | Shimmer / skeleton |
|
||||
| **Loaded** | Items array populated | Render feed |
|
||||
| **Empty** | `empty_state` present, `items` empty | Render empty state |
|
||||
| **Error** | Error in response | Show error with retry button |
|
||||
|
||||
### Component States
|
||||
|
||||
| State | `streaming` field | iOS App Behaviour |
|
||||
|---|---|---|
|
||||
| **Static** | `false` | Render normally |
|
||||
| **Streaming** | `true` | Show shimmer/blinking cursor |
|
||||
| **Updating** | `true` (with `chunk` events arriving) | Append text in place |
|
||||
| **Complete** | `false` (after `component_complete`) | Remove shimmer, show final content |
|
||||
|
||||
---
|
||||
|
||||
## 10. Conventions
|
||||
|
||||
### Naming
|
||||
|
||||
| Domain | Convention | Example |
|
||||
|---|---|---|
|
||||
| Files | `snake_case` | `session_card`, `keryx_bff` |
|
||||
| GraphQL fields | `camelCase` | `messageCount`, `createdAt` |
|
||||
| Component types | `snake_case` | `message_bubble`, `tool_call_card` |
|
||||
| Deep links | `keryx://resource/action` | `keryx://chat/{id}/send` |
|
||||
| SSE events | `lowercase_snake` | `component_complete`, `component_patch` |
|
||||
| Config keys | `snake_case` | `staging_cleanup_days` |
|
||||
| HTTP headers | `Pascal-Case` | `Authorization`, `Content-Type` |
|
||||
| Enum values (Swift) | `lowerCamelCase` | `.user`, `.assistant` |
|
||||
| Enum values (Python) | `SCREAMING_SNAKE` | `USER`, `ASSISTANT` |
|
||||
|
||||
### Dates & Times
|
||||
|
||||
- **BFF → iOS:** Relative time strings for display ("2m ago", "Yesterday", "3 weeks ago")
|
||||
- **BFF → iOS:** Unix timestamps (Float) for sorting
|
||||
- **BFF generation:** Use user's timezone (configured in BFF, or sent by iOS app in headers)
|
||||
- **iOS display:** Never show raw timestamps to users
|
||||
|
||||
### Files
|
||||
|
||||
- **File sizes** returned as bytes (Int). iOS converts for display (KB, MB, etc.)
|
||||
- **MIME types** follow standard IANA values (`application/pdf`, `text/plain`, `image/png`)
|
||||
- **Workspace paths** are always relative to the configured workspace root. The BFF resolves absolute paths internally.
|
||||
|
||||
### Errors
|
||||
|
||||
| Error Code | HTTP Status | Meaning |
|
||||
|---|---|---|
|
||||
| `auth_invalid` | 401 | API key missing or wrong |
|
||||
| `session_not_found` | 404 | Session ID doesn't exist |
|
||||
| `file_too_large` | 413 | Upload exceeds `max_upload_size_mb` |
|
||||
| `unsupported_file_type` | 415 | MIME type not allowed |
|
||||
| `hermes_unreachable` | 502 | Hermes API server not responding |
|
||||
| `hermes_error` | 502 | Hermes returned an error |
|
||||
| `workspace_out_of_bounds` | 403 | Path traversal attempt blocked |
|
||||
| `rate_limited` | 429 | Too many requests |
|
||||
|
||||
---
|
||||
|
||||
## 11. Appendix: Hermes API Mapping
|
||||
|
||||
This documents how BFF concepts map to Hermes API Server endpoints and data.
|
||||
|
||||
| Keryx Concept | Hermes API Endpoint | Notes |
|
||||
|---|---|---|
|
||||
| Session ID | API Server session ID | Direct 1:1 mapping |
|
||||
| Create session | `POST /api/sessions` | Returns Hermes session ID |
|
||||
| Session list | `GET /api/sessions` | BFF transforms to session_card components |
|
||||
| Session messages | `GET /api/sessions/{id}/messages` | BFF transforms to message_bubble + thinking_block + tool_call_card |
|
||||
| Delete session | `DELETE /api/sessions/{id}` | |
|
||||
| Rename session | `PATCH /api/sessions/{id}` | Update `title` field |
|
||||
| Send message | `POST /api/sessions/{id}/chat/stream` | SSE stream of events |
|
||||
| Model list | `GET /v1/models` | Proxied through BFF |
|
||||
| Health check | `GET /v1/health` | |
|
||||
| File upload | *(Not in Hermes API)* | BFF handles staging |
|
||||
| Workspace files | *(Not in Hermes API)* | BFF reads filesystem |
|
||||
|
||||
---
|
||||
|
||||
*This document is the source of truth for all naming, terminology, and conventions used in the Keryx project. If a UI label, GraphQL field, deep link, or code identifier contradicts this document, this document wins.*
|
||||
Loading…
Add table
Reference in a new issue