Files
wehub-resource-sync a789495a98
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
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

196 lines
5.8 KiB
Swift

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
}
}