chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import Foundation
|
||||
import JCodeKit
|
||||
import Observation
|
||||
|
||||
/// Observable glue between JCodeKit and the SwiftUI views.
|
||||
///
|
||||
/// Owns the credential store, the active `Connection`, and the derived
|
||||
/// `SessionState`. Contains no protocol or state-transition logic itself;
|
||||
/// everything flows through `SessionReducer`.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppModel {
|
||||
// MARK: - Published state
|
||||
|
||||
private(set) var session = SessionState()
|
||||
private(set) var servers: [ServerCredential] = []
|
||||
var activeServer: ServerCredential?
|
||||
|
||||
/// Composer draft.
|
||||
var draft = ""
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private let store: any CredentialStore
|
||||
private var connection: Connection?
|
||||
private var pumpTask: Task<Void, Never>?
|
||||
|
||||
init(store: any CredentialStore = KeychainCredentialStore()) {
|
||||
self.store = store
|
||||
servers = store.loadAll()
|
||||
activeServer = servers.last
|
||||
}
|
||||
|
||||
var isConnected: Bool {
|
||||
session.phase == .connected
|
||||
}
|
||||
|
||||
// MARK: - Pairing
|
||||
|
||||
func pair(gateway: Gateway, code: String, deviceName: String) async throws {
|
||||
let client = PairingClient()
|
||||
let response = try await client.pair(
|
||||
gateway: gateway,
|
||||
code: code,
|
||||
deviceID: deviceID(),
|
||||
deviceName: deviceName
|
||||
)
|
||||
let credential = ServerCredential(
|
||||
host: gateway.host,
|
||||
port: gateway.port,
|
||||
token: response.token,
|
||||
serverName: response.serverName,
|
||||
serverVersion: response.serverVersion
|
||||
)
|
||||
store.save(credential)
|
||||
servers = store.loadAll()
|
||||
activeServer = credential
|
||||
connect(to: credential)
|
||||
}
|
||||
|
||||
func removeServer(_ credential: ServerCredential) {
|
||||
store.remove(id: credential.id)
|
||||
servers = store.loadAll()
|
||||
if activeServer?.id == credential.id {
|
||||
disconnect()
|
||||
activeServer = servers.last
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Connection lifecycle
|
||||
|
||||
func connect(to credential: ServerCredential, sessionID: String? = nil) {
|
||||
session = SessionState()
|
||||
open(credential, sessionID: sessionID)
|
||||
}
|
||||
|
||||
/// Reconnects to the active server without discarding the rendered
|
||||
/// transcript; the history resync replaces it once the socket is back.
|
||||
func retryConnection() {
|
||||
guard let activeServer else { return }
|
||||
open(activeServer, sessionID: session.sessionID)
|
||||
}
|
||||
|
||||
private func open(_ credential: ServerCredential, sessionID: String?) {
|
||||
disconnect()
|
||||
activeServer = credential
|
||||
let connection = Connection(
|
||||
configuration: .init(
|
||||
gateway: credential.gateway,
|
||||
authToken: credential.token
|
||||
)
|
||||
)
|
||||
self.connection = connection
|
||||
pumpTask = Task { [weak self] in
|
||||
let stream = await connection.start(resumeSessionID: sessionID)
|
||||
for await output in stream {
|
||||
guard let self else { return }
|
||||
self.session = SessionReducer.reduce(self.session, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
pumpTask?.cancel()
|
||||
pumpTask = nil
|
||||
let connection = connection
|
||||
self.connection = nil
|
||||
Task { await connection?.stop() }
|
||||
session = SessionReducer.reduce(session, .phase(.disconnected))
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
func sendDraft() {
|
||||
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
draft = ""
|
||||
if session.isProcessing {
|
||||
session = SessionReducer.reduce(session, intent: .userQueuedInterrupt(text))
|
||||
send { .softInterrupt(id: $0, content: text, urgent: false) }
|
||||
} else {
|
||||
session = SessionReducer.reduce(session, intent: .userSentMessage(text))
|
||||
send { .message(id: $0, content: text) }
|
||||
}
|
||||
}
|
||||
|
||||
func interrupt() {
|
||||
send { .cancel(id: $0) }
|
||||
}
|
||||
|
||||
func switchSession(_ sessionID: String) {
|
||||
guard let activeServer else { return }
|
||||
connect(to: activeServer, sessionID: sessionID)
|
||||
}
|
||||
|
||||
func setModel(_ model: String) {
|
||||
send { .setModel(id: $0, model: model) }
|
||||
}
|
||||
|
||||
func setReasoningEffort(_ effort: String) {
|
||||
send { .setReasoningEffort(id: $0, effort: effort) }
|
||||
}
|
||||
|
||||
/// Asks the server to compact the conversation context.
|
||||
func compactConversation() {
|
||||
send { .compact(id: $0) }
|
||||
}
|
||||
|
||||
func renameSession(_ title: String) {
|
||||
send { .renameSession(id: $0, title: title.isEmpty ? nil : title) }
|
||||
}
|
||||
|
||||
func dismissError() {
|
||||
session = SessionReducer.reduce(session, intent: .dismissError)
|
||||
}
|
||||
|
||||
func dismissNotice(_ id: UUID) {
|
||||
session = SessionReducer.reduce(session, intent: .dismissNotice(id))
|
||||
}
|
||||
|
||||
/// Clears the current conversation on the server and optimistically locally.
|
||||
func clearConversation() {
|
||||
session = SessionReducer.reduce(session, intent: .clearedConversation)
|
||||
send { .clear(id: $0) }
|
||||
}
|
||||
|
||||
/// Drops any soft-interrupt messages queued mid-run before they inject.
|
||||
func cancelQueuedInterrupts() {
|
||||
session = SessionReducer.reduce(session, intent: .cancelledQueuedInterrupts)
|
||||
send { .cancelSoftInterrupts(id: $0) }
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func send(_ build: @escaping @Sendable (UInt64) -> Request) {
|
||||
guard let connection else { return }
|
||||
Task {
|
||||
do {
|
||||
try await connection.send(build)
|
||||
} catch {
|
||||
// Connection drops surface via phase changes; nothing to do here.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deviceID() -> String {
|
||||
let key = "jcode.device.id"
|
||||
if let existing = UserDefaults.standard.string(forKey: key) {
|
||||
return existing
|
||||
}
|
||||
let fresh = UUID().uuidString
|
||||
UserDefaults.standard.set(fresh, forKey: key)
|
||||
return fresh
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user