mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-10 19:42:21 +00:00
107 lines
3.9 KiB
Swift
107 lines
3.9 KiB
Swift
import HapiClient
|
|
import HapiProtocol
|
|
import Observation
|
|
import SwiftUI
|
|
|
|
/// App-only selection/navigation state. The interactor remains the source of
|
|
/// truth, including optimistic updates, capability gates and server reloads.
|
|
@MainActor @Observable
|
|
final class SessionConfigModel {
|
|
enum Page: Hashable {
|
|
case permission
|
|
case model
|
|
}
|
|
|
|
let interactor: ChatInteractor
|
|
var path: [Page] = []
|
|
private var rootDetent: PresentationDetent = .medium
|
|
|
|
init(interactor: ChatInteractor) {
|
|
self.interactor = interactor
|
|
}
|
|
|
|
var config: SessionConfigState { interactor.config }
|
|
var isApplying: Bool { interactor.configOpPending }
|
|
var modelLoadFailed: Bool { interactor.codexModels == .failed }
|
|
var showsModel: Bool { config.modelOptions != nil || config.modelOptionsLoading }
|
|
var showsEffort: Bool { config.effortOptions?.isEmpty == false }
|
|
var showsCollaborationMode: Bool { config.flavor == "codex" }
|
|
var hasSettings: Bool { !config.permissionModes.isEmpty || showsModel || showsEffort || showsCollaborationMode }
|
|
|
|
/// Details always expand; returning restores the user's root-sheet height.
|
|
var detent: PresentationDetent {
|
|
get { path.isEmpty ? rootDetent : .large }
|
|
set { if path.isEmpty { rootDetent = newValue } }
|
|
}
|
|
|
|
var permission: PermissionMode { config.permissionMode ?? .default }
|
|
var collaborationMode: CodexCollaborationMode { config.collaborationMode ?? .default }
|
|
|
|
var currentModel: String? {
|
|
if config.flavor == "claude" {
|
|
return ModelCatalog.normalizeClaudeModel(config.model)
|
|
}
|
|
if config.flavor == "codex", config.model == nil,
|
|
case .loaded(let models) = interactor.codexModels {
|
|
return models.first(where: \.isDefault)?.id
|
|
}
|
|
return config.model
|
|
}
|
|
|
|
var modelLabel: String {
|
|
config.modelOptions?.first { $0.value == currentModel }?.label
|
|
?? currentModel ?? "Default"
|
|
}
|
|
|
|
/// Do not misrepresent an unlisted current model as the first catalog item.
|
|
var unlistedModel: String? {
|
|
guard let currentModel,
|
|
config.modelOptions?.contains(where: { $0.value == currentModel }) != true else { return nil }
|
|
return currentModel
|
|
}
|
|
|
|
var currentEffort: String? {
|
|
config.flavor == "claude" ? ModelCatalog.normalizeClaudeEffort(config.effort) : config.effort
|
|
}
|
|
|
|
/// An obsolete effort can arrive via SSE or remain after a model switch.
|
|
/// Give Picker a matching, read-only tag without offering unsupported edits.
|
|
var effortOptions: [CatalogOption] {
|
|
guard var options = config.effortOptions, !options.isEmpty else { return [] }
|
|
if !options.contains(where: { $0.value == currentEffort }) {
|
|
options.insert(CatalogOption(value: currentEffort, label: currentEffort ?? "Default"), at: 0)
|
|
}
|
|
return options
|
|
}
|
|
|
|
func canSelectEffort(_ value: String?) -> Bool {
|
|
config.effortOptions?.contains(where: { $0.value == value }) == true
|
|
}
|
|
|
|
func selectPermission(_ mode: PermissionMode) {
|
|
guard !isApplying, config.permissionModes.contains(where: { $0.mode == mode }) else { return }
|
|
if mode != permission { interactor.setPermissionMode(mode) }
|
|
path.removeAll()
|
|
}
|
|
|
|
func selectModel(_ value: String?) {
|
|
guard !isApplying, config.modelOptions?.contains(where: { $0.value == value }) == true else { return }
|
|
if value != currentModel { interactor.setModel(value) }
|
|
path.removeAll()
|
|
}
|
|
|
|
func selectCollaborationMode(_ mode: CodexCollaborationMode) {
|
|
guard !isApplying, config.canChangeCollaborationMode, mode != collaborationMode else { return }
|
|
interactor.setCollaborationMode(mode)
|
|
}
|
|
|
|
func selectEffort(_ value: String?) {
|
|
guard !isApplying, value != currentEffort, canSelectEffort(value) else { return }
|
|
interactor.setEffort(value)
|
|
}
|
|
|
|
func loadModels() {
|
|
interactor.loadModelOptions()
|
|
}
|
|
}
|