openclaw / apps /shared /OpenClawKit /Sources /OpenClawChatUI /ChatModelSignIn.swift
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
b8370f5 verified
Raw
History Blame Contribute Delete
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: @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 }
// 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
}
}
@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)
}
}
}