mirror of https://github.com/openclaw/openclaw.git
Improve macOS onboarding UX and gateway setup
This commit is contained in:
parent
f4aff83c51
commit
01a6e0da81
|
|
@ -2,6 +2,13 @@ import Foundation
|
|||
|
||||
@MainActor
|
||||
enum CLIInstaller {
|
||||
struct PreflightStatus: Equatable {
|
||||
let needsCommandLineTools: Bool
|
||||
let message: String?
|
||||
|
||||
static let ready = PreflightStatus(needsCommandLineTools: false, message: nil)
|
||||
}
|
||||
|
||||
static func installedLocation() -> String? {
|
||||
self.installedLocation(
|
||||
searchPaths: CommandResolver.preferredPaths(),
|
||||
|
|
@ -34,6 +41,50 @@ enum CLIInstaller {
|
|||
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 {
|
||||
let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
|
||||
let prefix = Self.installPrefix()
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ enum CronCustomSessionTarget: Codable, Equatable {
|
|||
case predefined(CronSessionTarget)
|
||||
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 {
|
||||
switch self {
|
||||
case .predefined(let target):
|
||||
|
|
@ -254,6 +258,38 @@ struct CronJob: Identifiable, Codable, Equatable {
|
|||
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)
|
||||
var parsedSessionTarget: CronCustomSessionTarget {
|
||||
CronCustomSessionTarget.from(self.sessionTargetRaw)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ struct CronSettings_Previews: PreviewProvider {
|
|||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .every(everyMs: 86_400_000, anchorMs: nil),
|
||||
sessionTarget: .isolated,
|
||||
sessionTarget: .predefined(.isolated),
|
||||
wakeMode: .now,
|
||||
payload: .agentTurn(
|
||||
message: "Summarize inbox",
|
||||
|
|
@ -69,7 +69,7 @@ extension CronSettings {
|
|||
createdAtMs: 1_700_000_000_000,
|
||||
updatedAtMs: 1_700_000_100_000,
|
||||
schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
|
||||
sessionTarget: .isolated,
|
||||
sessionTarget: .predefined(.isolated),
|
||||
wakeMode: .nextHeartbeat,
|
||||
payload: .agentTurn(
|
||||
message: "Summarize",
|
||||
|
|
|
|||
|
|
@ -90,7 +90,18 @@ enum GatewayEnvironment {
|
|||
}
|
||||
|
||||
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)
|
||||
return (trimmed?.isEmpty == false) ? trimmed : nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import Foundation
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
|
|
@ -196,13 +197,9 @@ final class GatewayProcessManager {
|
|||
let instanceText = instance.map { self.describe(instance: $0) }
|
||||
let hasListener = instance != nil
|
||||
|
||||
let attemptAttach = {
|
||||
try await self.connection.requestRaw(method: .health, timeoutMs: 2000)
|
||||
}
|
||||
|
||||
for attempt in 0..<(hasListener ? 3 : 1) {
|
||||
do {
|
||||
let data = try await attemptAttach()
|
||||
let data = try await self.probeLocalGatewayHealth(timeoutMs: 2000)
|
||||
let snap = decodeHealthSnapshot(from: data)
|
||||
let details = self.describe(details: instanceText, port: port, snap: snap)
|
||||
self.existingGatewayDetails = details
|
||||
|
|
@ -337,7 +334,7 @@ final class GatewayProcessManager {
|
|||
while Date() < deadline {
|
||||
if !self.desiredActive { return }
|
||||
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 details = instance.map { "pid \($0.pid)" }
|
||||
self.clearLastFailure()
|
||||
|
|
@ -380,7 +377,7 @@ final class GatewayProcessManager {
|
|||
while Date() < deadline {
|
||||
if !self.desiredActive { return false }
|
||||
do {
|
||||
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500)
|
||||
_ = try await self.probeLocalGatewayHealth(timeoutMs: 1500)
|
||||
self.clearLastFailure()
|
||||
return true
|
||||
} catch {
|
||||
|
|
@ -413,6 +410,20 @@ final class GatewayProcessManager {
|
|||
if text.count <= limit { return text }
|
||||
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
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ final class MacNodeModeCoordinator {
|
|||
var retryDelay: UInt64 = 1_000_000_000
|
||||
var lastCameraEnabled: Bool?
|
||||
var lastBrowserControlEnabled: Bool?
|
||||
var lastBlockedOnOnboarding = false
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
while !Task.isCancelled {
|
||||
|
|
@ -41,6 +42,20 @@ final class MacNodeModeCoordinator {
|
|||
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
|
||||
if lastCameraEnabled == nil {
|
||||
lastCameraEnabled = cameraEnabled
|
||||
|
|
@ -116,6 +131,10 @@ final class MacNodeModeCoordinator {
|
|||
}
|
||||
}
|
||||
|
||||
static func shouldConnectNodeMode(onboardingSeen: Bool, onboardingVersion: Int) -> Bool {
|
||||
onboardingSeen && onboardingVersion >= currentOnboardingVersion
|
||||
}
|
||||
|
||||
private func currentCaps() -> [String] {
|
||||
var caps: [String] = [OpenClawCapability.canvas.rawValue, OpenClawCapability.screen.rawValue]
|
||||
if OpenClawConfigFile.browserControlEnabled() {
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ struct OnboardingView: View {
|
|||
@State var isRequesting = false
|
||||
@State var installingCLI = false
|
||||
@State var cliStatus: String?
|
||||
@State var cliPreflightStatus: String?
|
||||
@State var cliNeedsCommandLineTools = false
|
||||
@State var copied = false
|
||||
@State var monitoringPermissions = false
|
||||
@State var monitoringDiscovery = false
|
||||
|
|
@ -97,6 +99,7 @@ struct OnboardingView: View {
|
|||
let pageWidth: CGFloat = Self.windowWidth
|
||||
let contentHeight: CGFloat = 460
|
||||
let connectionPageIndex = 1
|
||||
let cliPageIndex = 6
|
||||
let wizardPageIndex = 3
|
||||
let onboardingChatPageIndex = 8
|
||||
|
||||
|
|
@ -113,7 +116,7 @@ struct OnboardingView: View {
|
|||
case .unconfigured:
|
||||
showOnboardingChat ? [0, 1, 8, 9] : [0, 1, 9]
|
||||
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 {
|
||||
!self.isWizardBlocking
|
||||
if self.activePageIndex == self.cliPageIndex {
|
||||
return self.cliInstalled && !self.installingCLI
|
||||
}
|
||||
return !self.isWizardBlocking
|
||||
}
|
||||
|
||||
var devLinkCommand: String {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import SwiftUI
|
|||
extension OnboardingView {
|
||||
var body: some View {
|
||||
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)
|
||||
.frame(height: 145)
|
||||
|
||||
|
|
@ -46,10 +49,6 @@ extension OnboardingView {
|
|||
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 {
|
||||
self.stopPermissionMonitoring()
|
||||
self.stopDiscovery()
|
||||
|
|
@ -57,7 +56,7 @@ extension OnboardingView {
|
|||
}
|
||||
.task {
|
||||
await self.refreshPerms()
|
||||
self.refreshCLIStatus()
|
||||
await self.refreshCLIInstallerReadiness()
|
||||
await self.loadWorkspaceDefaults()
|
||||
await self.ensureDefaultWorkspace()
|
||||
self.refreshBootstrapStatus()
|
||||
|
|
@ -156,6 +155,16 @@ extension OnboardingView {
|
|||
.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(
|
||||
spacing: CGFloat = 12,
|
||||
padding: CGFloat = 16,
|
||||
|
|
@ -166,10 +175,6 @@ extension OnboardingView {
|
|||
}
|
||||
.padding(padding)
|
||||
.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(
|
||||
|
|
|
|||
|
|
@ -43,6 +43,11 @@ extension OnboardingView {
|
|||
self.updatePermissionMonitoring(for: pageIndex)
|
||||
self.updateDiscoveryMonitoring(for: pageIndex)
|
||||
self.maybeKickoffOnboardingChat(for: pageIndex)
|
||||
if pageIndex == self.cliPageIndex {
|
||||
Task { @MainActor in
|
||||
await self.refreshCLIInstallerReadiness()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopPermissionMonitoring() {
|
||||
|
|
@ -57,12 +62,20 @@ extension OnboardingView {
|
|||
|
||||
func installCLI() async {
|
||||
guard !self.installingCLI else { return }
|
||||
await self.refreshCLIInstallerReadiness()
|
||||
|
||||
if self.cliNeedsCommandLineTools {
|
||||
await self.requestCommandLineToolsInstall()
|
||||
return
|
||||
}
|
||||
|
||||
self.installingCLI = true
|
||||
defer { installingCLI = false }
|
||||
await CLIInstaller.install { message in
|
||||
self.cliStatus = message
|
||||
}
|
||||
self.refreshCLIStatus()
|
||||
await self.refreshCLIInstallerReadiness()
|
||||
}
|
||||
|
||||
func refreshCLIStatus() {
|
||||
|
|
@ -71,6 +84,29 @@ extension OnboardingView {
|
|||
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 {
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
let desc = await PortGuardian.shared.describe(port: port)
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ extension OnboardingView {
|
|||
VStack(spacing: 22) {
|
||||
Text("Welcome to OpenClaw")
|
||||
.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)
|
||||
.foregroundStyle(.secondary)
|
||||
.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)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
|
|
@ -633,57 +640,92 @@ extension OnboardingView {
|
|||
self.onboardingPage {
|
||||
Text("Install the CLI")
|
||||
.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)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 520)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
self.onboardingCard(spacing: 10) {
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.installCLI() }
|
||||
} label: {
|
||||
let title = self.cliInstalled ? "Reinstall CLI" : "Install CLI"
|
||||
ZStack {
|
||||
Text(title)
|
||||
.opacity(self.installingCLI ? 0 : 1)
|
||||
if self.installingCLI {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
self.onboardingCard(spacing: 12) {
|
||||
Button {
|
||||
Task {
|
||||
if self.cliNeedsCommandLineTools {
|
||||
await self.requestCommandLineToolsInstall()
|
||||
} else {
|
||||
await self.installCLI()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
let title: String = if self.cliNeedsCommandLineTools {
|
||||
"Install Apple Developer Tools"
|
||||
} else if self.cliInstalled {
|
||||
"Reinstall CLI"
|
||||
} else {
|
||||
"Install CLI"
|
||||
}
|
||||
ZStack {
|
||||
Text(title)
|
||||
.opacity(self.installingCLI ? 0 : 1)
|
||||
if self.installingCLI {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.installingCLI)
|
||||
|
||||
if self.cliNeedsCommandLineTools {
|
||||
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)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 120)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.installingCLI)
|
||||
|
||||
Button(self.copied ? "Copied" : "Copy install command") {
|
||||
self.copyToPasteboard(self.devLinkCommand)
|
||||
}
|
||||
.disabled(self.installingCLI)
|
||||
|
||||
if self.cliInstalled, let loc = self.cliInstallLocation {
|
||||
Label("Installed at \(loc)", systemImage: "checkmark.circle.fill")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.green)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
if let cliStatus {
|
||||
if self.cliInstalled, let loc = self.cliInstallLocation {
|
||||
Label("Installed at \(loc)", systemImage: "checkmark.circle.fill")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
|
||||
if let cliPreflightStatus, self.cliNeedsCommandLineTools {
|
||||
Text(cliPreflightStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let cliStatus {
|
||||
Text(cliStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if !self.cliInstalled, self.cliInstallLocation == nil {
|
||||
Text(
|
||||
"""
|
||||
Installs a user-space Node 22+ runtime and the CLI (no Homebrew).
|
||||
Rerun anytime to reinstall or update.
|
||||
""")
|
||||
Text("Installs a user-space Node 22+ runtime (no Homebrew required).")
|
||||
.font(.footnote)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import SwiftUI
|
|||
|
||||
extension OnboardingView {
|
||||
func wizardPage() -> some View {
|
||||
self.onboardingPage {
|
||||
self.onboardingFixedPage {
|
||||
VStack(spacing: 16) {
|
||||
Text("Setup Wizard")
|
||||
Text("Configure OpenClaw")
|
||||
.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)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
|
|
|||
|
|
@ -193,28 +193,13 @@ final class OnboardingWizardModel {
|
|||
|
||||
private func shouldSkipWizard() -> Bool {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -254,17 +239,19 @@ struct OnboardingWizardStepView: 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) {
|
||||
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)
|
||||
}
|
||||
self.stepHeader
|
||||
|
||||
switch wizardStepType(self.step) {
|
||||
case "note":
|
||||
|
|
@ -274,8 +261,6 @@ struct OnboardingWizardStepView: View {
|
|||
case "confirm":
|
||||
Toggle("", isOn: self.$confirmValue)
|
||||
.toggleStyle(.switch)
|
||||
case "select":
|
||||
self.selectOptions
|
||||
case "multiselect":
|
||||
self.multiselectOptions
|
||||
case "progress":
|
||||
|
|
@ -288,14 +273,63 @@ struct OnboardingWizardStepView: View {
|
|||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Button(action: self.submit) {
|
||||
Text(wizardStepType(self.step) == "action" ? "Run" : "Continue")
|
||||
.frame(minWidth: 120)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.isSubmitting || self.isBlocked)
|
||||
self.primaryActionButton
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
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) {
|
||||
Text(wizardStepType(self.step) == "action" ? "Run" : "Continue")
|
||||
.frame(minWidth: 120)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.isSubmitting || self.isBlocked)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
|
@ -332,11 +366,12 @@ struct OnboardingWizardStepView: View {
|
|||
Button {
|
||||
self.selectedIndex = item.index
|
||||
} label: {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: self.selectedIndex == item.index ? "largecircle.fill.circle" : "circle")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.option.label)
|
||||
.font(.body.weight(self.selectedIndex == item.index ? .semibold : .regular))
|
||||
.foregroundStyle(.primary)
|
||||
if let hint = item.option.hint, !hint.isEmpty {
|
||||
Text(hint)
|
||||
|
|
@ -344,7 +379,10 @@ struct OnboardingWizardStepView: View {
|
|||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
|
@ -381,6 +419,22 @@ struct OnboardingWizardStepView: View {
|
|||
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() {
|
||||
switch wizardStepType(self.step) {
|
||||
case "note", "progress":
|
||||
|
|
|
|||
|
|
@ -37,22 +37,16 @@ const AUTH_CHOICE_GROUP_DEFS: {
|
|||
hint: "setup-token + API key",
|
||||
choices: ["token", "apiKey"],
|
||||
},
|
||||
{
|
||||
value: "chutes",
|
||||
label: "Chutes",
|
||||
hint: "OAuth",
|
||||
choices: ["chutes"],
|
||||
},
|
||||
{
|
||||
value: "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"],
|
||||
},
|
||||
{
|
||||
value: "moonshot",
|
||||
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"],
|
||||
},
|
||||
{
|
||||
|
|
@ -67,6 +61,12 @@ const AUTH_CHOICE_GROUP_DEFS: {
|
|||
hint: "API key",
|
||||
choices: ["xai-api-key"],
|
||||
},
|
||||
{
|
||||
value: "chutes",
|
||||
label: "Chutes",
|
||||
hint: "OAuth",
|
||||
choices: ["chutes"],
|
||||
},
|
||||
{
|
||||
value: "mistral",
|
||||
label: "Mistral AI",
|
||||
|
|
@ -106,7 +106,7 @@ const AUTH_CHOICE_GROUP_DEFS: {
|
|||
{
|
||||
value: "zai",
|
||||
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"],
|
||||
},
|
||||
{
|
||||
|
|
@ -124,7 +124,7 @@ const AUTH_CHOICE_GROUP_DEFS: {
|
|||
{
|
||||
value: "copilot",
|
||||
label: "Copilot",
|
||||
hint: "GitHub + local proxy",
|
||||
hint: "OAuth (GitHub device flow) or local proxy",
|
||||
choices: ["github-copilot", "copilot-proxy"],
|
||||
},
|
||||
{
|
||||
|
|
@ -148,7 +148,7 @@ const AUTH_CHOICE_GROUP_DEFS: {
|
|||
{
|
||||
value: "synthetic",
|
||||
label: "Synthetic",
|
||||
hint: "Anthropic-compatible (multi-model)",
|
||||
hint: "API key · Anthropic-compatible (multi-model)",
|
||||
choices: ["synthetic-api-key"],
|
||||
},
|
||||
{
|
||||
|
|
@ -166,13 +166,13 @@ const AUTH_CHOICE_GROUP_DEFS: {
|
|||
{
|
||||
value: "venice",
|
||||
label: "Venice AI",
|
||||
hint: "Privacy-focused (uncensored models)",
|
||||
hint: "API key · privacy-focused",
|
||||
choices: ["venice-api-key"],
|
||||
},
|
||||
{
|
||||
value: "litellm",
|
||||
label: "LiteLLM",
|
||||
hint: "Unified LLM gateway (100+ providers)",
|
||||
hint: "API key · 100+ providers",
|
||||
choices: ["litellm-api-key"],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export async function promptAuthChoiceGrouped(params: {
|
|||
];
|
||||
|
||||
const providerSelection = (await params.prompter.select({
|
||||
message: "Model/auth provider",
|
||||
message: "Choose how you want to connect.",
|
||||
options: providerOptions,
|
||||
})) as string;
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export async function applyAuthChoiceGitHubCopilot(
|
|||
});
|
||||
|
||||
if (params.setDefaultModel) {
|
||||
const model = "github-copilot/gpt-4o";
|
||||
const model = "github-copilot/gpt-5.4";
|
||||
nextConfig = {
|
||||
...nextConfig,
|
||||
agents: {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { OpenClawConfig } from "../config/config.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 {
|
||||
const next = ensureModelAllowlistEntry({
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ export async function runOnboardingWizard(
|
|||
let flow: WizardFlow =
|
||||
explicitFlow ??
|
||||
(await prompter.select({
|
||||
message: "Onboarding mode",
|
||||
message: "Setup mode",
|
||||
options: [
|
||||
{ value: "quickstart", label: "QuickStart", hint: quickstartHint },
|
||||
{ value: "advanced", label: "Manual", hint: manualHint },
|
||||
|
|
|
|||
Loading…
Reference in New Issue