Improve macOS onboarding UX and gateway setup

This commit is contained in:
ImLukeF 2026-03-15 22:55:59 +11:00
parent f4aff83c51
commit 01a6e0da81
No known key found for this signature in database
17 changed files with 386 additions and 115 deletions

View File

@ -2,6 +2,13 @@ import Foundation
@MainActor @MainActor
enum CLIInstaller { enum CLIInstaller {
struct PreflightStatus: Equatable {
let needsCommandLineTools: Bool
let message: String?
static let ready = PreflightStatus(needsCommandLineTools: false, message: nil)
}
static func installedLocation() -> String? { static func installedLocation() -> String? {
self.installedLocation( self.installedLocation(
searchPaths: CommandResolver.preferredPaths(), searchPaths: CommandResolver.preferredPaths(),
@ -34,6 +41,50 @@ enum CLIInstaller {
self.installedLocation() != nil self.installedLocation() != nil
} }
static func preflight() async -> PreflightStatus {
let response = await ShellExecutor.runDetailed(
command: ["/usr/bin/xcode-select", "-p"],
cwd: nil,
env: nil,
timeout: 10)
guard response.success else {
return PreflightStatus(
needsCommandLineTools: true,
message: """
Apple Developer Tools are required before OpenClaw can install the CLI.
Install them first, then come back and click I've Installed It, Recheck.
""")
}
return .ready
}
static func requestCommandLineToolsInstall(
statusHandler: @escaping @MainActor @Sendable (String) async -> Void
) async {
await statusHandler("Opening Apple developer tools installer…")
let response = await ShellExecutor.runDetailed(
command: ["/usr/bin/xcode-select", "--install"],
cwd: nil,
env: nil,
timeout: 10)
let combined = [response.stdout, response.stderr]
.joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if combined.contains("already installed") || combined.contains("softwareupdate") {
await statusHandler(
"Apple Developer Tools installer is already open or installed. Finish that step, then click “I've Installed It, Recheck”.")
return
}
await statusHandler(
"Complete Apple's developer tools installer dialog, then click “I've Installed It, Recheck”.")
}
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async { static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async {
let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "latest" let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
let prefix = Self.installPrefix() let prefix = Self.installPrefix()

View File

@ -14,6 +14,10 @@ enum CronCustomSessionTarget: Codable, Equatable {
case predefined(CronSessionTarget) case predefined(CronSessionTarget)
case session(id: String) case session(id: String)
static let main: CronCustomSessionTarget = .predefined(.main)
static let isolated: CronCustomSessionTarget = .predefined(.isolated)
static let current: CronCustomSessionTarget = .predefined(.current)
var rawValue: String { var rawValue: String {
switch self { switch self {
case .predefined(let target): case .predefined(let target):
@ -254,6 +258,38 @@ struct CronJob: Identifiable, Codable, Equatable {
case state case state
} }
init(
id: String,
agentId: String?,
name: String,
description: String?,
enabled: Bool,
deleteAfterRun: Bool?,
createdAtMs: Int,
updatedAtMs: Int,
schedule: CronSchedule,
sessionTarget: CronCustomSessionTarget,
wakeMode: CronWakeMode,
payload: CronPayload,
delivery: CronDelivery?,
state: CronJobState
) {
self.id = id
self.agentId = agentId
self.name = name
self.description = description
self.enabled = enabled
self.deleteAfterRun = deleteAfterRun
self.createdAtMs = createdAtMs
self.updatedAtMs = updatedAtMs
self.schedule = schedule
self.sessionTargetRaw = sessionTarget.rawValue
self.wakeMode = wakeMode
self.payload = payload
self.delivery = delivery
self.state = state
}
/// Parsed session target (predefined or custom session ID) /// Parsed session target (predefined or custom session ID)
var parsedSessionTarget: CronCustomSessionTarget { var parsedSessionTarget: CronCustomSessionTarget {
CronCustomSessionTarget.from(self.sessionTargetRaw) CronCustomSessionTarget.from(self.sessionTargetRaw)

View File

@ -15,7 +15,7 @@ struct CronSettings_Previews: PreviewProvider {
createdAtMs: 0, createdAtMs: 0,
updatedAtMs: 0, updatedAtMs: 0,
schedule: .every(everyMs: 86_400_000, anchorMs: nil), schedule: .every(everyMs: 86_400_000, anchorMs: nil),
sessionTarget: .isolated, sessionTarget: .predefined(.isolated),
wakeMode: .now, wakeMode: .now,
payload: .agentTurn( payload: .agentTurn(
message: "Summarize inbox", message: "Summarize inbox",
@ -69,7 +69,7 @@ extension CronSettings {
createdAtMs: 1_700_000_000_000, createdAtMs: 1_700_000_000_000,
updatedAtMs: 1_700_000_100_000, updatedAtMs: 1_700_000_100_000,
schedule: .cron(expr: "0 8 * * *", tz: "UTC"), schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
sessionTarget: .isolated, sessionTarget: .predefined(.isolated),
wakeMode: .nextHeartbeat, wakeMode: .nextHeartbeat,
payload: .agentTurn( payload: .agentTurn(
message: "Summarize", message: "Summarize",

View File

@ -90,7 +90,18 @@ enum GatewayEnvironment {
} }
static func expectedGatewayVersionString() -> String? { static func expectedGatewayVersionString() -> String? {
let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String self.expectedGatewayVersionString(
bundleVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String,
bundleIdentifier: Bundle.main.bundleIdentifier)
}
static func expectedGatewayVersionString(bundleVersion: String?, bundleIdentifier: String?) -> String? {
if let bundleIdentifier,
bundleIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix(".debug")
{
return nil
}
let trimmed = bundleVersion?.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = bundleVersion?.trimmingCharacters(in: .whitespacesAndNewlines)
return (trimmed?.isEmpty == false) ? trimmed : nil return (trimmed?.isEmpty == false) ? trimmed : nil
} }

View File

@ -1,5 +1,6 @@
import Foundation import Foundation
import Observation import Observation
import OpenClawKit
@MainActor @MainActor
@Observable @Observable
@ -196,13 +197,9 @@ final class GatewayProcessManager {
let instanceText = instance.map { self.describe(instance: $0) } let instanceText = instance.map { self.describe(instance: $0) }
let hasListener = instance != nil let hasListener = instance != nil
let attemptAttach = {
try await self.connection.requestRaw(method: .health, timeoutMs: 2000)
}
for attempt in 0..<(hasListener ? 3 : 1) { for attempt in 0..<(hasListener ? 3 : 1) {
do { do {
let data = try await attemptAttach() let data = try await self.probeLocalGatewayHealth(timeoutMs: 2000)
let snap = decodeHealthSnapshot(from: data) let snap = decodeHealthSnapshot(from: data)
let details = self.describe(details: instanceText, port: port, snap: snap) let details = self.describe(details: instanceText, port: port, snap: snap)
self.existingGatewayDetails = details self.existingGatewayDetails = details
@ -337,7 +334,7 @@ final class GatewayProcessManager {
while Date() < deadline { while Date() < deadline {
if !self.desiredActive { return } if !self.desiredActive { return }
do { do {
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500) _ = try await self.probeLocalGatewayHealth(timeoutMs: 1500)
let instance = await PortGuardian.shared.describe(port: port) let instance = await PortGuardian.shared.describe(port: port)
let details = instance.map { "pid \($0.pid)" } let details = instance.map { "pid \($0.pid)" }
self.clearLastFailure() self.clearLastFailure()
@ -380,7 +377,7 @@ final class GatewayProcessManager {
while Date() < deadline { while Date() < deadline {
if !self.desiredActive { return false } if !self.desiredActive { return false }
do { do {
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500) _ = try await self.probeLocalGatewayHealth(timeoutMs: 1500)
self.clearLastFailure() self.clearLastFailure()
return true return true
} catch { } catch {
@ -413,6 +410,20 @@ final class GatewayProcessManager {
if text.count <= limit { return text } if text.count <= limit { return text }
return String(text.suffix(limit)) return String(text.suffix(limit))
} }
private func probeLocalGatewayHealth(timeoutMs: Double) async throws -> Data {
let config = GatewayEndpointStore.localConfig()
let channel = GatewayChannelActor(
url: config.url,
token: config.token,
password: config.password)
defer {
Task {
await channel.shutdown()
}
}
return try await channel.request(method: GatewayConnection.Method.health.rawValue, params: nil, timeoutMs: timeoutMs)
}
} }
#if DEBUG #if DEBUG

View File

@ -33,6 +33,7 @@ final class MacNodeModeCoordinator {
var retryDelay: UInt64 = 1_000_000_000 var retryDelay: UInt64 = 1_000_000_000
var lastCameraEnabled: Bool? var lastCameraEnabled: Bool?
var lastBrowserControlEnabled: Bool? var lastBrowserControlEnabled: Bool?
var lastBlockedOnOnboarding = false
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
while !Task.isCancelled { while !Task.isCancelled {
@ -41,6 +42,20 @@ final class MacNodeModeCoordinator {
continue continue
} }
let onboardingComplete = Self.shouldConnectNodeMode(
onboardingSeen: defaults.bool(forKey: onboardingSeenKey),
onboardingVersion: defaults.integer(forKey: onboardingVersionKey))
if !onboardingComplete {
if !lastBlockedOnOnboarding {
self.logger.info("mac node waiting for onboarding completion")
lastBlockedOnOnboarding = true
}
await self.session.disconnect()
try? await Task.sleep(nanoseconds: 1_000_000_000)
continue
}
lastBlockedOnOnboarding = false
let cameraEnabled = defaults.object(forKey: cameraEnabledKey) as? Bool ?? false let cameraEnabled = defaults.object(forKey: cameraEnabledKey) as? Bool ?? false
if lastCameraEnabled == nil { if lastCameraEnabled == nil {
lastCameraEnabled = cameraEnabled lastCameraEnabled = cameraEnabled
@ -116,6 +131,10 @@ final class MacNodeModeCoordinator {
} }
} }
static func shouldConnectNodeMode(onboardingSeen: Bool, onboardingVersion: Int) -> Bool {
onboardingSeen && onboardingVersion >= currentOnboardingVersion
}
private func currentCaps() -> [String] { private func currentCaps() -> [String] {
var caps: [String] = [OpenClawCapability.canvas.rawValue, OpenClawCapability.screen.rawValue] var caps: [String] = [OpenClawCapability.canvas.rawValue, OpenClawCapability.screen.rawValue]
if OpenClawConfigFile.browserControlEnabled() { if OpenClawConfigFile.browserControlEnabled() {

View File

@ -67,6 +67,8 @@ struct OnboardingView: View {
@State var isRequesting = false @State var isRequesting = false
@State var installingCLI = false @State var installingCLI = false
@State var cliStatus: String? @State var cliStatus: String?
@State var cliPreflightStatus: String?
@State var cliNeedsCommandLineTools = false
@State var copied = false @State var copied = false
@State var monitoringPermissions = false @State var monitoringPermissions = false
@State var monitoringDiscovery = false @State var monitoringDiscovery = false
@ -97,6 +99,7 @@ struct OnboardingView: View {
let pageWidth: CGFloat = Self.windowWidth let pageWidth: CGFloat = Self.windowWidth
let contentHeight: CGFloat = 460 let contentHeight: CGFloat = 460
let connectionPageIndex = 1 let connectionPageIndex = 1
let cliPageIndex = 6
let wizardPageIndex = 3 let wizardPageIndex = 3
let onboardingChatPageIndex = 8 let onboardingChatPageIndex = 8
@ -113,7 +116,7 @@ struct OnboardingView: View {
case .unconfigured: case .unconfigured:
showOnboardingChat ? [0, 1, 8, 9] : [0, 1, 9] showOnboardingChat ? [0, 1, 8, 9] : [0, 1, 9]
case .local: case .local:
showOnboardingChat ? [0, 1, 3, 5, 8, 9] : [0, 1, 3, 5, 9] showOnboardingChat ? [0, 1, 6, 3, 5, 8, 9] : [0, 1, 6, 3, 5, 9]
} }
} }
@ -146,7 +149,10 @@ struct OnboardingView: View {
} }
var canAdvance: Bool { var canAdvance: Bool {
!self.isWizardBlocking if self.activePageIndex == self.cliPageIndex {
return self.cliInstalled && !self.installingCLI
}
return !self.isWizardBlocking
} }
var devLinkCommand: String { var devLinkCommand: String {

View File

@ -4,7 +4,10 @@ import SwiftUI
extension OnboardingView { extension OnboardingView {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
GlowingOpenClawIcon(size: 130, glowIntensity: 0.28) Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 130, height: 130)
.clipShape(RoundedRectangle(cornerRadius: 130 * 0.22, style: .continuous))
.offset(y: 10) .offset(y: 10)
.frame(height: 145) .frame(height: 145)
@ -46,10 +49,6 @@ extension OnboardingView {
self.currentPage = max(0, self.pageOrder.count - 1) self.currentPage = max(0, self.pageOrder.count - 1)
} }
} }
.onChange(of: self.onboardingWizard.isComplete) { _, newValue in
guard newValue, self.activePageIndex == self.wizardPageIndex else { return }
self.handleNext()
}
.onDisappear { .onDisappear {
self.stopPermissionMonitoring() self.stopPermissionMonitoring()
self.stopDiscovery() self.stopDiscovery()
@ -57,7 +56,7 @@ extension OnboardingView {
} }
.task { .task {
await self.refreshPerms() await self.refreshPerms()
self.refreshCLIStatus() await self.refreshCLIInstallerReadiness()
await self.loadWorkspaceDefaults() await self.loadWorkspaceDefaults()
await self.ensureDefaultWorkspace() await self.ensureDefaultWorkspace()
self.refreshBootstrapStatus() self.refreshBootstrapStatus()
@ -156,6 +155,16 @@ extension OnboardingView {
.frame(width: self.pageWidth, alignment: .top) .frame(width: self.pageWidth, alignment: .top)
} }
func onboardingFixedPage(@ViewBuilder _ content: () -> some View) -> some View {
VStack(spacing: 16) {
content()
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding(.horizontal, 28)
.frame(width: self.pageWidth, alignment: .top)
}
func onboardingCard( func onboardingCard(
spacing: CGFloat = 12, spacing: CGFloat = 12,
padding: CGFloat = 16, padding: CGFloat = 16,
@ -166,10 +175,6 @@ extension OnboardingView {
} }
.padding(padding) .padding(padding)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(NSColor.controlBackgroundColor))
.shadow(color: .black.opacity(0.06), radius: 8, y: 3))
} }
func onboardingGlassCard( func onboardingGlassCard(

View File

@ -43,6 +43,11 @@ extension OnboardingView {
self.updatePermissionMonitoring(for: pageIndex) self.updatePermissionMonitoring(for: pageIndex)
self.updateDiscoveryMonitoring(for: pageIndex) self.updateDiscoveryMonitoring(for: pageIndex)
self.maybeKickoffOnboardingChat(for: pageIndex) self.maybeKickoffOnboardingChat(for: pageIndex)
if pageIndex == self.cliPageIndex {
Task { @MainActor in
await self.refreshCLIInstallerReadiness()
}
}
} }
func stopPermissionMonitoring() { func stopPermissionMonitoring() {
@ -57,12 +62,20 @@ extension OnboardingView {
func installCLI() async { func installCLI() async {
guard !self.installingCLI else { return } guard !self.installingCLI else { return }
await self.refreshCLIInstallerReadiness()
if self.cliNeedsCommandLineTools {
await self.requestCommandLineToolsInstall()
return
}
self.installingCLI = true self.installingCLI = true
defer { installingCLI = false } defer { installingCLI = false }
await CLIInstaller.install { message in await CLIInstaller.install { message in
self.cliStatus = message self.cliStatus = message
} }
self.refreshCLIStatus() self.refreshCLIStatus()
await self.refreshCLIInstallerReadiness()
} }
func refreshCLIStatus() { func refreshCLIStatus() {
@ -71,6 +84,29 @@ extension OnboardingView {
self.cliInstalled = installLocation != nil self.cliInstalled = installLocation != nil
} }
@MainActor
func refreshCLIInstallerReadiness() async {
self.refreshCLIStatus()
if self.cliInstalled {
self.cliNeedsCommandLineTools = false
self.cliPreflightStatus = nil
return
}
let preflight = await CLIInstaller.preflight()
self.cliNeedsCommandLineTools = preflight.needsCommandLineTools
self.cliPreflightStatus = preflight.message
}
@MainActor
func requestCommandLineToolsInstall() async {
await CLIInstaller.requestCommandLineToolsInstall { message in
self.cliPreflightStatus = message
}
await self.refreshCLIInstallerReadiness()
}
func refreshLocalGatewayProbe() async { func refreshLocalGatewayProbe() async {
let port = GatewayEnvironment.gatewayPort() let port = GatewayEnvironment.gatewayPort()
let desc = await PortGuardian.shared.describe(port: port) let desc = await PortGuardian.shared.describe(port: port)

View File

@ -33,7 +33,7 @@ extension OnboardingView {
VStack(spacing: 22) { VStack(spacing: 22) {
Text("Welcome to OpenClaw") Text("Welcome to OpenClaw")
.font(.largeTitle.weight(.semibold)) .font(.largeTitle.weight(.semibold))
Text("OpenClaw is a powerful personal AI assistant that can connect to WhatsApp or Telegram.") Text("OpenClaw is a powerful personal AI assistant that connects to the apps you already use — WhatsApp, Telegram, Slack, and more.")
.font(.body) .font(.body)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@ -64,6 +64,13 @@ extension OnboardingView {
} }
} }
} }
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(NSColor.controlBackgroundColor))
.shadow(color: .black.opacity(0.06), radius: 8, y: 3))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.orange.opacity(0.06)))
.frame(maxWidth: 520) .frame(maxWidth: 520)
} }
.padding(.top, 16) .padding(.top, 16)
@ -633,19 +640,33 @@ extension OnboardingView {
self.onboardingPage { self.onboardingPage {
Text("Install the CLI") Text("Install the CLI")
.font(.largeTitle.weight(.semibold)) .font(.largeTitle.weight(.semibold))
Text("Required for local mode: installs `openclaw` so launchd can run the gateway.") Text(
self.cliNeedsCommandLineTools
? "OpenClaw needs Apple Developer Tools first. Install those, then come back to install the CLI."
: "Installs the OpenClaw command-line tool so the gateway can run in the background.")
.font(.body) .font(.body)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.frame(maxWidth: 520) .frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
self.onboardingCard(spacing: 10) { self.onboardingCard(spacing: 12) {
HStack(spacing: 12) {
Button { Button {
Task { await self.installCLI() } Task {
if self.cliNeedsCommandLineTools {
await self.requestCommandLineToolsInstall()
} else {
await self.installCLI()
}
}
} label: { } label: {
let title = self.cliInstalled ? "Reinstall CLI" : "Install CLI" let title: String = if self.cliNeedsCommandLineTools {
"Install Apple Developer Tools"
} else if self.cliInstalled {
"Reinstall CLI"
} else {
"Install CLI"
}
ZStack { ZStack {
Text(title) Text(title)
.opacity(self.installingCLI ? 0 : 1) .opacity(self.installingCLI ? 0 : 1)
@ -654,36 +675,57 @@ extension OnboardingView {
.controlSize(.mini) .controlSize(.mini)
} }
} }
.frame(minWidth: 120) .frame(maxWidth: .infinity)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(self.installingCLI) .disabled(self.installingCLI)
Button(self.copied ? "Copied" : "Copy install command") { if self.cliNeedsCommandLineTools {
self.copyToPasteboard(self.devLinkCommand) HStack(spacing: 10) {
Button("I've Installed It, Recheck") {
Task { await self.refreshCLIInstallerReadiness() }
}
.buttonStyle(.bordered)
Button("Open Software Update") {
if let url = URL(string: "x-apple.systempreferences:com.apple.preferences.softwareupdate") {
NSWorkspace.shared.open(url)
}
}
.buttonStyle(.bordered)
}
} }
.disabled(self.installingCLI)
if self.cliInstalled, let loc = self.cliInstallLocation { if self.cliInstalled, let loc = self.cliInstallLocation {
Label("Installed at \(loc)", systemImage: "checkmark.circle.fill") Label("Installed at \(loc)", systemImage: "checkmark.circle.fill")
.font(.footnote) .font(.footnote)
.foregroundStyle(.green) .foregroundStyle(.green)
} }
}
if let cliStatus { if let cliPreflightStatus, self.cliNeedsCommandLineTools {
Text(cliPreflightStatus)
.font(.caption)
.foregroundStyle(.secondary)
} else if let cliStatus {
Text(cliStatus) Text(cliStatus)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else if !self.cliInstalled, self.cliInstallLocation == nil { } else if !self.cliInstalled, self.cliInstallLocation == nil {
Text( Text("Installs a user-space Node 22+ runtime (no Homebrew required).")
"""
Installs a user-space Node 22+ runtime and the CLI (no Homebrew).
Rerun anytime to reinstall or update.
""")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Divider()
Text("Prefer to install manually?")
.font(.footnote)
.foregroundStyle(.secondary)
Button(self.copied ? "Copied" : "Copy install command") {
self.copyToPasteboard(self.devLinkCommand)
}
.buttonStyle(.bordered)
.disabled(self.installingCLI)
} }
} }
} }

View File

@ -4,11 +4,11 @@ import SwiftUI
extension OnboardingView { extension OnboardingView {
func wizardPage() -> some View { func wizardPage() -> some View {
self.onboardingPage { self.onboardingFixedPage {
VStack(spacing: 16) { VStack(spacing: 16) {
Text("Setup Wizard") Text("Configure OpenClaw")
.font(.largeTitle.weight(.semibold)) .font(.largeTitle.weight(.semibold))
Text("Follow the guided setup from the Gateway. This keeps onboarding in sync with the CLI.") Text("Follow the steps below to configure your AI provider and gateway.")
.font(.body) .font(.body)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)

View File

@ -193,28 +193,13 @@ final class OnboardingWizardModel {
private func shouldSkipWizard() -> Bool { private func shouldSkipWizard() -> Bool {
let root = OpenClawConfigFile.loadDict() let root = OpenClawConfigFile.loadDict()
return Self.shouldSkipWizard(root: root)
}
static func shouldSkipWizard(root: [String: Any]) -> Bool {
if let wizard = root["wizard"] as? [String: Any], !wizard.isEmpty { if let wizard = root["wizard"] as? [String: Any], !wizard.isEmpty {
return true return true
} }
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any]
{
if let mode = auth["mode"] as? String,
!mode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return true
}
if let token = auth["token"] as? String,
!token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return true
}
if let password = auth["password"] as? String,
!password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return true
}
}
return false return false
} }
} }
@ -254,17 +239,19 @@ struct OnboardingWizardStepView: View {
} }
var body: some View { var body: some View {
Group {
if wizardStepType(self.step) == "select" {
self.selectStepLayout
} else {
self.standardStepLayout
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var standardStepLayout: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
if let title = step.title, !title.isEmpty { self.stepHeader
Text(title)
.font(.title2.weight(.semibold))
}
if let message = step.message, !message.isEmpty {
Text(message)
.font(.body)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
switch wizardStepType(self.step) { switch wizardStepType(self.step) {
case "note": case "note":
@ -274,8 +261,6 @@ struct OnboardingWizardStepView: View {
case "confirm": case "confirm":
Toggle("", isOn: self.$confirmValue) Toggle("", isOn: self.$confirmValue)
.toggleStyle(.switch) .toggleStyle(.switch)
case "select":
self.selectOptions
case "multiselect": case "multiselect":
self.multiselectOptions self.multiselectOptions
case "progress": case "progress":
@ -288,6 +273,57 @@ struct OnboardingWizardStepView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
self.primaryActionButton
}
}
private var selectStepLayout: some View {
VStack(alignment: .leading, spacing: 12) {
self.stepHeader
ScrollView {
self.selectOptions
.padding(.vertical, 2)
}
.frame(minHeight: 220, maxHeight: 320)
Divider()
HStack(alignment: .center, spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text("Selected: \(self.selectedOptionLabel)")
.font(.subheadline.weight(.medium))
if let hint = self.selectedOptionHint {
Text(hint)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
Spacer(minLength: 12)
self.primaryActionButton
}
}
}
@ViewBuilder
private var stepHeader: some View {
if let title = step.title, !title.isEmpty {
Text(title)
.font(.title2.weight(.semibold))
}
if let message = step.message, !message.isEmpty {
Text(message)
.font(.body)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private var primaryActionButton: some View {
Button(action: self.submit) { Button(action: self.submit) {
Text(wizardStepType(self.step) == "action" ? "Run" : "Continue") Text(wizardStepType(self.step) == "action" ? "Run" : "Continue")
.frame(minWidth: 120) .frame(minWidth: 120)
@ -295,8 +331,6 @@ struct OnboardingWizardStepView: View {
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(self.isSubmitting || self.isBlocked) .disabled(self.isSubmitting || self.isBlocked)
} }
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder @ViewBuilder
private var textField: some View { private var textField: some View {
@ -332,11 +366,12 @@ struct OnboardingWizardStepView: View {
Button { Button {
self.selectedIndex = item.index self.selectedIndex = item.index
} label: { } label: {
HStack(alignment: .top, spacing: 8) { HStack(alignment: .top, spacing: 10) {
Image(systemName: self.selectedIndex == item.index ? "largecircle.fill.circle" : "circle") Image(systemName: self.selectedIndex == item.index ? "largecircle.fill.circle" : "circle")
.foregroundStyle(Color.accentColor) .foregroundStyle(Color.accentColor)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(item.option.label) Text(item.option.label)
.font(.body.weight(self.selectedIndex == item.index ? .semibold : .regular))
.foregroundStyle(.primary) .foregroundStyle(.primary)
if let hint = item.option.hint, !hint.isEmpty { if let hint = item.option.hint, !hint.isEmpty {
Text(hint) Text(hint)
@ -344,7 +379,10 @@ struct OnboardingWizardStepView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
Spacer(minLength: 0)
} }
.padding(.vertical, 6)
.contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
@ -381,6 +419,22 @@ struct OnboardingWizardStepView: View {
return false return false
} }
private var selectedOptionLabel: String {
guard self.optionItems.indices.contains(self.selectedIndex) else {
return "None"
}
return self.optionItems[self.selectedIndex].option.label
}
private var selectedOptionHint: String? {
guard self.optionItems.indices.contains(self.selectedIndex) else {
return nil
}
let hint = self.optionItems[self.selectedIndex].option.hint?.trimmingCharacters(
in: .whitespacesAndNewlines)
return hint?.isEmpty == false ? hint : nil
}
private func submit() { private func submit() {
switch wizardStepType(self.step) { switch wizardStepType(self.step) {
case "note", "progress": case "note", "progress":

View File

@ -37,22 +37,16 @@ const AUTH_CHOICE_GROUP_DEFS: {
hint: "setup-token + API key", hint: "setup-token + API key",
choices: ["token", "apiKey"], choices: ["token", "apiKey"],
}, },
{
value: "chutes",
label: "Chutes",
hint: "OAuth",
choices: ["chutes"],
},
{ {
value: "minimax", value: "minimax",
label: "MiniMax", label: "MiniMax",
hint: "M2.5 (recommended)", hint: "OAuth or API key · M2.5",
choices: ["minimax-global-oauth", "minimax-global-api", "minimax-cn-oauth", "minimax-cn-api"], choices: ["minimax-global-oauth", "minimax-global-api", "minimax-cn-oauth", "minimax-cn-api"],
}, },
{ {
value: "moonshot", value: "moonshot",
label: "Moonshot AI (Kimi K2.5)", label: "Moonshot AI (Kimi K2.5)",
hint: "Kimi K2.5 + Kimi Coding", hint: "API key · Kimi K2.5 + Kimi Coding",
choices: ["moonshot-api-key", "moonshot-api-key-cn", "kimi-code-api-key"], choices: ["moonshot-api-key", "moonshot-api-key-cn", "kimi-code-api-key"],
}, },
{ {
@ -67,6 +61,12 @@ const AUTH_CHOICE_GROUP_DEFS: {
hint: "API key", hint: "API key",
choices: ["xai-api-key"], choices: ["xai-api-key"],
}, },
{
value: "chutes",
label: "Chutes",
hint: "OAuth",
choices: ["chutes"],
},
{ {
value: "mistral", value: "mistral",
label: "Mistral AI", label: "Mistral AI",
@ -106,7 +106,7 @@ const AUTH_CHOICE_GROUP_DEFS: {
{ {
value: "zai", value: "zai",
label: "Z.AI", label: "Z.AI",
hint: "GLM Coding Plan / Global / CN", hint: "API key · GLM Coding Plan / Global / CN",
choices: ["zai-coding-global", "zai-coding-cn", "zai-global", "zai-cn"], choices: ["zai-coding-global", "zai-coding-cn", "zai-global", "zai-cn"],
}, },
{ {
@ -124,7 +124,7 @@ const AUTH_CHOICE_GROUP_DEFS: {
{ {
value: "copilot", value: "copilot",
label: "Copilot", label: "Copilot",
hint: "GitHub + local proxy", hint: "OAuth (GitHub device flow) or local proxy",
choices: ["github-copilot", "copilot-proxy"], choices: ["github-copilot", "copilot-proxy"],
}, },
{ {
@ -148,7 +148,7 @@ const AUTH_CHOICE_GROUP_DEFS: {
{ {
value: "synthetic", value: "synthetic",
label: "Synthetic", label: "Synthetic",
hint: "Anthropic-compatible (multi-model)", hint: "API key · Anthropic-compatible (multi-model)",
choices: ["synthetic-api-key"], choices: ["synthetic-api-key"],
}, },
{ {
@ -166,13 +166,13 @@ const AUTH_CHOICE_GROUP_DEFS: {
{ {
value: "venice", value: "venice",
label: "Venice AI", label: "Venice AI",
hint: "Privacy-focused (uncensored models)", hint: "API key · privacy-focused",
choices: ["venice-api-key"], choices: ["venice-api-key"],
}, },
{ {
value: "litellm", value: "litellm",
label: "LiteLLM", label: "LiteLLM",
hint: "Unified LLM gateway (100+ providers)", hint: "API key · 100+ providers",
choices: ["litellm-api-key"], choices: ["litellm-api-key"],
}, },
{ {

View File

@ -28,7 +28,7 @@ export async function promptAuthChoiceGrouped(params: {
]; ];
const providerSelection = (await params.prompter.select({ const providerSelection = (await params.prompter.select({
message: "Model/auth provider", message: "Choose how you want to connect.",
options: providerOptions, options: providerOptions,
})) as string; })) as string;

View File

@ -42,7 +42,7 @@ export async function applyAuthChoiceGitHubCopilot(
}); });
if (params.setDefaultModel) { if (params.setDefaultModel) {
const model = "github-copilot/gpt-4o"; const model = "github-copilot/gpt-5.4";
nextConfig = { nextConfig = {
...nextConfig, ...nextConfig,
agents: { agents: {

View File

@ -1,7 +1,7 @@
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import { ensureModelAllowlistEntry } from "./model-allowlist.js"; import { ensureModelAllowlistEntry } from "./model-allowlist.js";
export const OPENAI_DEFAULT_MODEL = "openai/gpt-5.1-codex"; export const OPENAI_DEFAULT_MODEL = "openai/gpt-5.4";
export function applyOpenAIProviderConfig(cfg: OpenClawConfig): OpenClawConfig { export function applyOpenAIProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const next = ensureModelAllowlistEntry({ const next = ensureModelAllowlistEntry({

View File

@ -122,7 +122,7 @@ export async function runOnboardingWizard(
let flow: WizardFlow = let flow: WizardFlow =
explicitFlow ?? explicitFlow ??
(await prompter.select({ (await prompter.select({
message: "Onboarding mode", message: "Setup mode",
options: [ options: [
{ value: "quickstart", label: "QuickStart", hint: quickstartHint }, { value: "quickstart", label: "QuickStart", hint: quickstartHint },
{ value: "advanced", label: "Manual", hint: manualHint }, { value: "advanced", label: "Manual", hint: manualHint },