| import Foundation |
| import Observation |
| import OpenClawKit |
| import OpenClawProtocol |
| import SwiftUI |
|
|
| |
| public struct OpenClawChatModelSignInContext: Sendable { |
| public let agentID: String |
| public let request: @MainActor @Sendable (String, [String: AnyCodable]) async throws -> Data |
| public let isCurrent: @MainActor @Sendable () async -> Bool |
|
|
| public init( |
| agentID: String, |
| request: @escaping @MainActor @Sendable (String, [String: AnyCodable]) async throws -> Data, |
| isCurrent: @escaping @MainActor @Sendable () async -> Bool) |
| { |
| self.agentID = agentID |
| self.request = request |
| self.isCurrent = isCurrent |
| } |
| } |
|
|
| struct ChatModelAuthStatus: Decodable { |
| struct Capability: Decodable { |
| let loginOptions: [LoginOption]? |
| } |
|
|
| struct LoginOption: Decodable, Identifiable { |
| let id: String |
| let label: String |
| let hint: String? |
| } |
|
|
| struct Provider: Decodable, Identifiable { |
| let provider: String |
| let displayName: String |
| let status: String |
| var id: String { |
| self.provider |
| } |
|
|
| var statusLabel: String { |
| switch self.status { |
| case "ok", "static": String(localized: "Connected") |
| case "expiring": String(localized: "Sign-in expires soon") |
| case "expired": String(localized: "Sign-in expired") |
| case "missing": String(localized: "Sign-in needed") |
| default: String(localized: "Status unavailable") |
| } |
| } |
| } |
|
|
| let providers: [Provider] |
| let providerCapabilities: [Capability]? |
| let unavailable: [String: AnyCodable]? |
|
|
| var loginOptions: [LoginOption] { |
| var seen = Set<String>() |
| return (self.providerCapabilities ?? []).flatMap { $0.loginOptions ?? [] } |
| .filter { seen.insert($0.id).inserted } |
| } |
| } |
|
|
| @MainActor |
| @Observable |
| final class ChatModelSignInModel { |
| private(set) var authStatus: ChatModelAuthStatus? |
| private(set) var step: WizardStep? |
| private(set) var sessionID: String? |
| private(set) var busy = false |
| private(set) var cancelling = false |
| private(set) var message: String? |
| var text = "" |
| var selection = 0 |
| var confirmation = false |
| private let context: OpenClawChatModelSignInContext |
| private let onAuthChanged: @MainActor () async -> Void |
| private var closed = false |
| private var cancelRequested = false |
|
|
| init(context: OpenClawChatModelSignInContext, onAuthChanged: @escaping @MainActor () async -> Void) { |
| self.context = context |
| self.onAuthChanged = onAuthChanged |
| } |
|
|
| func refresh() async { |
| guard !self.busy, self.sessionID == nil, !self.closed else { return } |
| self.busy = true |
| defer { self.busy = false } |
| do { |
| try await self.readStatus() |
| } catch { |
| self |
| .message = |
| String( |
| localized: "Could not load sign-in options. Check the connection or update your Gateway.") |
| } |
| } |
|
|
| func start(_ option: ChatModelAuthStatus.LoginOption) async { |
| guard !self.busy, !self.closed, self.sessionID == nil, |
| self.authStatus?.loginOptions.contains(where: { $0.id == option.id }) == true |
| else { return } |
| let id = UUID().uuidString |
| self.sessionID = id |
| self.cancelRequested = false |
| self.message = nil |
| self.step = nil |
| await self.run(id) { |
| let data: Data |
| do { |
| guard await self.context.isCurrent(), !self.closed else { throw CancellationError() } |
| data = try await self.context.request("models.authLogin", [ |
| "sessionId": AnyCodable(id), "agentId": AnyCodable(self.context.agentID), |
| "authChoice": AnyCodable(option.id), |
| ]) |
| } catch let error as GatewayResponseError { |
| if self.sessionID == id { self.sessionID = nil } |
| self |
| .message = |
| String(localized: "Sign-in could not start. Refresh the options or use Models in the Dashboard.") |
| throw error |
| } catch let error as GatewayNodeSessionRequestError { |
| if self.sessionID == id { self.sessionID = nil } |
| self.message = String(localized: "The connection changed. Close sign-in and open it again.") |
| throw error |
| } |
| let result = try JSONDecoder().decode(WizardStartResult.self, from: data) |
| if self.closed || self.cancelRequested { |
| try await self.closeSession(id) |
| } else { |
| guard await self.context.isCurrent(), !self.closed else { throw CancellationError() } |
| try await self.advance(id, result: WizardNextResult( |
| done: result.done, step: result.step, status: result.status, error: result.error)) |
| } |
| } |
| } |
|
|
| func answer() async { |
| guard let id = self.sessionID, !self.busy, !self.cancelling, !self.closed else { return } |
| var params = ["sessionId": AnyCodable(id)] |
| if let step = self.step, wizardStepExecutor(step) != "gateway" { |
| let value: AnyCodable? = switch wizardStepType(step) { |
| case "text": AnyCodable(self.text) |
| case "confirm": AnyCodable(self.confirmation) |
| case "select": parseWizardOptions(step.options).indices.contains(self.selection) |
| ? parseWizardOptions(step.options)[self.selection].value : nil |
| default: nil |
| } |
| var answer = ["stepId": AnyCodable(step.id)] |
| if let value { answer["value"] = value } |
| params["answer"] = AnyCodable(answer) |
| } |
| await self.run(id) { |
| let data = try await self.request("wizard.next", params) |
| try await self.advance(id, result: JSONDecoder().decode(WizardNextResult.self, from: data)) |
| } |
| } |
|
|
| func cancel() async { |
| guard let id = self.sessionID, !self.cancelling else { return } |
| self.cancelRequested = true |
| self.cancelling = true |
| defer { self.cancelling = false } |
| do { |
| try await self.closeSession(id) |
| } catch let error as GatewayResponseError |
| where error.details["code"]?.stringValue == "WIZARD_NOT_FOUND" && !self.busy |
| { |
| if await self.retireIfContextLost(id) { return } |
| await self.finish(id, status: nil, error: nil) |
| } catch { |
| if await self.retireIfContextLost(id) { return } |
| if self.sessionID == id, !self.closed { |
| self |
| .message = |
| String(localized: "Cancellation is not confirmed. Check the connection and try Cancel again.") |
| } |
| } |
| } |
|
|
| func close() async { |
| self.closed = true |
| await self.cancel() |
| } |
|
|
| private func run(_ id: String, operation: () async throws -> Void) async { |
| self.busy = true |
| defer { self.busy = false } |
| do { |
| try await operation() |
| } catch { |
| let cancellationAlreadyRequested = self.cancelRequested |
| if await self.retireIfContextLost(id) { |
| if !cancellationAlreadyRequested { try? await self.closeSession(id) } |
| } else if !self.closed, self.sessionID == id, !self.cancelRequested { |
| self |
| .message = |
| String( |
| localized: "Sign-in stopped. Check the connection, then cancel this attempt and retry.") |
| } |
| } |
| } |
|
|
| private func retireIfContextLost(_ id: String) async -> Bool { |
| guard await !(self.context.isCurrent()) else { return false } |
| guard self.sessionID == id else { return true } |
| |
| self.cancelRequested = true |
| self.sessionID = nil |
| self.step = nil |
| self.authStatus = nil |
| self.message = String(localized: "The connection changed. Close sign-in and open it again.") |
| return true |
| } |
|
|
| private func advance(_ id: String, result first: WizardNextResult) async throws { |
| var result = first |
| while !self.closed, self.sessionID == id, !self.cancelRequested { |
| if result.done { |
| await self.finish(id, status: wizardStatusString(result.status), error: result.error) |
| return |
| } |
| let preservesInput = self.step?.id == result.step?.id && result.error != nil |
| self.step = result.step |
| self.message = result.error |
| if !preservesInput { |
| self.text = anyCodableString(result.step?.initialvalue) |
| self.confirmation = anyCodableBool(result.step?.initialvalue) |
| self.selection = parseWizardOptions(result.step?.options).firstIndex { |
| anyCodableEqual($0.value, result.step?.initialvalue) |
| } ?? 0 |
| } |
| if let step = result.step, wizardStepExecutor(step) != "gateway" { return } |
| let data = try await self.request("wizard.next", ["sessionId": AnyCodable(id)]) |
| result = try JSONDecoder().decode(WizardNextResult.self, from: data) |
| } |
| } |
|
|
| private func closeSession(_ id: String) async throws { |
| let data = try await self.context.request("wizard.cancel", [ |
| "sessionId": AnyCodable(id), "closeInput": AnyCodable(true), |
| ]) |
| let result = try JSONDecoder().decode(WizardStatusResult.self, from: data) |
| await self.finish(id, status: wizardStatusString(result.status), error: result.error) |
| } |
|
|
| private func finish(_ id: String, status: String?, error: String?) async { |
| guard self.sessionID == id else { return } |
| self.sessionID = nil |
| self.step = nil |
| guard await self.context.isCurrent(), !self.closed else { return } |
| self.message = error == nil && status == "done" |
| ? String(localized: "Sign-in finished.") |
| : String(localized: "Sign-in ended. Review the account status before trying again.") |
| |
| do { |
| try await self.readStatus() |
| } catch { |
| self |
| .message = |
| String(localized: "Sign-in ended, but account status could not be loaded. Refresh to check it.") |
| } |
| if await self.context.isCurrent(), !self.closed { await self.onAuthChanged() } |
| } |
|
|
| private func readStatus() async throws { |
| let data = try await self.request("models.authStatus", ["agentId": AnyCodable(self.context.agentID)]) |
| let status = try JSONDecoder().decode(ChatModelAuthStatus.self, from: data) |
| self.authStatus = status |
| if status.unavailable != nil { |
| self.message = String(localized: "Account status is not ready. Refresh after setup finishes.") |
| } |
| } |
|
|
| private func request(_ method: String, _ params: [String: AnyCodable]) async throws -> Data { |
| guard await self.context.isCurrent(), !self.closed else { throw CancellationError() } |
| let data = try await self.context.request(method, params) |
| guard await self.context.isCurrent(), !self.closed else { throw CancellationError() } |
| return data |
| } |
| } |
|
|
| @MainActor |
| struct OpenClawChatModelSignInSheet: View { |
| @Environment(\.dismiss) private var dismiss |
| @State private var model: ChatModelSignInModel |
|
|
| init(context: OpenClawChatModelSignInContext, onAuthChanged: @escaping @MainActor () async -> Void) { |
| self._model = State(initialValue: ChatModelSignInModel(context: context, onAuthChanged: onAuthChanged)) |
| } |
|
|
| var body: some View { |
| VStack(alignment: .leading, spacing: 16) { |
| Text("Model sign-in").font(OpenClawChatTypography.heading(level: 2)) |
| ScrollView { |
| VStack(alignment: .leading, spacing: 16) { |
| if let step = self.model.step { |
| self.wizardStep(step) |
| } else if self.model.sessionID == nil { |
| self.accounts |
| } |
| if self.model.busy || self.model.cancelling { ProgressView() } |
| if let message = self.model.message { |
| Text(message).font(OpenClawChatTypography.callout).textSelection(.enabled) |
| } |
| } |
| .frame(maxWidth: .infinity, alignment: .leading) |
| } |
| HStack { |
| Button { self.dismiss() } label: { |
| Text("Close").font(OpenClawChatTypography.body) |
| } |
| if self.model.sessionID != nil { |
| Button { Task { await self.model.cancel() } } label: { |
| Text("Cancel sign-in").font(OpenClawChatTypography.body) |
| }.disabled(self.model.cancelling) |
| } else { |
| Button { Task { await self.model.refresh() } } label: { |
| Text("Refresh").font(OpenClawChatTypography.body) |
| }.disabled(self.model.busy) |
| } |
| } |
| } |
| .padding(24) |
| .frame(idealWidth: 460, idealHeight: 480) |
| .task { await self.model.refresh() } |
| .onDisappear { Task { await self.model.close() } } |
| } |
|
|
| private var accounts: some View { |
| VStack(alignment: .leading, spacing: 12) { |
| ForEach(self.model.authStatus?.providers ?? []) { provider in |
| HStack { |
| Text(provider.displayName).font(OpenClawChatTypography.body) |
| Spacer() |
| Text(provider.statusLabel).font(OpenClawChatTypography.caption) |
| } |
| .accessibilityElement(children: .combine) |
| .accessibilityIdentifier("model-auth-provider-\(provider.id)") |
| } |
| ForEach(self.model.authStatus?.loginOptions ?? []) { option in |
| Button { Task { await self.model.start(option) } } label: { |
| VStack(alignment: .leading) { |
| Text(option.label).font(OpenClawChatTypography.body) |
| if let hint = option.hint { Text(hint).font(OpenClawChatTypography.caption) } |
| } |
| }.disabled(self.model.busy) |
| } |
| if self.model.authStatus?.loginOptions.isEmpty == true { |
| Text("No native sign-in options are available. Open Models in the Dashboard, or update the Gateway.") |
| .font(OpenClawChatTypography.callout) |
| } |
| } |
| } |
|
|
| @ViewBuilder |
| private func wizardStep(_ step: WizardStep) -> some View { |
| @Bindable var model = self.model |
| if let title = step.title { Text(title).font(OpenClawChatTypography.headline) } |
| if let message = step.message { |
| Text(message).font(OpenClawChatTypography.callout).textSelection(.enabled) |
| } |
| if let code = parseWizardDeviceCode(step.devicecode) { |
| Text(code.code).font(OpenClawChatTypography.mono(size: 24, weight: .semibold, relativeTo: .title2)) |
| .textSelection(.enabled) |
| if let message = code.message { Text(message).font(OpenClawChatTypography.callout) } |
| } |
| if let rawURL = step.externalurl, let url = URL(string: rawURL), |
| url.scheme == "https" || url.scheme == "http", url.host != nil |
| { |
| Link(destination: url) { |
| Text("Open sign-in page").font(OpenClawChatTypography.body) |
| } |
| .accessibilityIdentifier("model-auth-external-url") |
| } |
| if wizardStepExecutor(step) != "gateway" { |
| switch wizardStepType(step) { |
| case "text": |
| if step.sensitive == true { |
| SecureField(text: $model.text) { |
| Text(step.placeholder ?? String(localized: "Value")).font(OpenClawChatTypography.body) |
| }.font(OpenClawChatTypography.body) |
| } else { |
| TextField(text: $model.text) { |
| Text(step.placeholder ?? String(localized: "Value")).font(OpenClawChatTypography.body) |
| }.font(OpenClawChatTypography.body) |
| } |
| case "confirm": |
| Toggle(isOn: $model.confirmation) { |
| Text("Confirm").font(OpenClawChatTypography.body) |
| } |
| case "select": |
| Picker(selection: $model.selection) { |
| ForEach(Array(parseWizardOptions(step.options).enumerated()), id: \.offset) { index, option in |
| Text(option.label).font(OpenClawChatTypography.body).tag(index) |
| } |
| } label: { |
| Text("Option").font(OpenClawChatTypography.body) |
| } |
| default: EmptyView() |
| } |
| Button { Task { await self.model.answer() } } label: { |
| Text("Continue").font(OpenClawChatTypography.body) |
| }.disabled(self.model.busy || self.model.cancelling) |
| } |
| } |
| } |
|
|