Download apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModelSignIn.swift from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 17.5 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModelSignIn.swift
- Command line
-
hf download hf://SaylorTwift/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModelSignIn.swift
-
curl -L -o ChatModelSignIn.swift https://huggingface.co/SaylorTwift/openclaw/resolve/main/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModelSignIn.swift
17.5 kB
| import Foundation | |
| import Observation | |
| import OpenClawKit | |
| import OpenClawProtocol | |
| import SwiftUI | |
| /// Requests stay bound to the agent and physical connection captured when the sheet opens. | |
| public struct OpenClawChatModelSignInContext: Sendable { | |
| public let agentID: String | |
| public let request: (String, [String: AnyCodable]) async throws -> Data | |
| public let isCurrent: () async -> Bool | |
| public init( | |
| agentID: String, | |
| request: @escaping (String, [String: AnyCodable]) async throws -> Data, | |
| isCurrent: @escaping () 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 } | |
| } | |
| } | |
| 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: () async -> Void | |
| private var closed = false | |
| private var cancelRequested = false | |
| init(context: OpenClawChatModelSignInContext, onAuthChanged: @escaping () 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 } | |
| // Pending admission retains its captured id and still closes any late server session. | |
| 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.") | |
| // Native login publishes credentials before settling, including writes followed by errors. | |
| 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 | |
| } | |
| } | |
| struct OpenClawChatModelSignInSheet: View { | |
| (\.dismiss) private var dismiss | |
| private var model: ChatModelSignInModel | |
| init(context: OpenClawChatModelSignInContext, onAuthChanged: @escaping () 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) | |
| } | |
| } | |
| } | |
| private func wizardStep(_ step: WizardStep) -> some View { | |
| 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) | |
| } | |
| } | |
| } | |