Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+ModelControls.swift
Peter Steinberger 8ae1581798 feat(clients): provider-grouped model picker with fast, verbosity, and inherited-thinking controls (#110339)
* feat(clients): provider-grouped model picker with fast, verbosity, and inherited-thinking controls

* chore(i18n): translate model-control strings and regenerate platform catalogs

* feat(clients): inherited-default restore for fast mode and verbosity controls

* fix(clients): preserve control state in thinking snapshots and persist inherited verbosity

* chore(i18n): resync native inventory after rebase onto message-reader main

* chore(i18n): realign locale artifacts after rebase onto message-reader main

* refactor(clients): drop superseded fast-mode accessors and picker convenience flagged by periphery

* chore(i18n): resync inventory line refs after dead-code removal
2026-07-18 07:01:31 +01:00

307 lines
14 KiB
Swift

import Foundation
extension OpenClawChatViewModel {
public static let verboseLevelOptions = ["off", "on", "full"]
public var thinkingSelectionID: String {
self.thinkingOverrideIsInherited ? Self.inheritedThinkingSelectionID : self.thinkingLevel
}
public var thinkingOverrideIsInherited: Bool {
if self.hasAppliedLiveSessions {
return self.currentSessionEntry()?.thinkingLevel == nil
}
return !self.prefersExplicitThinkingLevel
}
public var verboseLevel: String {
if self.hasAppliedLiveSessions {
return Self.normalizedVerboseLevel(self.currentSessionEntry()?.verboseLevel)
?? Self.inheritedThinkingSelectionID
}
return self.prefersExplicitVerboseLevel
? self.preferredVerboseLevel
: Self.inheritedThinkingSelectionID
}
public var fastModeSelectionID: String {
guard let session = self.currentSessionEntry(), session.fastMode != nil else {
return Self.inheritedThinkingSelectionID
}
return (session.effectiveFastMode ?? session.fastMode)?.isEnabled == true ? "on" : "off"
}
/// `models.list` currently has no fast-support capability field. Keep the
/// control available and let the gateway validate the session patch.
public var selectedModelSupportsFastMode: Bool {
true
}
public var isUpdatingSessionSettings: Bool {
self.inFlightSettingsPatchCountsByTarget[self.currentModelPatchTarget()] != nil
}
static func normalizedVerboseLevel(_ level: String?) -> String? {
let normalized = level?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return Self.verboseLevelOptions.contains(normalized ?? "") ? normalized : nil
}
func performSelectVerboseLevel(_ level: String) {
let clearsOverride = level == Self.inheritedThinkingSelectionID
let next = clearsOverride ? nil : Self.normalizedVerboseLevel(level)
guard clearsOverride || next != nil else { return }
let target = self.currentModelPatchTarget()
let sessionKey = self.sessionKey
let baselineSessionLevel = self.currentSessionEntry()?.verboseLevel
guard clearsOverride ? baselineSessionLevel != nil : Self.normalizedVerboseLevel(baselineSessionLevel) != next
else { return }
if self.acceptedVerboseLevelsByTarget[target] == nil {
self.acceptedVerboseLevelsByTarget[target] = baselineSessionLevel.map(VerboseLevelState.value)
?? VerboseLevelState.none
}
self.updateCurrentSessionVerboseLevel(next, sessionKey: sessionKey)
self.nextVerboseSelectionRequestID &+= 1
let verboseRequestID = self.nextVerboseSelectionRequestID
let requestedPreference = VerbosePreferenceState(
level: next ?? self.preferredVerboseLevel,
isExplicit: !clearsOverride)
self.verbosePreferenceRequests[verboseRequestID] = .pending(requestedPreference)
self.reconcileVerbosePreferenceRequests()
let requestID = self.reserveSessionSettingsRequest(for: target)
self.enqueueSessionSettingsPatch(requestID: requestID, target: target) { [weak self] routeLease in
guard let self else { return }
do {
guard let routeLease else { throw OpenClawChatTransportSendError.notDispatched }
let result = try await routeLease.patchSessionSettings(
sessionKey: target.canonicalSessionKey,
agentID: target.agentID,
patch: OpenClawChatSessionSettingsPatch(verboseLevel: .some(next)))
let accepted = clearsOverride ? nil : (Self.normalizedVerboseLevel(result?.verboseLevel) ?? next)
self.acceptedVerboseLevelsByTarget[target] = accepted.map(VerboseLevelState.value)
?? VerboseLevelState.none
self.recordModelControlPatchSuccess(
result: result,
requestID: requestID,
target: target,
verboseLevelOverride: .some(accepted))
self.verbosePreferenceRequests[verboseRequestID] = .succeeded(VerbosePreferenceState(
level: accepted ?? requestedPreference.level,
isExplicit: !clearsOverride))
self.reconcileVerbosePreferenceRequests()
if let state = self.modelControlState(for: target, originalSessionKey: sessionKey) {
self.updateCurrentSessionVerboseLevel(
accepted,
sessionKey: state.key,
exactMatchOnly: state.exactMatchOnly)
}
} catch {
self.verbosePreferenceRequests[verboseRequestID] = .failed
self.reconcileVerbosePreferenceRequests()
if let state = self.modelControlState(for: target, originalSessionKey: sessionKey) {
self.updateCurrentSessionVerboseLevel(
self.acceptedVerboseLevelsByTarget[target]?.level,
sessionKey: state.key,
exactMatchOnly: state.exactMatchOnly)
}
}
}
}
private func reconcileVerbosePreferenceRequests() {
let resolved = self.verbosePreferenceRequests.keys.sorted(by: >).compactMap { requestID
-> VerbosePreferenceState? in
switch self.verbosePreferenceRequests[requestID] {
case let .pending(state), let .succeeded(state): state
case .failed, .none: nil
}
}.first ?? self.confirmedVerbosePreference
if resolved.level != self.preferredVerboseLevel {
self.preferredVerboseLevel = resolved.level
self.onVerboseLevelChanged?(resolved.level)
}
self.prefersExplicitVerboseLevel = resolved.isExplicit
if resolved != self.emittedVerbosePreference {
self.emittedVerbosePreference = resolved
self.onVerbosePreferenceChanged?(resolved.isExplicit ? resolved.level : nil)
}
guard !self.verbosePreferenceRequests.values.contains(where: {
if case .pending = $0 { return true }
return false
}) else { return }
self.confirmedVerbosePreference = resolved
self.verbosePreferenceRequests.removeAll()
}
func performSelectFastMode(_ selectionID: String) {
let next: OpenClawChatFastMode?
switch selectionID {
case Self.inheritedThinkingSelectionID: next = nil
case "on": next = .on
case "off": next = .off
default: return
}
let target = self.currentModelPatchTarget()
let sessionKey = self.sessionKey
let baselineFastMode = self.currentSessionEntry()?.fastMode
let baselineEffectiveFastMode = self.currentSessionEntry()?.effectiveFastMode
guard baselineFastMode != next else { return }
if self.acceptedFastModesByTarget[target] == nil {
self.acceptedFastModesByTarget[target] = FastModeState(
override: baselineFastMode,
effective: baselineEffectiveFastMode)
}
self.updateCurrentSessionFastMode(
next,
effective: next ?? baselineEffectiveFastMode,
sessionKey: sessionKey)
let requestID = self.reserveSessionSettingsRequest(for: target)
self.enqueueSessionSettingsPatch(requestID: requestID, target: target) { [weak self] routeLease in
guard let self else { return }
do {
guard let routeLease else { throw OpenClawChatTransportSendError.notDispatched }
let result = try await routeLease.patchSessionSettings(
sessionKey: target.canonicalSessionKey,
agentID: target.agentID,
patch: OpenClawChatSessionSettingsPatch(fastMode: .some(next)))
let acceptedOverride = next == nil ? nil : (result?.fastMode ?? next)
let acceptedEffective = result?.effectiveFastMode
?? result?.fastMode
?? acceptedOverride
?? baselineEffectiveFastMode
self.acceptedFastModesByTarget[target] = FastModeState(
override: acceptedOverride,
effective: acceptedEffective)
self.recordModelControlPatchSuccess(
result: result,
requestID: requestID,
target: target,
fastModeOverride: .some(acceptedOverride),
effectiveFastMode: acceptedEffective)
if let state = self.modelControlState(for: target, originalSessionKey: sessionKey) {
self.updateCurrentSessionFastMode(
acceptedOverride,
effective: acceptedEffective,
sessionKey: state.key,
exactMatchOnly: state.exactMatchOnly)
}
} catch {
if let state = self.modelControlState(for: target, originalSessionKey: sessionKey) {
let accepted = self.acceptedFastModesByTarget[target]
self.updateCurrentSessionFastMode(
accepted?.override,
effective: accepted?.effective,
sessionKey: state.key,
exactMatchOnly: state.exactMatchOnly)
}
}
}
}
func applyModelControlPatchResult(
_ result: OpenClawChatModelPatchResult,
sessionKey: String,
fastOverrideCleared: Bool = false,
verboseOverrideCleared: Bool = false)
{
let session = self.currentSessionEntry()
if fastOverrideCleared {
self.updateCurrentSessionFastMode(
nil,
effective: result.effectiveFastMode ?? result.fastMode ?? session?.effectiveFastMode,
sessionKey: sessionKey)
} else if let fastMode = result.fastMode {
self.updateCurrentSessionFastMode(
fastMode,
effective: result.effectiveFastMode ?? fastMode,
sessionKey: sessionKey)
} else if let effectiveFastMode = result.effectiveFastMode {
self.updateCurrentSessionFastMode(
session?.fastMode,
effective: effectiveFastMode,
sessionKey: sessionKey)
}
if verboseOverrideCleared {
self.updateCurrentSessionVerboseLevel(nil, sessionKey: sessionKey)
} else if let verboseLevel = Self.normalizedVerboseLevel(result.verboseLevel) {
self.updateCurrentSessionVerboseLevel(verboseLevel, sessionKey: sessionKey)
}
}
private func recordModelControlPatchSuccess(
result: OpenClawChatModelPatchResult?,
requestID: UInt64,
target: ModelPatchTarget,
fastModeOverride: OpenClawChatFastMode?? = nil,
effectiveFastMode: OpenClawChatFastMode? = nil,
verboseLevelOverride: String?? = nil)
{
let previous = self.lastSuccessfulSettingsPatchResultsByTarget[target]
let recordedFastMode: OpenClawChatFastMode? = if let fastModeOverride {
fastModeOverride
} else {
result?.fastMode ?? previous?.fastMode
}
let recordedVerboseLevel: String? = if let verboseLevelOverride {
verboseLevelOverride
} else {
result?.verboseLevel ?? previous?.verboseLevel
}
if let fastModeOverride {
self.lastSuccessfulFastOverrideClearedByTarget[target] = fastModeOverride == nil
}
if let verboseLevelOverride {
self.lastSuccessfulVerboseOverrideClearedByTarget[target] = verboseLevelOverride == nil
}
self.lastSuccessfulSettingsPatchRequestIDsByTarget[target] = requestID
self.lastSuccessfulSettingsPatchResultsByTarget[target] = OpenClawChatModelPatchResult(
key: result?.key ?? previous?.key ?? target.canonicalSessionKey,
modelProvider: result?.modelProvider ?? previous?.modelProvider,
model: result?.model ?? previous?.model,
thinkingLevel: result?.thinkingLevel ?? previous?.thinkingLevel,
thinkingLevels: result?.thinkingLevels ?? previous?.thinkingLevels,
fastMode: recordedFastMode,
effectiveFastMode: result?.effectiveFastMode ?? effectiveFastMode ?? previous?.effectiveFastMode,
verboseLevel: recordedVerboseLevel)
}
private func modelControlState(for target: ModelPatchTarget, originalSessionKey: String)
-> (key: String, exactMatchOnly: Bool)?
{
if target == self.currentModelPatchTarget() {
return (originalSessionKey, false)
}
guard let key = self.inactiveSettingsStateKey(for: target) else { return nil }
return (key, true)
}
private func updateCurrentSessionVerboseLevel(
_ level: String?,
sessionKey: String,
exactMatchOnly: Bool = false)
{
let index = exactMatchOnly
? self.sessions.firstIndex(where: { $0.key == sessionKey })
: self.sessionIndexForModelState(sessionKey: sessionKey)
guard let index else { return }
self.sessions[index].verboseLevel = level
}
private func updateCurrentSessionFastMode(
_ mode: OpenClawChatFastMode?,
effective: OpenClawChatFastMode?,
sessionKey: String,
exactMatchOnly: Bool = false)
{
let index = exactMatchOnly
? self.sessions.firstIndex(where: { $0.key == sessionKey })
: self.sessionIndexForModelState(sessionKey: sessionKey)
guard let index else { return }
self.sessions[index].fastMode = mode
self.sessions[index].effectiveFastMode = effective
}
}