mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 13:31:16 +00:00
* feat(macos): add Quick Chat power features * docs(macos): document Quick Chat power features * chore(i18n): refresh native locale artifacts for Quick Chat power features * fix(macos): update DEBUG test helper sendProvider to the reasoning-threaded arity
1243 lines
52 KiB
Swift
1243 lines
52 KiB
Swift
import Foundation
|
|
import Observation
|
|
import OpenClawChatUI
|
|
import OpenClawIPC
|
|
import OpenClawKit
|
|
import OpenClawProtocol
|
|
|
|
enum QuickChatAgentAvatar: Equatable, Sendable {
|
|
case none
|
|
case image(Data)
|
|
}
|
|
|
|
struct QuickChatAgentDisplay: Equatable, Sendable, Identifiable {
|
|
static let maximumAvatarBytes = 6_000_000
|
|
static let placeholder = QuickChatAgentDisplay(id: "", name: "Agent", emoji: nil, avatar: .none)
|
|
|
|
let id: String
|
|
let name: String
|
|
let emoji: String?
|
|
let avatar: QuickChatAgentAvatar
|
|
let monogram: String
|
|
let tintHue: Double
|
|
|
|
init(id: String, name: String, emoji: String?, avatar: QuickChatAgentAvatar = .none) {
|
|
self.id = id
|
|
self.name = name
|
|
self.emoji = emoji
|
|
self.avatar = avatar
|
|
let monogramSource = name.isEmpty ? id : name
|
|
self.monogram = String(monogramSource.prefix(1)).uppercased()
|
|
self.tintHue = Self.stableTintHue(for: id)
|
|
}
|
|
|
|
init(summary: AgentSummary) {
|
|
let emoji = (summary.identity?["emoji"]?.value as? String)?.nonEmptyTrimmed
|
|
let avatarRendered = summary.identity?["avatarUrl"]?.value as? String
|
|
self.init(
|
|
id: summary.id,
|
|
name: summary.name ?? summary.id,
|
|
emoji: emoji,
|
|
avatar: Self.avatar(fromRendered: avatarRendered))
|
|
}
|
|
|
|
static func avatar(fromRendered rawValue: String?) -> QuickChatAgentAvatar {
|
|
guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!rawValue.isEmpty
|
|
else { return .none }
|
|
|
|
if rawValue.lowercased().hasPrefix("data:") {
|
|
guard let comma = rawValue.firstIndex(of: ",") else { return .none }
|
|
let metadata = rawValue[..<comma].lowercased()
|
|
guard metadata.hasSuffix(";base64") else { return .none }
|
|
let payload = rawValue[rawValue.index(after: comma)...]
|
|
let maximumEncodedBytes = ((Self.maximumAvatarBytes + 2) / 3) * 4 + 4
|
|
guard payload.utf8.count <= maximumEncodedBytes,
|
|
let data = Data(base64Encoded: String(payload), options: .ignoreUnknownCharacters),
|
|
data.count <= Self.maximumAvatarBytes
|
|
else { return .none }
|
|
return .image(data)
|
|
}
|
|
|
|
// Remote http(s) avatar URLs are deliberately not fetched: a gateway-supplied URL
|
|
// must not turn this app into a blind request primitive against local networks.
|
|
// Local avatar files already arrive as data URIs; everything else falls back.
|
|
return .none
|
|
}
|
|
|
|
private static func stableTintHue(for id: String) -> Double {
|
|
var hash: UInt64 = 14_695_981_039_346_656_037
|
|
for byte in id.utf8 {
|
|
hash ^= UInt64(byte)
|
|
hash &*= 1_099_511_628_211
|
|
}
|
|
return Double(hash % 360) / 360
|
|
}
|
|
}
|
|
|
|
struct QuickChatRoutingTarget: Equatable, Hashable, Sendable {
|
|
let sessionKey: String
|
|
let agentID: String?
|
|
}
|
|
|
|
struct QuickChatSessionTargetOverride: Equatable, Hashable, Sendable {
|
|
let key: String
|
|
let displayName: String
|
|
}
|
|
|
|
enum QuickChatConnectionGate: Equatable {
|
|
case available
|
|
case unconfigured
|
|
case paused
|
|
case disconnected
|
|
}
|
|
|
|
enum QuickChatSendState: Equatable {
|
|
case idle
|
|
case sending
|
|
case sent
|
|
case failed(String)
|
|
}
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class QuickChatModel {
|
|
private struct RetryIdentity {
|
|
let draft: String
|
|
let message: String
|
|
let thinking: String?
|
|
let sessionKey: String
|
|
let agentID: String?
|
|
let attachments: [OpenClawChatAttachmentPayload]
|
|
let idempotencyKey: String
|
|
}
|
|
|
|
typealias SessionKeyProvider = @MainActor () async -> String
|
|
typealias AgentsProvider = @MainActor () async throws -> AgentsListResult
|
|
typealias AgentIdentityProvider = @MainActor (String) async throws -> QuickChatAgentDisplay
|
|
typealias SendProvider = @MainActor (
|
|
String,
|
|
String?,
|
|
String,
|
|
String?,
|
|
String,
|
|
[OpenClawChatAttachmentPayload]) async throws -> String
|
|
typealias PermissionStatusProvider = @MainActor ([Capability]) async -> [Capability: Bool]
|
|
typealias PermissionGrantProvider = @MainActor ([Capability]) async -> [Capability: Bool]
|
|
typealias ConnectionGateProvider = @MainActor () -> QuickChatConnectionGate
|
|
typealias FrontmostAppNameProvider = @MainActor () -> String
|
|
typealias TextContextCaptureProvider = @MainActor () async -> QuickChatTextContextCaptureOutcome
|
|
typealias ModelControlsProvider = @MainActor (
|
|
QuickChatRoutingTarget) async throws -> QuickChatModelControlSnapshot
|
|
typealias ModelPatchProvider = @MainActor (
|
|
QuickChatRoutingTarget,
|
|
String?) async throws -> OpenClawChatModelPatchResult?
|
|
|
|
static let trackedPermissions: [Capability] = [.notifications, .accessibility, .screenRecording]
|
|
|
|
var text = "" {
|
|
didSet {
|
|
if !self.text.isEmpty, self.sendState == .sent {
|
|
self.sendState = .idle
|
|
}
|
|
if self.sendTask == nil, let retryIdentity = self.retryIdentity, retryIdentity.draft != self.text {
|
|
self.retryIdentity = nil
|
|
}
|
|
if !self.isDictating, !self.isStartingDictation, self.dictationTextSession == nil {
|
|
self.dictationSelectionRange = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
private(set) var sessionKey = ""
|
|
private(set) var sendAgentID: String?
|
|
private(set) var targetSessionOverride: QuickChatSessionTargetOverride?
|
|
private(set) var agents: [QuickChatAgentDisplay] = []
|
|
private(set) var defaultAgentID: String?
|
|
private(set) var selectedAgentID: String?
|
|
private(set) var agentDisplay = QuickChatAgentDisplay.placeholder
|
|
private(set) var missingPermissions: [Capability] = []
|
|
private(set) var permissionsDismissedThisSession = false
|
|
private(set) var isGrantingPermissions = false
|
|
private(set) var sendState: QuickChatSendState = .idle
|
|
private(set) var isPresentationActive = false
|
|
private(set) var frontmostAppName = String(localized: "focused app")
|
|
private(set) var textContext: QuickChatTextContext?
|
|
private(set) var isCapturingTextContext = false
|
|
private(set) var textContextCaptureMessage: String?
|
|
private(set) var isStartingDictation = false
|
|
private(set) var isDictating = false
|
|
private(set) var dictationStatusMessage: String?
|
|
private(set) var dictationSelectionRange: NSRange?
|
|
private(set) var modelChoices: [OpenClawChatModelChoice] = []
|
|
private(set) var currentSessionModelSelectionID: String?
|
|
private(set) var currentSessionThinkingLevel: String?
|
|
private(set) var thinkingOptions = QuickChatModelControlLogic.baseThinkingOptions
|
|
private(set) var selectedModelSelectionID: String?
|
|
private(set) var selectedThinkingLevel: String?
|
|
private(set) var modelDefaultProvider: String?
|
|
private(set) var isLoadingModelControls = false
|
|
private(set) var isUpdatingModel = false
|
|
private(set) var modelControlStatusMessage: String?
|
|
/// Route of the most recently accepted send; navigation reads this immutable value
|
|
/// instead of sampling live routing state that an agent switch could move meanwhile.
|
|
private(set) var lastAcceptedRoute: QuickChatRoutingTarget?
|
|
private(set) var lastAcceptedIdempotencyKey: String?
|
|
|
|
@ObservationIgnored private let sessionKeyProvider: SessionKeyProvider
|
|
@ObservationIgnored private let agentsProvider: AgentsProvider
|
|
@ObservationIgnored private let agentIdentityProvider: AgentIdentityProvider
|
|
@ObservationIgnored private let sendProvider: SendProvider
|
|
@ObservationIgnored private let permissionStatusProvider: PermissionStatusProvider
|
|
@ObservationIgnored private let permissionGrantProvider: PermissionGrantProvider
|
|
@ObservationIgnored private let connectionGateProvider: ConnectionGateProvider
|
|
@ObservationIgnored private let frontmostAppNameProvider: FrontmostAppNameProvider
|
|
@ObservationIgnored private let textContextCaptureProvider: TextContextCaptureProvider
|
|
@ObservationIgnored private let modelControlsProvider: ModelControlsProvider
|
|
@ObservationIgnored private let modelPatchProvider: ModelPatchProvider
|
|
/// Invoked with the snapshotted route just before a send is dispatched, for every
|
|
/// send path (text and capture); wires the reply consumer's pre-bind.
|
|
@ObservationIgnored var onSendDispatched: ((QuickChatRoutingTarget) -> Void)?
|
|
@ObservationIgnored private var presentationID = UUID()
|
|
@ObservationIgnored private var agentsScope: String?
|
|
@ObservationIgnored private var agentsMainKey: String?
|
|
@ObservationIgnored private var baseRoutingTarget: QuickChatRoutingTarget?
|
|
@ObservationIgnored private var sendTask: Task<String, Error>?
|
|
@ObservationIgnored private var permissionTask: Task<Void, Never>?
|
|
@ObservationIgnored private var permissionPollTask: Task<Void, Never>?
|
|
@ObservationIgnored private var retryIdentity: RetryIdentity?
|
|
@ObservationIgnored private var capturePipelineID: UUID?
|
|
@ObservationIgnored private var textContextCaptureID: UUID?
|
|
@ObservationIgnored private var textContextCaptureTask: Task<Void, Never>?
|
|
@ObservationIgnored private var dictationTextSession: QuickChatDictationTextSession?
|
|
@ObservationIgnored private var modelControlsTask: Task<Void, Never>?
|
|
@ObservationIgnored private var modelControlsRequestID = UUID()
|
|
@ObservationIgnored private var modelPatchTask: Task<OpenClawChatModelPatchResult?, Error>?
|
|
@ObservationIgnored private var modelPatchTarget: QuickChatRoutingTarget?
|
|
@ObservationIgnored private var modelPatchSelectionID: String?
|
|
@ObservationIgnored private var modelPatchRequestID = UUID()
|
|
@ObservationIgnored private var appliedModelSelections: [QuickChatRoutingTarget: String] = [:]
|
|
|
|
init(
|
|
sessionKeyProvider: @escaping SessionKeyProvider = {
|
|
await GatewayConnection.shared.mainSessionKey()
|
|
},
|
|
agentsProvider: @escaping AgentsProvider = {
|
|
try await GatewayConnection.shared.agentsList()
|
|
},
|
|
agentIdentityProvider: @escaping AgentIdentityProvider = { sessionKey in
|
|
let identity = try await GatewayConnection.shared.agentIdentity(sessionKey: sessionKey)
|
|
let name = identity.name?.nonEmptyTrimmed ?? identity.agentid
|
|
let emoji = identity.emoji?.nonEmptyTrimmed
|
|
return QuickChatAgentDisplay(
|
|
id: identity.agentid,
|
|
name: name,
|
|
emoji: emoji,
|
|
avatar: QuickChatAgentDisplay.avatar(fromRendered: identity.avatar))
|
|
},
|
|
sendProvider: @escaping SendProvider = { sessionKey, agentID, message, thinking, idempotencyKey, attachments in
|
|
let response = try await GatewayConnection.shared.chatSend(
|
|
sessionKey: sessionKey,
|
|
agentID: agentID,
|
|
message: message,
|
|
thinking: thinking,
|
|
idempotencyKey: idempotencyKey,
|
|
attachments: attachments)
|
|
return response.status
|
|
},
|
|
permissionStatusProvider: @escaping PermissionStatusProvider = { capabilities in
|
|
await PermissionManager.status(capabilities)
|
|
},
|
|
permissionGrantProvider: @escaping PermissionGrantProvider = { capabilities in
|
|
await PermissionManager.ensure(capabilities, interactive: true)
|
|
},
|
|
connectionGateProvider: @escaping ConnectionGateProvider = {
|
|
let appState = AppStateStore.shared
|
|
if appState.connectionMode == .unconfigured { return .unconfigured }
|
|
if appState.isPaused { return .paused }
|
|
if ControlChannel.shared.state != .connected { return .disconnected }
|
|
return .available
|
|
},
|
|
frontmostAppNameProvider: @escaping FrontmostAppNameProvider = {
|
|
QuickChatFocusedTextCaptureService.frontmostApplicationName()
|
|
},
|
|
textContextCaptureProvider: @escaping TextContextCaptureProvider = {
|
|
await QuickChatFocusedTextCaptureService.capture()
|
|
},
|
|
modelControlsProvider: @escaping ModelControlsProvider = { target in
|
|
let transport = MacGatewayChatTransport(defaultGlobalAgentID: target.agentID)
|
|
async let models = transport.listModels()
|
|
async let sessions = transport.listSessions(limit: 200, search: target.sessionKey, archived: false)
|
|
async let agents = GatewayConnection.shared.agentsList()
|
|
return try await QuickChatModelControlLogic.snapshot(
|
|
target: target,
|
|
models: models,
|
|
sessions: sessions,
|
|
agents: agents)
|
|
},
|
|
modelPatchProvider: @escaping ModelPatchProvider = { target, model in
|
|
let transport = MacGatewayChatTransport(defaultGlobalAgentID: target.agentID)
|
|
return try await transport.patchSessionSettings(
|
|
sessionKey: target.sessionKey,
|
|
agentID: target.agentID,
|
|
patch: OpenClawChatSessionSettingsPatch(model: .some(model)))
|
|
})
|
|
{
|
|
self.sessionKeyProvider = sessionKeyProvider
|
|
self.agentsProvider = agentsProvider
|
|
self.agentIdentityProvider = agentIdentityProvider
|
|
self.sendProvider = sendProvider
|
|
self.permissionStatusProvider = permissionStatusProvider
|
|
self.permissionGrantProvider = permissionGrantProvider
|
|
self.connectionGateProvider = connectionGateProvider
|
|
self.frontmostAppNameProvider = frontmostAppNameProvider
|
|
self.textContextCaptureProvider = textContextCaptureProvider
|
|
self.modelControlsProvider = modelControlsProvider
|
|
self.modelPatchProvider = modelPatchProvider
|
|
}
|
|
|
|
var connectionGate: QuickChatConnectionGate {
|
|
self.connectionGateProvider()
|
|
}
|
|
|
|
var connectionStatusMessage: String? {
|
|
switch self.connectionGate {
|
|
case .available: nil
|
|
case .unconfigured: "Not configured"
|
|
case .paused: "OpenClaw is paused"
|
|
case .disconnected: "Gateway disconnected"
|
|
}
|
|
}
|
|
|
|
var shouldShowPermissionStrip: Bool {
|
|
!self.permissionsDismissedThisSession && !self.missingPermissions.isEmpty
|
|
}
|
|
|
|
var canSend: Bool {
|
|
let hasPendingOverrideState = self.selectedModelSelectionID != nil || self.selectedThinkingLevel != nil
|
|
return (!self.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || self.textContext != nil) &&
|
|
!self.sessionKey.isEmpty &&
|
|
self.connectionGate == .available &&
|
|
!self.isCapturingTextContext &&
|
|
!self.isStartingDictation &&
|
|
!self.isUpdatingModel &&
|
|
(!hasPendingOverrideState || !self.isLoadingModelControls) &&
|
|
self.isSelectedThinkingLevelSupported &&
|
|
self.sendState != .sending
|
|
}
|
|
|
|
var canCaptureWindow: Bool {
|
|
!self.sessionKey.isEmpty &&
|
|
self.connectionGate == .available &&
|
|
!self.isGrantingPermissions &&
|
|
!self.isCapturingTextContext &&
|
|
!self.isStartingDictation &&
|
|
!self.isDictating &&
|
|
!self.isUpdatingModel &&
|
|
self.sendState != .sending
|
|
}
|
|
|
|
var canCaptureTextContext: Bool {
|
|
self.canCaptureWindow && !self.isCapturingTextContext
|
|
}
|
|
|
|
var canSelectRecentSession: Bool {
|
|
!self.sessionKey.isEmpty &&
|
|
self.connectionGate == .available &&
|
|
!self.isGrantingPermissions &&
|
|
!self.isCapturingTextContext &&
|
|
!self.isStartingDictation &&
|
|
!self.isDictating &&
|
|
!self.isUpdatingModel &&
|
|
self.sendState != .sending
|
|
}
|
|
|
|
var canToggleDictation: Bool {
|
|
self.isDictating || self.isStartingDictation || (
|
|
!self.sessionKey.isEmpty &&
|
|
self.connectionGate == .available &&
|
|
!self.isGrantingPermissions &&
|
|
!self.isCapturingTextContext &&
|
|
!self.isUpdatingModel &&
|
|
self.sendState != .sending)
|
|
}
|
|
|
|
var messagePlaceholder: String {
|
|
if let targetSessionOverride {
|
|
return "Reply in \(targetSessionOverride.displayName)"
|
|
}
|
|
return "Message \(self.agentDisplay.name)"
|
|
}
|
|
|
|
var routingTarget: QuickChatRoutingTarget? {
|
|
guard !self.sessionKey.isEmpty else { return nil }
|
|
return QuickChatRoutingTarget(sessionKey: self.sessionKey, agentID: self.sendAgentID)
|
|
}
|
|
|
|
var activePresentationID: UUID? {
|
|
self.isPresentationActive ? self.presentationID : nil
|
|
}
|
|
|
|
func beginPresentation() -> UUID {
|
|
self.presentationID = UUID()
|
|
self.isPresentationActive = true
|
|
self.sessionKey = ""
|
|
self.sendAgentID = nil
|
|
self.frontmostAppName = self.frontmostAppNameProvider()
|
|
self.textContext = nil
|
|
self.textContextCaptureMessage = nil
|
|
self.cancelTextContextCapture()
|
|
self.resetDictationState()
|
|
self.cancelModelControlRefresh()
|
|
self.appliedModelSelections.removeAll()
|
|
self.selectedModelSelectionID = nil
|
|
self.selectedThinkingLevel = nil
|
|
self.currentSessionModelSelectionID = nil
|
|
self.currentSessionThinkingLevel = nil
|
|
self.modelControlStatusMessage = nil
|
|
self.targetSessionOverride = nil
|
|
self.baseRoutingTarget = nil
|
|
// The cached list stays displayable, but routing metadata must wait for the fresh
|
|
// contract: selecting from a stale scope/mainKey could target an obsolete session.
|
|
self.agentsScope = nil
|
|
self.agentsMainKey = nil
|
|
if self.sendTask == nil { self.sendState = .idle }
|
|
self.startPermissionPolling(id: self.presentationID)
|
|
return self.presentationID
|
|
}
|
|
|
|
func refreshForPresentation(id: UUID) async {
|
|
guard self.isCurrentPresentation(id) else { return }
|
|
async let permissionStatus = self.permissionStatusProvider(Self.trackedPermissions)
|
|
|
|
let agentsResult: Result<AgentsListResult, Error>
|
|
do {
|
|
agentsResult = try await .success(self.agentsProvider())
|
|
} catch {
|
|
agentsResult = .failure(error)
|
|
}
|
|
|
|
let status = await permissionStatus
|
|
guard self.isCurrentPresentation(id), !Task.isCancelled else { return }
|
|
self.applyPermissionStatus(status)
|
|
|
|
switch agentsResult {
|
|
case let .success(result):
|
|
self.applyAgentsList(result)
|
|
case .failure:
|
|
await self.refreshFallbackIdentity(id: id)
|
|
}
|
|
}
|
|
|
|
func selectAgent(_ id: String) {
|
|
// A held or in-flight send is already routed; switching now would reroute a
|
|
// screenshot captured for the previously selected agent.
|
|
guard self.sendState != .sending,
|
|
!self.isUpdatingModel,
|
|
let display = self.agents.first(where: { $0.id == id }),
|
|
let mainKey = self.agentsMainKey
|
|
else { return }
|
|
// Agent and recent-session targeting are mutually exclusive: keeping both would
|
|
// make the avatar advertise one destination while sends go somewhere else.
|
|
self.targetSessionOverride = nil
|
|
self.selectedAgentID = id
|
|
self.agentDisplay = display
|
|
let target = Self.routingTarget(scope: self.agentsScope, selectedAgentID: id, mainKey: mainKey)
|
|
self.baseRoutingTarget = target
|
|
self.applyRoutingTarget()
|
|
}
|
|
|
|
func selectSessionOverride(_ target: QuickChatSessionTargetOverride?) {
|
|
guard self.sendState != .sending, !self.isUpdatingModel else { return }
|
|
self.targetSessionOverride = target
|
|
self.applyRoutingTarget()
|
|
}
|
|
|
|
func dismissPermissionsForSession() {
|
|
self.permissionsDismissedThisSession = true
|
|
}
|
|
|
|
func grantMissingPermissions() {
|
|
let capabilities = self.missingPermissions
|
|
guard !capabilities.isEmpty, !self.isGrantingPermissions else { return }
|
|
let id = self.presentationID
|
|
self.permissionTask?.cancel()
|
|
self.isGrantingPermissions = true
|
|
self.permissionTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
_ = await self.permissionGrantProvider(capabilities)
|
|
guard self.isCurrentPresentation(id), !Task.isCancelled else { return }
|
|
await self.recheckPermissions(id: id)
|
|
guard self.isCurrentPresentation(id), !Task.isCancelled else { return }
|
|
self.isGrantingPermissions = false
|
|
self.permissionTask = nil
|
|
}
|
|
}
|
|
|
|
func prepareDictation(selection: NSRange) -> Bool {
|
|
guard self.canToggleDictation, !self.isDictating, !self.isStartingDictation else { return false }
|
|
self.dictationTextSession = QuickChatDictationTextSession(
|
|
baseText: self.text,
|
|
replacementRange: selection)
|
|
self.dictationSelectionRange = nil
|
|
self.dictationStatusMessage = nil
|
|
self.isStartingDictation = true
|
|
return true
|
|
}
|
|
|
|
func dictationDidStart() {
|
|
guard self.isStartingDictation else { return }
|
|
self.isStartingDictation = false
|
|
self.isDictating = true
|
|
}
|
|
|
|
func applyDictationTranscript(_ transcript: String) {
|
|
guard self.isDictating, let dictationTextSession else { return }
|
|
let update = dictationTextSession.update(transcript: transcript)
|
|
self.dictationSelectionRange = update.selection
|
|
self.text = update.text
|
|
}
|
|
|
|
func stopDictation() {
|
|
self.isStartingDictation = false
|
|
self.isDictating = false
|
|
self.dictationTextSession = nil
|
|
}
|
|
|
|
func failDictation(message: String) {
|
|
self.stopDictation()
|
|
self.dictationStatusMessage = message
|
|
}
|
|
|
|
func selectThinkingLevel(_ level: String?) {
|
|
let normalized = level?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
let selection = normalized?.isEmpty == false ? normalized : nil
|
|
guard selection != self.selectedThinkingLevel else { return }
|
|
self.selectedThinkingLevel = selection
|
|
self.modelControlStatusMessage = nil
|
|
self.retryIdentity = nil
|
|
}
|
|
|
|
func selectModel(_ selectionID: String) {
|
|
guard self.canUseModelControls, !self.isUpdatingModel, let target = self.routingTarget else { return }
|
|
let normalized = selectionID.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard normalized == OpenClawChatViewModel.defaultModelSelectionID ||
|
|
self.modelChoices.contains(where: { $0.selectionID == normalized })
|
|
else { return }
|
|
guard normalized != self.selectedModelSelectionID else { return }
|
|
let patchDecision = QuickChatModelControlLogic.modelPatchDecision(
|
|
selectionID: normalized,
|
|
appliedSelectionID: self.appliedModelSelections[target],
|
|
currentSessionSelectionID: self.currentSessionModelSelectionID)
|
|
self.selectedModelSelectionID = normalized
|
|
self.modelControlStatusMessage = nil
|
|
if patchDecision != .none {
|
|
self.retryIdentity = nil
|
|
}
|
|
self.startSelectedModelPatchIfNeeded(selectionID: normalized, target: target)
|
|
}
|
|
|
|
func send() async -> Bool {
|
|
await self.performSend(messageOverride: nil, attachments: [])
|
|
}
|
|
|
|
func captureFocusedAppText() {
|
|
guard self.canCaptureTextContext, self.isPresentationActive else { return }
|
|
let captureID = UUID()
|
|
let presentationID = self.presentationID
|
|
self.textContextCaptureID = captureID
|
|
self.isCapturingTextContext = true
|
|
self.textContextCaptureMessage = nil
|
|
self.textContextCaptureTask?.cancel()
|
|
self.textContextCaptureTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
let outcome = await self.textContextCaptureProvider()
|
|
guard self.isCurrentPresentation(presentationID),
|
|
self.textContextCaptureID == captureID,
|
|
!Task.isCancelled
|
|
else { return }
|
|
self.textContextCaptureID = nil
|
|
self.textContextCaptureTask = nil
|
|
self.isCapturingTextContext = false
|
|
switch outcome {
|
|
case let .captured(context):
|
|
self.replaceTextContext(context)
|
|
case let .failed(message):
|
|
self.textContextCaptureMessage = message
|
|
case .cancelled:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func replaceTextContext(_ context: QuickChatTextContext) {
|
|
self.textContext = context
|
|
self.textContextCaptureMessage = nil
|
|
self.retryIdentity = nil
|
|
}
|
|
|
|
func clearTextContext() {
|
|
self.textContext = nil
|
|
self.retryIdentity = nil
|
|
}
|
|
|
|
/// Holds the send state for the whole capture pipeline so typing/Return cannot race
|
|
/// the screenshot send. Returns an ownership token (nil when a send is already active);
|
|
/// detached processing can outlive cancellation, so every later pipeline mutation must
|
|
/// present this token or it could reset a newer pipeline's held state.
|
|
func beginCapturePipeline() -> UUID? {
|
|
guard self.sendState != .sending else { return nil }
|
|
let id = UUID()
|
|
self.capturePipelineID = id
|
|
self.sendState = .sending
|
|
return id
|
|
}
|
|
|
|
func cancelCapturePipeline(_ id: UUID) {
|
|
guard self.capturePipelineID == id else { return }
|
|
self.capturePipelineID = nil
|
|
if self.sendState == .sending, self.sendTask == nil {
|
|
self.sendState = .idle
|
|
}
|
|
}
|
|
|
|
func failCapturePipeline(_ id: UUID) {
|
|
guard self.capturePipelineID == id else { return }
|
|
self.capturePipelineID = nil
|
|
self.sendState = .failed(String(localized: "Couldn't capture that image."))
|
|
}
|
|
|
|
/// Pre-pipeline capture failures (enumeration, no candidates). Never clobbers a held
|
|
/// send state; those failures belong to their owning pipeline token.
|
|
func setCaptureFailure() {
|
|
guard self.sendState != .sending else { return }
|
|
self.sendState = .failed(String(localized: "Couldn't capture that image."))
|
|
}
|
|
|
|
func sendCapturedImage(
|
|
pipelineID: UUID,
|
|
data: Data,
|
|
label: String,
|
|
fileName: String) async -> Bool
|
|
{
|
|
// Bind the pipeline to the route visible when the user completed the selection; an agent
|
|
// switch or re-presentation during processing must drop the capture, not reroute it.
|
|
guard self.capturePipelineID == pipelineID,
|
|
let presentationID = self.activePresentationID
|
|
else { return false }
|
|
let sessionKey = self.sessionKey
|
|
let agentID = self.sendAgentID
|
|
guard !sessionKey.isEmpty else {
|
|
self.cancelCapturePipeline(pipelineID)
|
|
return false
|
|
}
|
|
let draft = self.text
|
|
let trimmedDraft = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let baseMessage = trimmedDraft.isEmpty ? String(localized: "Screenshot: \(label)") : trimmedDraft
|
|
let textContext = self.textContext
|
|
let message = Self.assembleMessage(draft: baseMessage, context: textContext)
|
|
|
|
let attachment: OpenClawChatAttachmentPayload
|
|
do {
|
|
attachment = try await Task.detached(priority: .userInitiated) {
|
|
let processed = try ChatImageProcessor.processForUpload(data: data)
|
|
return OpenClawChatAttachmentPayload(
|
|
type: "file",
|
|
mimeType: "image/jpeg",
|
|
fileName: fileName,
|
|
content: processed.base64EncodedString())
|
|
}.value
|
|
} catch {
|
|
self.failCapturePipeline(pipelineID)
|
|
return false
|
|
}
|
|
|
|
guard self.capturePipelineID == pipelineID,
|
|
self.activePresentationID == presentationID,
|
|
self.sessionKey == sessionKey,
|
|
self.sendAgentID == agentID,
|
|
!Task.isCancelled
|
|
else {
|
|
self.cancelCapturePipeline(pipelineID)
|
|
return false
|
|
}
|
|
let accepted = await self.performSend(
|
|
messageOverride: message,
|
|
attachments: [attachment],
|
|
draftOverride: draft,
|
|
textContextOverride: textContext,
|
|
continuesCapturePipeline: true)
|
|
if self.capturePipelineID == pipelineID {
|
|
self.capturePipelineID = nil
|
|
// performSend left the held state untouched only on its early guard failure.
|
|
if !accepted, self.sendState == .sending, self.sendTask == nil {
|
|
self.sendState = .idle
|
|
}
|
|
}
|
|
return accepted
|
|
}
|
|
|
|
nonisolated static func routingTarget(
|
|
scope: String?,
|
|
selectedAgentID: String,
|
|
mainKey: String) -> QuickChatRoutingTarget
|
|
{
|
|
if scope?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "global" {
|
|
return QuickChatRoutingTarget(sessionKey: "global", agentID: selectedAgentID)
|
|
}
|
|
// Canonical agent keys already encode ownership; a redundant agentId is rejected.
|
|
return QuickChatRoutingTarget(
|
|
sessionKey: "agent:\(selectedAgentID):\(mainKey)",
|
|
agentID: nil)
|
|
}
|
|
|
|
nonisolated static func assembleMessage(draft: String, context: QuickChatTextContext?) -> String {
|
|
let trimmedDraft = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let context else { return trimmedDraft }
|
|
let contextLabel = String(localized: "Context from \(context.appName) — \(context.windowTitle)")
|
|
let contextBlock = "[\(contextLabel)]\n\(context.text)"
|
|
return trimmedDraft.isEmpty ? contextBlock : "\(trimmedDraft)\n\n\(contextBlock)"
|
|
}
|
|
|
|
nonisolated static func routingTarget(
|
|
override: QuickChatSessionTargetOverride?,
|
|
base: QuickChatRoutingTarget) -> QuickChatRoutingTarget
|
|
{
|
|
guard let override else { return base }
|
|
// sessions.list preserves the bare global sentinel. It needs the same explicit
|
|
// agent owner as the selected base route when session scope is global.
|
|
let agentID = override.key.lowercased() == "global" ? base.agentID : nil
|
|
return QuickChatRoutingTarget(sessionKey: override.key, agentID: agentID)
|
|
}
|
|
|
|
nonisolated static func screenshotFileName(appName: String) -> String {
|
|
let scalarValues = appName.lowercased().unicodeScalars.map { scalar -> Character in
|
|
CharacterSet.alphanumerics.contains(scalar) ? Character(String(scalar)) : "-"
|
|
}
|
|
let sanitized = String(scalarValues)
|
|
.split(separator: "-", omittingEmptySubsequences: true)
|
|
.joined(separator: "-")
|
|
return "window-\(sanitized.isEmpty ? "screenshot" : sanitized).jpg"
|
|
}
|
|
|
|
func endPresentation() {
|
|
self.isPresentationActive = false
|
|
self.presentationID = UUID()
|
|
// A quick bar target is presentation-scoped. Never carry a stale recent session
|
|
// into the next invocation where the compact UI no longer explains that choice.
|
|
self.targetSessionOverride = nil
|
|
self.baseRoutingTarget = nil
|
|
self.sessionKey = ""
|
|
self.sendAgentID = nil
|
|
// Captured AX text is presentation-scoped: hiding the bar must erase it so a
|
|
// later recipient can never inherit stale focused-app context.
|
|
self.clearTextContext()
|
|
self.textContextCaptureMessage = nil
|
|
self.cancelTextContextCapture()
|
|
self.resetDictationState()
|
|
// Model patches are persistent session mutations. Let an accepted selection
|
|
// settle even though its presentation-scoped controls are being discarded.
|
|
self.cancelModelControlRefresh()
|
|
self.appliedModelSelections.removeAll()
|
|
self.selectedModelSelectionID = nil
|
|
self.selectedThinkingLevel = nil
|
|
self.currentSessionModelSelectionID = nil
|
|
self.currentSessionThinkingLevel = nil
|
|
self.modelControlStatusMessage = nil
|
|
// A dispatched chat.send may already be accepted; cancelling and retrying with a new UUID can duplicate it.
|
|
self.cancelPermissionTask()
|
|
self.cancelPermissionPolling()
|
|
}
|
|
|
|
func cancelAllTasks() {
|
|
self.sendTask?.cancel()
|
|
self.sendTask = nil
|
|
self.retryIdentity = nil
|
|
self.cancelPermissionTask()
|
|
self.cancelPermissionPolling()
|
|
self.cancelTextContextCapture()
|
|
self.cancelModelControlRefresh()
|
|
self.resetDictationState()
|
|
if self.sendState == .sending { self.sendState = .idle }
|
|
}
|
|
|
|
private func applyAgentsList(_ result: AgentsListResult) {
|
|
let displays = result.agents.map(QuickChatAgentDisplay.init(summary:))
|
|
let selectedID: String? = if let selectedAgentID,
|
|
displays.contains(where: { $0.id == selectedAgentID })
|
|
{
|
|
selectedAgentID
|
|
} else if displays.contains(where: { $0.id == result.defaultid }) {
|
|
result.defaultid
|
|
} else {
|
|
displays.first?.id
|
|
}
|
|
|
|
self.agents = displays
|
|
self.defaultAgentID = result.defaultid
|
|
self.selectedAgentID = selectedID
|
|
self.agentsScope = result.scope.value as? String
|
|
self.agentsMainKey = result.mainkey
|
|
|
|
guard let selectedID,
|
|
let display = displays.first(where: { $0.id == selectedID })
|
|
else {
|
|
self.agentDisplay = .placeholder
|
|
self.baseRoutingTarget = nil
|
|
self.sessionKey = ""
|
|
self.sendAgentID = nil
|
|
return
|
|
}
|
|
self.agentDisplay = display
|
|
let target = Self.routingTarget(
|
|
scope: self.agentsScope,
|
|
selectedAgentID: selectedID,
|
|
mainKey: result.mainkey)
|
|
self.baseRoutingTarget = target
|
|
self.applyRoutingTarget()
|
|
}
|
|
|
|
private func refreshFallbackIdentity(id: UUID) async {
|
|
let resolvedSessionKey = await self.sessionKeyProvider()
|
|
guard self.isCurrentPresentation(id), !Task.isCancelled else { return }
|
|
self.baseRoutingTarget = QuickChatRoutingTarget(sessionKey: resolvedSessionKey, agentID: nil)
|
|
self.applyRoutingTarget()
|
|
self.agents = []
|
|
self.defaultAgentID = nil
|
|
self.selectedAgentID = nil
|
|
self.agentsScope = nil
|
|
self.agentsMainKey = nil
|
|
self.agentDisplay = .placeholder
|
|
|
|
do {
|
|
let display = try await self.agentIdentityProvider(resolvedSessionKey)
|
|
guard self.isCurrentPresentation(id), !Task.isCancelled else { return }
|
|
self.agentDisplay = display
|
|
self.agents = [display]
|
|
self.defaultAgentID = display.id
|
|
self.selectedAgentID = display.id
|
|
} catch {
|
|
// The fallback session remains sendable even when its optional identity cannot load.
|
|
}
|
|
}
|
|
|
|
private func applyRoutingTarget() {
|
|
guard let baseRoutingTarget else {
|
|
self.sessionKey = ""
|
|
self.sendAgentID = nil
|
|
self.cancelModelControlRefresh()
|
|
return
|
|
}
|
|
let target = Self.routingTarget(override: self.targetSessionOverride, base: baseRoutingTarget)
|
|
let previousTarget = self.routingTarget
|
|
self.sessionKey = target.sessionKey
|
|
self.sendAgentID = target.agentID
|
|
if previousTarget != target {
|
|
// Explicit overrides survive target changes, but the target's underlying
|
|
// session state must be read again before the compact control claims a value.
|
|
self.currentSessionModelSelectionID = nil
|
|
self.currentSessionThinkingLevel = nil
|
|
self.modelDefaultProvider = nil
|
|
self.thinkingOptions = []
|
|
}
|
|
self.refreshModelControls(for: target)
|
|
}
|
|
|
|
private func performSend(
|
|
messageOverride: String?,
|
|
attachments: [OpenClawChatAttachmentPayload],
|
|
draftOverride: String? = nil,
|
|
textContextOverride: QuickChatTextContext? = nil,
|
|
continuesCapturePipeline: Bool = false) async -> Bool
|
|
{
|
|
guard let presentationID = self.activePresentationID else { return false }
|
|
// The capture pipeline captured its draft before detached processing; edits made
|
|
// meanwhile must survive, so the clear-decision compares against that original.
|
|
let draft = draftOverride ?? self.text
|
|
let textContext = textContextOverride ?? self.textContext
|
|
let message = messageOverride ?? Self.assembleMessage(draft: draft, context: textContext)
|
|
guard !message.isEmpty, !self.sessionKey.isEmpty, self.connectionGate == .available else {
|
|
// The capture pipeline unwinds its own held state after this returns false.
|
|
return false
|
|
}
|
|
guard continuesCapturePipeline || self.sendState != .sending else { return false }
|
|
|
|
let sessionKey = self.sessionKey
|
|
let agentID = self.sendAgentID
|
|
let route = QuickChatRoutingTarget(sessionKey: sessionKey, agentID: agentID)
|
|
let selectedModelSelectionID = self.selectedModelSelectionID
|
|
guard await self.applySelectedModelIfNeeded(
|
|
to: route,
|
|
selectionID: selectedModelSelectionID)
|
|
else { return false }
|
|
guard !Task.isCancelled,
|
|
self.isCurrentPresentation(presentationID),
|
|
self.routingTarget == route,
|
|
self.connectionGate == .available,
|
|
self.isSelectedThinkingLevelSupported,
|
|
continuesCapturePipeline || self.sendState != .sending
|
|
else { return false }
|
|
let thinking = self.selectedThinkingLevel
|
|
self.lastAcceptedIdempotencyKey = nil
|
|
// Pre-bind the reply consumer for every send path (text and screenshots): a
|
|
// fast turn must not emit frames before the reply view model starts listening.
|
|
self.onSendDispatched?(route)
|
|
let idempotencyKey: String
|
|
if let retryIdentity = self.retryIdentity,
|
|
retryIdentity.draft == draft,
|
|
retryIdentity.message == message,
|
|
retryIdentity.thinking == thinking,
|
|
retryIdentity.sessionKey == sessionKey,
|
|
retryIdentity.agentID == agentID,
|
|
retryIdentity.attachments == attachments
|
|
{
|
|
idempotencyKey = retryIdentity.idempotencyKey
|
|
} else {
|
|
idempotencyKey = UUID().uuidString
|
|
self.retryIdentity = RetryIdentity(
|
|
draft: draft,
|
|
message: message,
|
|
thinking: thinking,
|
|
sessionKey: sessionKey,
|
|
agentID: agentID,
|
|
attachments: attachments,
|
|
idempotencyKey: idempotencyKey)
|
|
}
|
|
let task = Task {
|
|
try await self.sendProvider(sessionKey, agentID, message, thinking, idempotencyKey, attachments)
|
|
}
|
|
self.sendTask = task
|
|
self.sendState = .sending
|
|
do {
|
|
let status = try await task.value
|
|
self.sendTask = nil
|
|
switch ChatSendStatus.acceptance(of: status) {
|
|
case .terminalFailure:
|
|
self.retryIdentity = nil
|
|
let normalized = ChatSendStatus.normalized(status)
|
|
self.sendState = self.text == draft
|
|
? .failed("Message was not accepted (\(normalized)).")
|
|
: .idle
|
|
return false
|
|
case .terminalSuccess, .inFlight:
|
|
self.retryIdentity = nil
|
|
self.lastAcceptedRoute = route
|
|
self.lastAcceptedIdempotencyKey = idempotencyKey
|
|
if self.textContext == textContext {
|
|
self.textContext = nil
|
|
}
|
|
if self.text == draft {
|
|
self.sendState = .sent
|
|
self.text = ""
|
|
} else {
|
|
self.sendState = .idle
|
|
}
|
|
return true
|
|
}
|
|
} catch is CancellationError {
|
|
self.sendTask = nil
|
|
self.retryIdentity = nil
|
|
self.sendState = .idle
|
|
return false
|
|
} catch {
|
|
self.sendTask = nil
|
|
self.sendState = self.text == draft ? .failed(error.localizedDescription) : .idle
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func startPermissionPolling(id: UUID) {
|
|
guard !ProcessInfo.processInfo.isRunningTests else { return }
|
|
self.permissionPollTask?.cancel()
|
|
// TCC posts no change notifications; poll only while the bar is presented.
|
|
self.permissionPollTask = Task { [weak self] in
|
|
while !Task.isCancelled {
|
|
try? await Task.sleep(for: .seconds(1))
|
|
guard let self, self.isCurrentPresentation(id), !Task.isCancelled else { return }
|
|
await self.recheckPermissions(id: id)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func cancelPermissionPolling() {
|
|
self.permissionPollTask?.cancel()
|
|
self.permissionPollTask = nil
|
|
}
|
|
|
|
private func cancelPermissionTask() {
|
|
self.permissionTask?.cancel()
|
|
self.permissionTask = nil
|
|
self.isGrantingPermissions = false
|
|
}
|
|
|
|
private func cancelTextContextCapture() {
|
|
self.textContextCaptureTask?.cancel()
|
|
self.textContextCaptureTask = nil
|
|
self.textContextCaptureID = nil
|
|
self.isCapturingTextContext = false
|
|
}
|
|
|
|
private func recheckPermissions(id: UUID) async {
|
|
let status = await self.permissionStatusProvider(Self.trackedPermissions)
|
|
guard self.isCurrentPresentation(id), !Task.isCancelled else { return }
|
|
self.applyPermissionStatus(status)
|
|
}
|
|
|
|
private func applyPermissionStatus(_ status: [Capability: Bool]) {
|
|
self.missingPermissions = Self.trackedPermissions.filter { status[$0] != true }
|
|
}
|
|
|
|
private func isCurrentPresentation(_ id: UUID) -> Bool {
|
|
self.isPresentationActive && self.presentationID == id
|
|
}
|
|
}
|
|
|
|
extension QuickChatModel {
|
|
var canUseModelControls: Bool {
|
|
!self.sessionKey.isEmpty &&
|
|
self.connectionGate == .available &&
|
|
!self.isGrantingPermissions &&
|
|
!self.isCapturingTextContext &&
|
|
!self.isStartingDictation &&
|
|
!self.isLoadingModelControls &&
|
|
!self.isUpdatingModel &&
|
|
self.sendState != .sending
|
|
}
|
|
|
|
var isSelectedThinkingLevelSupported: Bool {
|
|
QuickChatModelControlLogic.validatedThinkingSelection(
|
|
self.selectedThinkingLevel,
|
|
options: self.thinkingOptions) == self.selectedThinkingLevel
|
|
}
|
|
|
|
var modelPickerSections: ChatModelPickerSections {
|
|
ChatModelPickerStore.sections(
|
|
choices: self.modelChoices,
|
|
favorites: [],
|
|
recents: [],
|
|
defaultProvider: self.modelDefaultProvider)
|
|
}
|
|
|
|
var displayedModelSelectionID: String? {
|
|
self.selectedModelSelectionID ?? self.currentSessionModelSelectionID
|
|
}
|
|
|
|
var displayedThinkingLevel: String? {
|
|
self.selectedThinkingLevel ?? self.currentSessionThinkingLevel
|
|
}
|
|
|
|
var modelControlLabel: String {
|
|
let automatic = String(localized: "Auto")
|
|
let model = QuickChatModelControlLogic.displayName(
|
|
selectionID: self.displayedModelSelectionID,
|
|
models: self.modelChoices,
|
|
automaticLabel: automatic)
|
|
let thinking = self.thinkingOptions.first(where: { $0.id == self.displayedThinkingLevel })?.label
|
|
?? self.displayedThinkingLevel
|
|
?? automatic
|
|
return String(localized: "\(model) · \(thinking)")
|
|
}
|
|
|
|
private func refreshModelControls(for target: QuickChatRoutingTarget) {
|
|
guard self.isPresentationActive else { return }
|
|
let requestID = UUID()
|
|
self.modelControlsRequestID = requestID
|
|
self.modelControlsTask?.cancel()
|
|
self.isLoadingModelControls = true
|
|
self.modelControlsTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
do {
|
|
let snapshot = try await self.modelControlsProvider(target)
|
|
guard !Task.isCancelled,
|
|
self.modelControlsRequestID == requestID,
|
|
self.routingTarget == target
|
|
else { return }
|
|
self.modelChoices = snapshot.models
|
|
self.currentSessionModelSelectionID = snapshot.currentModelSelectionID
|
|
self.currentSessionThinkingLevel = snapshot.currentThinkingLevel
|
|
self.thinkingOptions = snapshot.thinkingOptions
|
|
self.modelDefaultProvider = snapshot.defaultProvider
|
|
self.modelControlStatusMessage = self.isSelectedThinkingLevelSupported
|
|
? nil
|
|
: String(localized: "Selected reasoning is unavailable for this target.")
|
|
} catch is CancellationError {
|
|
return
|
|
} catch {
|
|
guard self.modelControlsRequestID == requestID,
|
|
self.routingTarget == target
|
|
else { return }
|
|
self.modelControlStatusMessage = String(localized: "Couldn't load model settings.")
|
|
}
|
|
guard self.modelControlsRequestID == requestID else { return }
|
|
self.isLoadingModelControls = false
|
|
self.modelControlsTask = nil
|
|
}
|
|
}
|
|
|
|
private func startSelectedModelPatchIfNeeded(selectionID: String, target: QuickChatRoutingTarget) {
|
|
Task { [weak self] in
|
|
_ = await self?.applySelectedModelIfNeeded(to: target, selectionID: selectionID)
|
|
}
|
|
}
|
|
|
|
private func applySelectedModelIfNeeded(
|
|
to target: QuickChatRoutingTarget,
|
|
selectionID: String?) async -> Bool
|
|
{
|
|
let appliedSelectionID = self.appliedModelSelections[target]
|
|
switch QuickChatModelControlLogic.modelPatchDecision(
|
|
selectionID: selectionID,
|
|
appliedSelectionID: appliedSelectionID,
|
|
currentSessionSelectionID: self.routingTarget == target
|
|
? self.currentSessionModelSelectionID
|
|
: nil)
|
|
{
|
|
case .none:
|
|
return true
|
|
case let .patch(model):
|
|
// Model is a session setting and persists through sessions.patch. Reasoning
|
|
// remains a per-message override passed only to chat.send below.
|
|
if let task = self.modelPatchTask,
|
|
self.modelPatchTarget == target,
|
|
self.modelPatchSelectionID == selectionID
|
|
{
|
|
return await self.finishModelPatch(
|
|
task,
|
|
requestID: self.modelPatchRequestID,
|
|
target: target,
|
|
selectionID: selectionID)
|
|
}
|
|
|
|
self.modelPatchTask?.cancel()
|
|
self.modelControlsRequestID = UUID()
|
|
self.modelControlsTask?.cancel()
|
|
self.modelControlsTask = nil
|
|
self.isLoadingModelControls = false
|
|
let requestID = UUID()
|
|
self.modelPatchRequestID = requestID
|
|
self.modelPatchTarget = target
|
|
self.modelPatchSelectionID = selectionID
|
|
self.isUpdatingModel = true
|
|
let task = Task {
|
|
try await self.modelPatchProvider(target, model)
|
|
}
|
|
self.modelPatchTask = task
|
|
return await self.finishModelPatch(
|
|
task,
|
|
requestID: requestID,
|
|
target: target,
|
|
selectionID: selectionID)
|
|
}
|
|
}
|
|
|
|
private func finishModelPatch(
|
|
_ task: Task<OpenClawChatModelPatchResult?, Error>,
|
|
requestID: UUID,
|
|
target: QuickChatRoutingTarget,
|
|
selectionID: String?) async -> Bool
|
|
{
|
|
do {
|
|
let result = try await task.value
|
|
var refreshedSnapshot: QuickChatModelControlSnapshot?
|
|
var refreshFailed = false
|
|
if result?.thinkingLevels == nil, self.routingTarget == target {
|
|
do {
|
|
refreshedSnapshot = try await self.modelControlsProvider(target)
|
|
} catch {
|
|
refreshFailed = true
|
|
}
|
|
}
|
|
guard self.modelPatchRequestID == requestID else { return false }
|
|
if self.isPresentationActive, let selectionID {
|
|
self.appliedModelSelections[target] = selectionID
|
|
}
|
|
if self.routingTarget == target {
|
|
self.modelControlsRequestID = UUID()
|
|
self.modelControlsTask?.cancel()
|
|
self.modelControlsTask = nil
|
|
self.isLoadingModelControls = false
|
|
if let refreshedSnapshot {
|
|
self.modelChoices = refreshedSnapshot.models
|
|
self.currentSessionModelSelectionID = refreshedSnapshot.currentModelSelectionID
|
|
self.currentSessionThinkingLevel = refreshedSnapshot.currentThinkingLevel
|
|
self.modelDefaultProvider = refreshedSnapshot.defaultProvider
|
|
} else if let model = result?.model {
|
|
self.currentSessionModelSelectionID = QuickChatModelControlLogic.selectionID(
|
|
model: model,
|
|
provider: result?.modelProvider)
|
|
} else if selectionID != OpenClawChatViewModel.defaultModelSelectionID {
|
|
self.currentSessionModelSelectionID = selectionID
|
|
} else {
|
|
self.currentSessionModelSelectionID = nil
|
|
}
|
|
// The refreshedSnapshot branch above already seeded currentSessionThinkingLevel
|
|
// from the authoritative fetch, so when the patch response omits both
|
|
// thinkingLevels and thinkingLevel both branches here are skipped and the
|
|
// refreshed level survives; these only override when the patch itself
|
|
// returned a level.
|
|
if result?.thinkingLevels != nil {
|
|
self.currentSessionThinkingLevel = result?.thinkingLevel
|
|
} else if let thinkingLevel = result?.thinkingLevel {
|
|
self.currentSessionThinkingLevel = thinkingLevel
|
|
}
|
|
let thinkingOptions = result?.thinkingLevels ?? refreshedSnapshot?.thinkingOptions ?? []
|
|
self.thinkingOptions = thinkingOptions
|
|
if !refreshFailed {
|
|
let validated = QuickChatModelControlLogic.validatedThinkingSelection(
|
|
self.selectedThinkingLevel,
|
|
options: thinkingOptions)
|
|
if validated != self.selectedThinkingLevel {
|
|
self.selectedThinkingLevel = validated
|
|
self.retryIdentity = nil
|
|
}
|
|
}
|
|
self.modelControlStatusMessage = refreshFailed
|
|
? String(localized: "Couldn't load model settings.")
|
|
: nil
|
|
}
|
|
self.clearModelPatchState(requestID: requestID)
|
|
return true
|
|
} catch is CancellationError {
|
|
self.clearModelPatchState(requestID: requestID)
|
|
return false
|
|
} catch {
|
|
guard self.modelPatchRequestID == requestID else { return false }
|
|
if self.routingTarget == target {
|
|
self.modelControlStatusMessage = String(localized: "Couldn't change the model.")
|
|
}
|
|
self.clearModelPatchState(requestID: requestID)
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func clearModelPatchState(requestID: UUID) {
|
|
guard self.modelPatchRequestID == requestID else { return }
|
|
self.isUpdatingModel = false
|
|
self.modelPatchTask = nil
|
|
self.modelPatchTarget = nil
|
|
self.modelPatchSelectionID = nil
|
|
}
|
|
|
|
private func cancelModelControlRefresh() {
|
|
self.modelControlsRequestID = UUID()
|
|
self.modelControlsTask?.cancel()
|
|
self.modelControlsTask = nil
|
|
self.isLoadingModelControls = false
|
|
}
|
|
|
|
private func resetDictationState() {
|
|
self.isStartingDictation = false
|
|
self.isDictating = false
|
|
self.dictationStatusMessage = nil
|
|
self.dictationSelectionRange = nil
|
|
self.dictationTextSession = nil
|
|
}
|
|
}
|
|
|
|
extension String {
|
|
fileprivate var nonEmptyTrimmed: String? {
|
|
let trimmed = self.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
}
|