mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
feat: verify AI access during macOS onboarding before the first chat (#100288)
* feat(crestodian): add live-tested structured inference setup (detect/activate gateway RPCs) * feat(macos): redesign onboarding around a verified Connect-your-AI step * docs: describe the verified AI onboarding step and gemini setup ladder entry * chore(macos): drop replaced OnboardingView+CrestodianSetup source * fix(macos): keep the AI-detect error card from pairing with an unproven empty-state claim * chore(protocol): regenerate Swift gateway models for crestodian.setup methods * test(crestodian): give setup-inference mocks explicit params for test-types lane * chore(i18n): sync native app string inventory for onboarding redesign * chore(i18n): sync native app string inventory for onboarding redesign
This commit is contained in:
+500
-212
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Settings pane hosting the Crestodian setup/repair chat.
|
||||
///
|
||||
/// Crestodian answers even when no model is configured (deterministic engine
|
||||
/// on the gateway), so this pane is the "always works" place to fix config,
|
||||
/// switch models, connect channels, or run doctor — in plain language.
|
||||
struct CrestodianSettings: View {
|
||||
let isActive: Bool
|
||||
@State private var chat = CrestodianOnboardingChatModel(
|
||||
welcomeVariant: nil,
|
||||
sessionPrefix: "mac-settings-crestodian")
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
SettingsPageHeader(
|
||||
title: "Crestodian",
|
||||
subtitle: "Your setup helper. It can check status, fix config, switch models, " +
|
||||
"and connect channels — even when the agent itself is not working.")
|
||||
|
||||
SettingsCardGroup("Chat") {
|
||||
CrestodianOnboardingChatView(model: self.chat)
|
||||
.frame(maxWidth: .infinity, minHeight: 320, maxHeight: .infinity)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
Text("Tip: try “status”, “doctor”, “set default model …”, or “connect telegram”.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.settingsDetailContent()
|
||||
.task(id: self.isActive) {
|
||||
guard self.isActive else { return }
|
||||
self.chat.onAgentHandoff = {
|
||||
AppNavigationActions.openChat()
|
||||
}
|
||||
await self.chat.startIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,13 @@ enum RemoteOnboardingProbeState: Equatable {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class OnboardingController {
|
||||
final class OnboardingController: NSObject, NSWindowDelegate {
|
||||
static let shared = OnboardingController()
|
||||
private var window: NSWindow?
|
||||
/// Human description of work in flight ("Installing the Gateway…").
|
||||
/// While set, closing the window asks for confirmation instead of quitting
|
||||
/// setup mid-operation.
|
||||
var busyReason: String?
|
||||
|
||||
static func markComplete() {
|
||||
UserDefaults.standard.set(true, forKey: onboardingSeenKey)
|
||||
@@ -47,6 +51,7 @@ final class OnboardingController {
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.titleVisibility = .hidden
|
||||
window.isMovableByWindowBackground = true
|
||||
window.delegate = self
|
||||
window.center()
|
||||
DockIconManager.shared.temporarilyShowDock()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
@@ -55,6 +60,7 @@ final class OnboardingController {
|
||||
}
|
||||
|
||||
func close() {
|
||||
self.busyReason = nil
|
||||
self.window?.close()
|
||||
self.window = nil
|
||||
}
|
||||
@@ -67,12 +73,39 @@ final class OnboardingController {
|
||||
self.close()
|
||||
self.show()
|
||||
}
|
||||
|
||||
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||||
guard let busyReason else { return true }
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Setup is still working"
|
||||
alert.informativeText =
|
||||
"\(busyReason)\n\nYou can keep this window open until it finishes, " +
|
||||
"or quit setup and pick it up again later from the menu bar."
|
||||
alert.alertStyle = .warning
|
||||
alert.addButton(withTitle: "Continue Setup")
|
||||
alert.addButton(withTitle: "Quit Setup")
|
||||
let response = alert.runModal()
|
||||
return response == .alertSecondButtonReturn
|
||||
}
|
||||
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
guard let closing = notification.object as? NSWindow, closing === self.window else { return }
|
||||
self.busyReason = nil
|
||||
self.window = nil
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingView: View {
|
||||
enum CLIInstallPhase {
|
||||
case idle
|
||||
case installing
|
||||
case startingService
|
||||
}
|
||||
|
||||
@State var currentPage = 0
|
||||
@State var isRequesting = false
|
||||
@State var installingCLI = false
|
||||
@State var cliInstallPhase: CLIInstallPhase = .idle
|
||||
@State var cliStatus: String?
|
||||
@State var copied = false
|
||||
@State var monitoringPermissions = false
|
||||
@@ -87,6 +120,7 @@ struct OnboardingView: View {
|
||||
@State var needsBootstrap = false
|
||||
@State var didAutoKickoff = false
|
||||
@State var showAdvancedConnection = false
|
||||
@State var showRemoteChoices = false
|
||||
@State var preferredGatewayID: String?
|
||||
@State var remoteProbeState: RemoteOnboardingProbeState = .idle
|
||||
@State var remoteAuthIssue: RemoteGatewayAuthIssue?
|
||||
@@ -95,7 +129,7 @@ struct OnboardingView: View {
|
||||
@State var onboardingChatModel: OpenClawChatViewModel
|
||||
@State var onboardingSkillsModel = SkillsSettingsModel()
|
||||
@State var crestodianChat = CrestodianOnboardingChatModel()
|
||||
@State var crestodianSetupComplete = false
|
||||
@State var aiSetup = OnboardingAISetupModel()
|
||||
@State var didLoadOnboardingSkills = false
|
||||
@State var localGatewayProbe: LocalGatewayProbe?
|
||||
@State var defaultsToLocalGateway: Bool
|
||||
@@ -106,15 +140,32 @@ struct OnboardingView: View {
|
||||
static let windowHeight: CGFloat = 752 // ~+10% to fit full onboarding content
|
||||
|
||||
let pageWidth: CGFloat = Self.windowWidth
|
||||
// Sized so the permissions page fits all capabilities without scrolling:
|
||||
// 145 (icon header) + 535 + ~60 (nav bar) stays inside windowHeight 752.
|
||||
let contentHeight: CGFloat = 535
|
||||
let connectionPageIndex = 1
|
||||
let cliPageIndex = 2
|
||||
let crestodianPageIndex = 3
|
||||
let aiPageIndex = 3
|
||||
let onboardingChatPageIndex = 8
|
||||
|
||||
let permissionsPageIndex = 5
|
||||
|
||||
/// Chat-like pages shrink the mascot so the conversation gets the room.
|
||||
var usesCompactHero: Bool {
|
||||
[self.aiPageIndex, self.onboardingChatPageIndex].contains(self.activePageIndex)
|
||||
}
|
||||
|
||||
var heroFrameHeight: CGFloat {
|
||||
self.usesCompactHero ? 78 : 145
|
||||
}
|
||||
|
||||
var heroSize: CGFloat {
|
||||
self.usesCompactHero ? 64 : 130
|
||||
}
|
||||
|
||||
/// Sized so the permissions page fits all capabilities without scrolling:
|
||||
/// heroFrameHeight + contentHeight + ~72 (nav bar) fills windowHeight 752.
|
||||
var contentHeight: CGFloat {
|
||||
Self.windowHeight - self.heroFrameHeight - 72
|
||||
}
|
||||
|
||||
static func pageOrder(
|
||||
for mode: AppState.ConnectionMode,
|
||||
showOnboardingChat: Bool,
|
||||
@@ -123,8 +174,9 @@ struct OnboardingView: View {
|
||||
switch mode {
|
||||
case .remote:
|
||||
// Remote setup doesn't need local gateway/CLI/workspace setup pages,
|
||||
// and WhatsApp/Telegram setup is optional.
|
||||
return showOnboardingChat ? [0, 1, 5, 8, 9] : [0, 1, 5, 9]
|
||||
// but the AI check runs against the remote gateway so a broken
|
||||
// remote model surfaces here, not in the first chat.
|
||||
return showOnboardingChat ? [0, 1, 3, 5, 8, 9] : [0, 1, 3, 5, 9]
|
||||
case .unconfigured:
|
||||
return showOnboardingChat ? [0, 1, 8, 9] : [0, 1, 9]
|
||||
case .local:
|
||||
@@ -171,18 +223,18 @@ struct OnboardingView: View {
|
||||
self.activePageIndex == self.cliPageIndex && !self.cliInstalled
|
||||
}
|
||||
|
||||
/// Local onboarding must not finish with nothing configured: the Crestodian
|
||||
/// page blocks Next until setup authored the config (the conversation's
|
||||
/// "yes"), mirroring the old wizard-completion gate. "Configure later" on
|
||||
/// the connection page remains the explicit skip path.
|
||||
var isCrestodianBlocking: Bool {
|
||||
self.activePageIndex == self.crestodianPageIndex &&
|
||||
self.state.connectionMode == .local &&
|
||||
!self.crestodianSetupComplete
|
||||
/// Onboarding must not finish without working inference: the AI page
|
||||
/// blocks Next until a candidate passed its live test (config is authored
|
||||
/// server-side on that success). "Configure later" on the connection page
|
||||
/// remains the explicit skip path.
|
||||
var isAISetupBlocking: Bool {
|
||||
self.activePageIndex == self.aiPageIndex &&
|
||||
self.state.connectionMode != .unconfigured &&
|
||||
!self.aiSetup.connected
|
||||
}
|
||||
|
||||
var canAdvance: Bool {
|
||||
!self.isCLIBlocking && !self.isCrestodianBlocking
|
||||
!self.isCLIBlocking && !self.isAISetupBlocking
|
||||
}
|
||||
|
||||
struct LocalGatewayProbe: Equatable {
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OpenClawIPC
|
||||
import SwiftUI
|
||||
|
||||
/// Structured "Connect your AI" onboarding step.
|
||||
///
|
||||
/// Drives the gateway's `crestodian.setup.detect` / `crestodian.setup.activate`
|
||||
/// RPCs: detect reusable AI access (Claude Code, Codex, Gemini logins, API
|
||||
/// keys), live-test the best candidate, and automatically fall through to the
|
||||
/// next one when a test fails. Config is only written server-side after a
|
||||
/// candidate actually answered, so this page can never strand the user with a
|
||||
/// broken model.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class OnboardingAISetupModel {
|
||||
struct Candidate: Identifiable, Equatable {
|
||||
let kind: String
|
||||
let label: String
|
||||
let detail: String
|
||||
let modelRef: String
|
||||
let recommended: Bool
|
||||
let credentials: Bool?
|
||||
|
||||
var id: String {
|
||||
self.kind
|
||||
}
|
||||
}
|
||||
|
||||
enum CandidateStatus: Equatable {
|
||||
case untried
|
||||
case testing
|
||||
case failed(message: String)
|
||||
case connected
|
||||
}
|
||||
|
||||
enum Phase: Equatable {
|
||||
case idle
|
||||
case detecting
|
||||
case ready
|
||||
case testing
|
||||
case connected
|
||||
}
|
||||
|
||||
enum ManualProvider: String, CaseIterable, Identifiable {
|
||||
case anthropic
|
||||
case openai
|
||||
case google
|
||||
|
||||
var id: String {
|
||||
self.rawValue
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .anthropic: "Claude (Anthropic)"
|
||||
case .openai: "OpenAI"
|
||||
case .google: "Google Gemini"
|
||||
}
|
||||
}
|
||||
|
||||
var keyHint: String {
|
||||
switch self {
|
||||
case .anthropic: "sk-ant-…"
|
||||
case .openai: "sk-…"
|
||||
case .google: "AIza…"
|
||||
}
|
||||
}
|
||||
|
||||
var consoleName: String {
|
||||
switch self {
|
||||
case .anthropic: "console.anthropic.com"
|
||||
case .openai: "platform.openai.com"
|
||||
case .google: "aistudio.google.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private(set) var phase: Phase = .idle {
|
||||
didSet {
|
||||
// Close-guard: quitting mid-test is confirmable, not silent.
|
||||
OnboardingController.shared.busyReason = self.phase == .testing
|
||||
? "OpenClaw is testing your AI connection."
|
||||
: nil
|
||||
}
|
||||
}
|
||||
|
||||
private(set) var candidates: [Candidate] = []
|
||||
private(set) var statuses: [String: CandidateStatus] = [:]
|
||||
private(set) var selectedKind: String?
|
||||
private(set) var connectedModelRef: String?
|
||||
private(set) var connectedLatencyMs: Int?
|
||||
private(set) var detectError: String?
|
||||
/// Set once every detected candidate failed; opens the manual key form.
|
||||
private(set) var exhaustedAutoCandidates = false
|
||||
|
||||
var manualProvider: ManualProvider = .anthropic
|
||||
var manualKey: String = ""
|
||||
private(set) var manualTesting = false
|
||||
private(set) var manualError: String?
|
||||
var showManualEntry = false
|
||||
|
||||
var connected: Bool {
|
||||
self.phase == .connected
|
||||
}
|
||||
|
||||
var isBusy: Bool {
|
||||
self.phase == .detecting || self.phase == .testing || self.manualTesting
|
||||
}
|
||||
|
||||
/// Called when a candidate connects so the page can advance.
|
||||
var onConnected: (() -> Void)?
|
||||
|
||||
private var started = false
|
||||
private var attemptToken = UUID()
|
||||
|
||||
private struct DetectResult: Decodable {
|
||||
struct DetectedCandidate: Decodable {
|
||||
let kind: String
|
||||
let label: String
|
||||
let detail: String
|
||||
let modelRef: String
|
||||
let recommended: Bool
|
||||
let credentials: Bool?
|
||||
}
|
||||
|
||||
let candidates: [DetectedCandidate]
|
||||
let workspace: String
|
||||
let configuredModel: String?
|
||||
let setupComplete: Bool
|
||||
}
|
||||
|
||||
private struct ActivateResult: Decodable {
|
||||
let ok: Bool
|
||||
let modelRef: String?
|
||||
let latencyMs: Double?
|
||||
let status: String?
|
||||
let error: String?
|
||||
}
|
||||
|
||||
func startIfNeeded() {
|
||||
guard !self.started else { return }
|
||||
self.started = true
|
||||
Task { await self.detectAndAutoConnect() }
|
||||
}
|
||||
|
||||
func retryFromScratch() {
|
||||
self.attemptToken = UUID()
|
||||
self.phase = .idle
|
||||
self.candidates = []
|
||||
self.statuses = [:]
|
||||
self.selectedKind = nil
|
||||
self.detectError = nil
|
||||
self.exhaustedAutoCandidates = false
|
||||
self.manualError = nil
|
||||
self.manualTesting = false
|
||||
self.showManualEntry = false
|
||||
Task { await self.detectAndAutoConnect() }
|
||||
}
|
||||
|
||||
func detectAndAutoConnect() async {
|
||||
let token = self.attemptToken
|
||||
self.phase = .detecting
|
||||
self.detectError = nil
|
||||
do {
|
||||
let data = try await GatewayConnection.shared.request(
|
||||
method: "crestodian.setup.detect",
|
||||
params: [:],
|
||||
timeoutMs: 20000,
|
||||
retryTransportFailures: true)
|
||||
guard token == self.attemptToken else { return }
|
||||
let result = try JSONDecoder().decode(DetectResult.self, from: data)
|
||||
self.candidates = result.candidates.map { detected in
|
||||
Candidate(
|
||||
kind: detected.kind,
|
||||
label: detected.label,
|
||||
detail: detected.detail,
|
||||
modelRef: detected.modelRef,
|
||||
recommended: detected.recommended,
|
||||
credentials: detected.credentials)
|
||||
}
|
||||
for candidate in self.candidates {
|
||||
self.statuses[candidate.kind] = .untried
|
||||
}
|
||||
self.phase = .ready
|
||||
if let first = self.autoCandidateAfter(kind: nil) {
|
||||
// Best candidate found: connect without asking. Switching later
|
||||
// stays one click away while the test runs server-side.
|
||||
await self.activate(kind: first.kind)
|
||||
} else {
|
||||
self.showManualEntry = true
|
||||
}
|
||||
} catch {
|
||||
guard token == self.attemptToken else { return }
|
||||
self.phase = .ready
|
||||
self.detectError = Self.friendlyTransportError(error.localizedDescription)
|
||||
self.showManualEntry = self.candidates.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport/protocol failures deserve plain language, not RPC codes.
|
||||
static func friendlyTransportError(_ raw: String) -> String {
|
||||
if raw.localizedCaseInsensitiveContains("unknown method") {
|
||||
return "The Gateway is running an older OpenClaw version that doesn’t support " +
|
||||
"app-guided setup. Update OpenClaw on the gateway, then try again."
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/// Candidates the automatic ladder may try: skip definitively logged-out
|
||||
/// installs and anything already attempted.
|
||||
private func autoCandidateAfter(kind: String?) -> Candidate? {
|
||||
let startIndex: Int = if let kind, let index = self.candidates.firstIndex(where: { $0.kind == kind }) {
|
||||
index + 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
guard startIndex <= self.candidates.count else { return nil }
|
||||
return self.candidates[startIndex...].first { candidate in
|
||||
candidate.credentials != false && self.statuses[candidate.kind] == .untried
|
||||
}
|
||||
}
|
||||
|
||||
func userSelect(kind: String) {
|
||||
guard !self.isBusy else { return }
|
||||
guard self.statuses[kind] != .connected else { return }
|
||||
Task { await self.activate(kind: kind) }
|
||||
}
|
||||
|
||||
func activate(kind: String) async {
|
||||
let token = self.attemptToken
|
||||
self.selectedKind = kind
|
||||
self.phase = .testing
|
||||
self.statuses[kind] = .testing
|
||||
do {
|
||||
let data = try await GatewayConnection.shared.request(
|
||||
method: "crestodian.setup.activate",
|
||||
params: ["kind": AnyCodable(kind)],
|
||||
timeoutMs: 150_000,
|
||||
retryTransportFailures: false)
|
||||
guard token == self.attemptToken else { return }
|
||||
let result = try JSONDecoder().decode(ActivateResult.self, from: data)
|
||||
if result.ok {
|
||||
self.finishConnected(kind: kind, result: result)
|
||||
} else {
|
||||
self.statuses[kind] = .failed(message: Self.friendlyFailure(
|
||||
label: self.candidates.first { $0.kind == kind }?.label ?? kind,
|
||||
status: result.status,
|
||||
error: result.error))
|
||||
await self.tryNextAfterFailure(of: kind)
|
||||
}
|
||||
} catch {
|
||||
guard token == self.attemptToken else { return }
|
||||
self.statuses[kind] = .failed(message: Self.friendlyTransportError(error.localizedDescription))
|
||||
await self.tryNextAfterFailure(of: kind)
|
||||
}
|
||||
}
|
||||
|
||||
func submitManualKey() {
|
||||
let key = self.manualKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty, !self.manualTesting else { return }
|
||||
self.manualError = nil
|
||||
self.manualTesting = true
|
||||
let token = self.attemptToken
|
||||
Task {
|
||||
defer { self.manualTesting = false }
|
||||
do {
|
||||
let data = try await GatewayConnection.shared.request(
|
||||
method: "crestodian.setup.activate",
|
||||
params: [
|
||||
"kind": AnyCodable("api-key"),
|
||||
"provider": AnyCodable(self.manualProvider.rawValue),
|
||||
"apiKey": AnyCodable(key),
|
||||
],
|
||||
timeoutMs: 150_000,
|
||||
retryTransportFailures: false)
|
||||
guard token == self.attemptToken else { return }
|
||||
let result = try JSONDecoder().decode(ActivateResult.self, from: data)
|
||||
if result.ok {
|
||||
self.manualKey = ""
|
||||
self.finishConnected(kind: "api-key", result: result)
|
||||
} else {
|
||||
self.manualError = Self.friendlyFailure(
|
||||
label: self.manualProvider.title,
|
||||
status: result.status,
|
||||
error: result.error)
|
||||
}
|
||||
} catch {
|
||||
guard token == self.attemptToken else { return }
|
||||
self.manualError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func finishConnected(kind: String, result: ActivateResult) {
|
||||
self.statuses[kind] = .connected
|
||||
self.selectedKind = kind
|
||||
self.connectedModelRef = result.modelRef
|
||||
self.connectedLatencyMs = result.latencyMs.map { Int($0.rounded()) }
|
||||
self.phase = .connected
|
||||
self.onConnected?()
|
||||
}
|
||||
|
||||
private func tryNextAfterFailure(of kind: String) async {
|
||||
if let next = self.autoCandidateAfter(kind: kind) {
|
||||
await self.activate(kind: next.kind)
|
||||
return
|
||||
}
|
||||
self.phase = .ready
|
||||
self.exhaustedAutoCandidates = true
|
||||
self.showManualEntry = true
|
||||
}
|
||||
|
||||
/// One friendly sentence per failure bucket; raw detail stays available
|
||||
/// underneath so support/docs can work with it.
|
||||
static func friendlyFailure(label: String, status: String?, error: String?) -> String {
|
||||
let detail = error?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
switch status {
|
||||
case "auth":
|
||||
return "\(label) is installed, but the login didn’t work. Sign in again, then retry."
|
||||
case "billing":
|
||||
return "\(label) responded, but the account has a billing problem."
|
||||
case "rate_limit":
|
||||
return "\(label) is temporarily rate-limited. Try again in a moment."
|
||||
case "timeout":
|
||||
return "\(label) didn’t answer in time."
|
||||
case "format", "unavailable":
|
||||
return detail.isEmpty ? "\(label) couldn’t complete the test." : detail
|
||||
default:
|
||||
return detail.isEmpty ? "\(label) couldn’t complete the test." : detail
|
||||
}
|
||||
}
|
||||
|
||||
var connectedSummary: String {
|
||||
guard let modelRef = self.connectedModelRef else { return "Your AI is connected." }
|
||||
let label = self.candidates.first { $0.kind == self.selectedKind }?.label
|
||||
let via = label.map { " via \($0)" } ?? ""
|
||||
if let latency = self.connectedLatencyMs {
|
||||
let seconds = Double(latency) / 1000
|
||||
return "\(modelRef)\(via) — replied in \(String(format: "%.1f", seconds))s"
|
||||
}
|
||||
return "\(modelRef)\(via)"
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingAISetupView: View {
|
||||
@Bindable var model: OnboardingAISetupModel
|
||||
@State private var showCrestodianChat = false
|
||||
var crestodianChat: CrestodianOnboardingChatModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
switch self.model.phase {
|
||||
case .idle, .detecting:
|
||||
self.detectingView
|
||||
default:
|
||||
self.resultsView
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.sheet(isPresented: self.$showCrestodianChat) {
|
||||
self.crestodianSheet
|
||||
}
|
||||
}
|
||||
|
||||
private var detectingView: some View {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Looking for AI you already use…")
|
||||
.font(.callout.weight(.semibold))
|
||||
Text("Checking for Claude Code, Codex, Gemini, and saved API keys.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var resultsView: some View {
|
||||
if self.model.connected {
|
||||
self.connectedBanner
|
||||
}
|
||||
|
||||
if !self.model.candidates.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(self.model.candidates) { candidate in
|
||||
self.candidateRow(candidate)
|
||||
}
|
||||
}
|
||||
} else if self.model.phase != .connected, self.model.detectError == nil {
|
||||
// A failed detect must not claim "nothing found" — the error card
|
||||
// below owns that state and the claim would be unproven.
|
||||
self.noCandidatesIntro
|
||||
}
|
||||
|
||||
if let detectError = self.model.detectError {
|
||||
OnboardingErrorCard(
|
||||
title: "Couldn’t check this Mac for AI accounts",
|
||||
message: detectError,
|
||||
docsSlug: "start/onboarding",
|
||||
retryTitle: "Try again")
|
||||
{
|
||||
self.model.retryFromScratch()
|
||||
}
|
||||
}
|
||||
|
||||
if self.model.exhaustedAutoCandidates, !self.model.connected {
|
||||
OnboardingErrorCard(
|
||||
title: "None of the found options worked",
|
||||
message: "The details are listed on each option above. You can fix the login and retry, or connect with an API key below.",
|
||||
docsSlug: "concepts/model-providers",
|
||||
retryTitle: "Check again")
|
||||
{
|
||||
self.model.retryFromScratch()
|
||||
}
|
||||
}
|
||||
|
||||
if !self.model.connected {
|
||||
self.manualSection
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
Button {
|
||||
self.showCrestodianChat = true
|
||||
} label: {
|
||||
Label("Need help? Chat with Crestodian", systemImage: "questionmark.bubble")
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.link)
|
||||
}
|
||||
}
|
||||
|
||||
private var connectedBanner: some View {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.green)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Your AI is ready")
|
||||
.font(.headline)
|
||||
Text(self.model.connectedSummary)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color.green.opacity(0.12)))
|
||||
}
|
||||
|
||||
private var noCandidatesIntro: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("No AI accounts found on this Mac")
|
||||
.font(.headline)
|
||||
Text(
|
||||
"That’s fine — you can connect one with an API key. " +
|
||||
"If you use Claude Code, Codex, or the Gemini CLI on this Mac, " +
|
||||
"sign in there first and hit “Check again”.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Button("Check again") {
|
||||
self.model.retryFromScratch()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func candidateRow(_ candidate: OnboardingAISetupModel.Candidate) -> some View {
|
||||
let status = self.model.statuses[candidate.kind] ?? .untried
|
||||
let selected = self.model.selectedKind == candidate.kind
|
||||
return Button {
|
||||
self.model.userSelect(kind: candidate.kind)
|
||||
} label: {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Image(systemName: Self.symbol(for: candidate.kind))
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.frame(width: 26)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(candidate.label)
|
||||
.font(.callout.weight(.semibold))
|
||||
if candidate.recommended, status != .connected {
|
||||
Text("Recommended")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Capsule().fill(Color.accentColor.opacity(0.16)))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
Text(self.subtitle(for: candidate, status: status))
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.subtitleStyle(for: status))
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
self.trailingIndicator(status: status, selected: selected)
|
||||
}
|
||||
.openClawSelectableRowChrome(selected: selected && status != .failed(message: ""))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(self.model.isBusy || self.model.connected)
|
||||
}
|
||||
|
||||
private func subtitle(
|
||||
for candidate: OnboardingAISetupModel.Candidate,
|
||||
status: OnboardingAISetupModel.CandidateStatus) -> String
|
||||
{
|
||||
switch status {
|
||||
case .testing:
|
||||
"Testing — asking \(candidate.modelRef) for a quick reply…"
|
||||
case let .failed(message):
|
||||
message
|
||||
case .connected:
|
||||
self.model.connectedSummary
|
||||
case .untried:
|
||||
"\(candidate.modelRef) · \(candidate.detail)"
|
||||
}
|
||||
}
|
||||
|
||||
private func subtitleStyle(
|
||||
for status: OnboardingAISetupModel.CandidateStatus) -> Color
|
||||
{
|
||||
if case .failed = status {
|
||||
return .orange
|
||||
}
|
||||
return .secondary
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func trailingIndicator(
|
||||
status: OnboardingAISetupModel.CandidateStatus,
|
||||
selected: Bool) -> some View
|
||||
{
|
||||
switch status {
|
||||
case .testing:
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
case .connected:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
case .failed:
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
case .untried:
|
||||
SelectionStateIndicator(selected: selected)
|
||||
}
|
||||
}
|
||||
|
||||
private static func symbol(for kind: String) -> String {
|
||||
switch kind {
|
||||
case "claude-cli": "sparkle"
|
||||
case "codex-cli": "chevron.left.forwardslash.chevron.right"
|
||||
case "gemini-cli": "diamond"
|
||||
case "existing-model": "checkmark.seal"
|
||||
default: "key.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private var manualSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if self.model.candidates.isEmpty || self.model.showManualEntry {
|
||||
self.manualForm
|
||||
} else {
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
||||
self.model.showManualEntry = true
|
||||
}
|
||||
} label: {
|
||||
Label("Connect with an API key instead…", systemImage: "key")
|
||||
.font(.callout)
|
||||
}
|
||||
.buttonStyle(.link)
|
||||
.disabled(self.model.isBusy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var manualForm: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Connect with an API key")
|
||||
.font(.headline)
|
||||
HStack(spacing: 8) {
|
||||
Picker("Provider", selection: self.$model.manualProvider) {
|
||||
ForEach(OnboardingAISetupModel.ManualProvider.allCases) { provider in
|
||||
Text(provider.title).tag(provider)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 170)
|
||||
|
||||
SecureField(self.model.manualProvider.keyHint, text: self.$model.manualKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit { self.model.submitManualKey() }
|
||||
|
||||
Button {
|
||||
self.model.submitManualKey()
|
||||
} label: {
|
||||
if self.model.manualTesting {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.frame(minWidth: 74)
|
||||
} else {
|
||||
Text("Connect")
|
||||
.frame(minWidth: 74)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.model.manualTesting ||
|
||||
self.model.manualKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
Text(
|
||||
"Create a key at \(self.model.manualProvider.consoleName), paste it here, " +
|
||||
"and OpenClaw checks it with a real test question.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let manualError = self.model.manualError {
|
||||
OnboardingErrorCard(
|
||||
title: "That key didn’t work",
|
||||
message: manualError,
|
||||
docsSlug: "concepts/model-providers",
|
||||
retryTitle: nil,
|
||||
retry: nil)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(NSColor.controlBackgroundColor)))
|
||||
}
|
||||
|
||||
private var crestodianSheet: some View {
|
||||
VStack(spacing: 8) {
|
||||
HStack {
|
||||
Label("Crestodian — setup helper", systemImage: "lifepreserver")
|
||||
.font(.headline)
|
||||
Spacer(minLength: 0)
|
||||
Button("Done") {
|
||||
self.showCrestodianChat = false
|
||||
}
|
||||
}
|
||||
.padding([.top, .horizontal], 14)
|
||||
CrestodianOnboardingChatView(model: self.crestodianChat)
|
||||
.task { await self.crestodianChat.startIfNeeded() }
|
||||
}
|
||||
.frame(width: 520, height: 480)
|
||||
}
|
||||
}
|
||||
|
||||
/// Friendly error presentation with a consistent docs escape hatch.
|
||||
/// Every onboarding failure points at a docs.openclaw.ai page so people are
|
||||
/// never stuck staring at a raw error string.
|
||||
struct OnboardingErrorCard: View {
|
||||
let title: String
|
||||
let message: String
|
||||
let docsSlug: String
|
||||
var retryTitle: String?
|
||||
var retry: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.top, 1)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.title)
|
||||
.font(.callout.weight(.semibold))
|
||||
Text(self.message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
HStack(spacing: 14) {
|
||||
if let retryTitle = self.retryTitle, let retry = self.retry {
|
||||
Button(retryTitle, action: retry)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
Button("Open help…") {
|
||||
if let url = URL(string: "https://docs.openclaw.ai/\(self.docsSlug)") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.link)
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color.orange.opacity(0.10)))
|
||||
}
|
||||
}
|
||||
@@ -30,9 +30,17 @@ final class CrestodianOnboardingChatModel {
|
||||
/// Called after every assistant reply (setup may have applied config).
|
||||
var onReplyReceived: (() -> Void)?
|
||||
|
||||
private let sessionId = "mac-onboarding-\(UUID().uuidString)"
|
||||
private let sessionId: String
|
||||
/// "onboarding" seeds the first-run setup proposal; nil gets the
|
||||
/// status/repair greeting (used by Settings → Crestodian).
|
||||
private let welcomeVariant: String?
|
||||
private var started = false
|
||||
|
||||
init(welcomeVariant: String? = "onboarding", sessionPrefix: String = "mac-onboarding") {
|
||||
self.welcomeVariant = welcomeVariant
|
||||
self.sessionId = "\(sessionPrefix)-\(UUID().uuidString)"
|
||||
}
|
||||
|
||||
private struct ChatResult: Decodable {
|
||||
let sessionId: String
|
||||
let reply: String
|
||||
@@ -67,8 +75,10 @@ final class CrestodianOnboardingChatModel {
|
||||
do {
|
||||
var params: [String: AnyCodable] = [
|
||||
"sessionId": AnyCodable(self.sessionId),
|
||||
"welcomeVariant": AnyCodable("onboarding"),
|
||||
]
|
||||
if let welcomeVariant = self.welcomeVariant {
|
||||
params["welcomeVariant"] = AnyCodable(welcomeVariant)
|
||||
}
|
||||
if let message {
|
||||
params["message"] = AnyCodable(message)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import SwiftUI
|
||||
|
||||
extension OnboardingView {
|
||||
/// Structured AI setup: detect what's already on this machine, test the
|
||||
/// best option live, fall through automatically, offer an API-key form
|
||||
/// when nothing works. Crestodian chat stays one click away for help.
|
||||
func aiSetupPage() -> some View {
|
||||
VStack(spacing: 12) {
|
||||
Text("Connect your AI")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text(self.aiSetupSubtitle)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 540)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
ScrollView {
|
||||
OnboardingAISetupView(model: self.aiSetup, crestodianChat: self.crestodianChat)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
.scrollIndicators(.automatic)
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
.frame(width: self.pageWidth, height: self.contentHeight, alignment: .top)
|
||||
}
|
||||
|
||||
private var aiSetupSubtitle: String {
|
||||
if self.aiSetup.connected {
|
||||
return "All good — your assistant has a working AI connection."
|
||||
}
|
||||
return "OpenClaw needs an AI account to think. " +
|
||||
"It reuses what you already have — nothing new to sign up for if " +
|
||||
"Claude Code, Codex, or an API key is on this Mac."
|
||||
}
|
||||
|
||||
func maybeStartAISetup(for pageIndex: Int) {
|
||||
guard pageIndex == self.aiPageIndex else { return }
|
||||
// Local mode reaches this page only after the CLI/gateway install page,
|
||||
// so the gateway is up before the first RPC.
|
||||
guard self.state.connectionMode != .local || self.cliInstalled else { return }
|
||||
if self.aiSetup.onConnected == nil {
|
||||
self.aiSetup.onConnected = { [self] in
|
||||
// Setup authored the workspace (BOOTSTRAP.md); re-check so the
|
||||
// Meet-your-agent page joins the flow.
|
||||
self.refreshBootstrapStatus()
|
||||
}
|
||||
}
|
||||
self.aiSetup.startIfNeeded()
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ extension OnboardingView {
|
||||
self.state.connectionMode = .local
|
||||
self.preferredGatewayID = nil
|
||||
self.showAdvancedConnection = false
|
||||
self.showRemoteChoices = false
|
||||
GatewayDiscoveryPreferences.setPreferredStableID(nil)
|
||||
}
|
||||
|
||||
@@ -18,6 +19,7 @@ extension OnboardingView {
|
||||
self.state.connectionMode = .unconfigured
|
||||
self.preferredGatewayID = nil
|
||||
self.showAdvancedConnection = false
|
||||
self.showRemoteChoices = false
|
||||
GatewayDiscoveryPreferences.setPreferredStableID(nil)
|
||||
}
|
||||
|
||||
@@ -64,6 +66,11 @@ extension OnboardingView {
|
||||
func finish() {
|
||||
OnboardingController.markComplete()
|
||||
OnboardingController.shared.close()
|
||||
// Land people in the real conversation, not on an empty desktop: the
|
||||
// agent chat is the product, and it is verified working by now.
|
||||
if self.state.connectionMode != .unconfigured {
|
||||
AppNavigationActions.openChat()
|
||||
}
|
||||
}
|
||||
|
||||
func copyToPasteboard(_ text: String) {
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension OnboardingView {
|
||||
/// Conversational setup: the user talks to Crestodian over the gateway and
|
||||
/// it configures everything (AI detection, config, workspace). No wizard.
|
||||
func crestodianSetupPage() -> some View {
|
||||
VStack(spacing: 12) {
|
||||
Text("Talk to Crestodian")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text(
|
||||
"Crestodian is OpenClaw's setup custodian. It finds AI access you already have — " +
|
||||
"a Claude Code or Codex login, or API keys — and sets everything up when you say yes. " +
|
||||
"Just tell it what you want.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 540)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
self.onboardingGlassCard(padding: 4) {
|
||||
CrestodianOnboardingChatView(model: self.crestodianChat)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
.frame(width: self.pageWidth, height: self.contentHeight, alignment: .top)
|
||||
}
|
||||
|
||||
func maybeStartCrestodianChat(for pageIndex: Int) {
|
||||
self.refreshCrestodianSetupComplete()
|
||||
guard pageIndex == self.crestodianPageIndex else { return }
|
||||
// Local mode reaches this page only after the CLI/gateway install page,
|
||||
// so the gateway is up before the first RPC.
|
||||
guard self.state.connectionMode != .local || self.cliInstalled else { return }
|
||||
if self.crestodianChat.onAgentHandoff == nil {
|
||||
self.crestodianChat.onAgentHandoff = { [self] in
|
||||
// "talk to agent": refresh workspace state so the agent chat
|
||||
// page appears, then advance.
|
||||
self.refreshBootstrapStatus()
|
||||
self.refreshCrestodianSetupComplete()
|
||||
self.handleNext()
|
||||
}
|
||||
}
|
||||
if self.crestodianChat.onReplyReceived == nil {
|
||||
self.crestodianChat.onReplyReceived = { [self] in
|
||||
// Setup applies mid-conversation; re-check so Next unlocks.
|
||||
self.refreshCrestodianSetupComplete()
|
||||
}
|
||||
}
|
||||
Task { await self.crestodianChat.startIfNeeded() }
|
||||
}
|
||||
|
||||
/// Setup is complete once the config carries authored wizard/gateway-auth
|
||||
/// state — the same signal the old step wizard used to skip itself.
|
||||
func refreshCrestodianSetupComplete() {
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
if let wizard = root["wizard"] as? [String: Any], !wizard.isEmpty {
|
||||
self.crestodianSetupComplete = true
|
||||
return
|
||||
}
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
Self.hasCrestodianSetupAuth(auth)
|
||||
{
|
||||
self.crestodianSetupComplete = true
|
||||
return
|
||||
}
|
||||
self.crestodianSetupComplete = false
|
||||
}
|
||||
|
||||
static func hasCrestodianSetupAuth(_ auth: [String: Any]) -> Bool {
|
||||
if let mode = auth["mode"] as? String,
|
||||
!mode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
return true
|
||||
}
|
||||
return ["token", "password"].contains { key in
|
||||
if let value = auth[key] as? String {
|
||||
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
guard let ref = auth[key] as? [String: Any], ref.count == 3,
|
||||
let source = ref["source"] as? String,
|
||||
["env", "file", "exec"].contains(source),
|
||||
let provider = ref["provider"] as? String,
|
||||
!provider.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
let id = ref["id"] as? String
|
||||
else { return false }
|
||||
return !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import SwiftUI
|
||||
extension OnboardingView {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
GlowingOpenClawIcon(size: 130)
|
||||
.offset(y: 10)
|
||||
.frame(height: 145)
|
||||
// Chat-heavy pages shrink the mascot so the content gets the room.
|
||||
GlowingOpenClawIcon(size: self.heroSize)
|
||||
.offset(y: self.usesCompactHero ? 4 : 10)
|
||||
.frame(height: self.heroFrameHeight)
|
||||
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: self.usesCompactHero)
|
||||
|
||||
GeometryReader { _ in
|
||||
HStack(spacing: 0) {
|
||||
@@ -23,6 +25,7 @@ extension OnboardingView {
|
||||
.clipped()
|
||||
}
|
||||
.frame(height: self.contentHeight)
|
||||
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: self.usesCompactHero)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
self.navigationBar
|
||||
@@ -87,7 +90,7 @@ extension OnboardingView {
|
||||
var navigationBar: some View {
|
||||
let connectionLockIndex = pageOrder.firstIndex(of: connectionPageIndex)
|
||||
let cliLockIndex = pageOrder.firstIndex(of: cliPageIndex)
|
||||
let crestodianLockIndex = pageOrder.firstIndex(of: crestodianPageIndex)
|
||||
let aiLockIndex = pageOrder.firstIndex(of: aiPageIndex)
|
||||
return HStack(spacing: 20) {
|
||||
ZStack(alignment: .leading) {
|
||||
Button(action: {}, label: {
|
||||
@@ -105,7 +108,7 @@ extension OnboardingView {
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.secondary)
|
||||
.opacity(0.8)
|
||||
.disabled(self.installingCLI)
|
||||
.disabled(self.installingCLI || self.aiSetup.isBusy)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
}
|
||||
}
|
||||
@@ -115,18 +118,19 @@ extension OnboardingView {
|
||||
|
||||
HStack(spacing: 8) {
|
||||
ForEach(0..<self.pageCount, id: \.self) { index in
|
||||
let isInstallLocked = self.installingCLI && index != self.currentPage
|
||||
let isInstallLocked = (self.installingCLI || self.aiSetup.isBusy) &&
|
||||
index != self.currentPage
|
||||
let isConnectionLocked = self.isConnectionSelectionBlocking &&
|
||||
index > (connectionLockIndex ?? 0)
|
||||
let isCLILocked = cliLockIndex != nil && !self.cliInstalled && index > (cliLockIndex ?? 0)
|
||||
// Dots must honor the same setup gate as Next: no jumping
|
||||
// past the Crestodian page before setup authored the config.
|
||||
let isCrestodianLocked = crestodianLockIndex != nil &&
|
||||
self.state.connectionMode == .local &&
|
||||
!self.crestodianSetupComplete &&
|
||||
index > (crestodianLockIndex ?? 0)
|
||||
// past the AI page before a candidate passed its live test.
|
||||
let isAILocked = aiLockIndex != nil &&
|
||||
self.state.connectionMode != .unconfigured &&
|
||||
!self.aiSetup.connected &&
|
||||
index > (aiLockIndex ?? 0)
|
||||
let isLocked = isInstallLocked || isConnectionLocked || isCLILocked ||
|
||||
isCrestodianLocked
|
||||
isAILocked
|
||||
Button {
|
||||
withAnimation { self.currentPage = index }
|
||||
} label: {
|
||||
|
||||
@@ -43,9 +43,9 @@ extension OnboardingView {
|
||||
self.updatePermissionMonitoring(for: pageIndex)
|
||||
self.updateDiscoveryMonitoring(for: pageIndex)
|
||||
self.maybeInstallCLI(for: pageIndex)
|
||||
self.maybeStartCrestodianChat(for: pageIndex)
|
||||
// Crestodian setup creates the workspace (BOOTSTRAP.md); re-check so
|
||||
// the Meet-your-agent page joins the flow once setup ran.
|
||||
self.maybeStartAISetup(for: pageIndex)
|
||||
// AI setup creates the workspace (BOOTSTRAP.md); re-check so the
|
||||
// Meet-your-agent page joins the flow once setup ran.
|
||||
self.refreshBootstrapStatus()
|
||||
maybeKickoffOnboardingChat(for: pageIndex)
|
||||
}
|
||||
@@ -77,6 +77,8 @@ extension OnboardingView {
|
||||
guard self.onboardingVisible, !installingCLI else { return }
|
||||
installingCLI = true
|
||||
OnboardingController.shared.setWindowCloseEnabled(false)
|
||||
// Cmd-W bypasses the disabled close button; the delegate asks first.
|
||||
OnboardingController.shared.busyReason = "OpenClaw is installing the Gateway service."
|
||||
Task { @MainActor in await self.runCLIInstall() }
|
||||
}
|
||||
|
||||
@@ -91,9 +93,12 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
func runCLIInstall() async {
|
||||
self.cliInstallPhase = .installing
|
||||
defer {
|
||||
self.installingCLI = false
|
||||
self.cliInstallPhase = .idle
|
||||
OnboardingController.shared.setWindowCloseEnabled(true)
|
||||
OnboardingController.shared.busyReason = nil
|
||||
}
|
||||
let installed = await CLIInstaller.install { message in
|
||||
self.cliStatus = message
|
||||
@@ -101,6 +106,9 @@ extension OnboardingView {
|
||||
guard installed else { return }
|
||||
cliInstallLocation = CLIInstaller.managedExecutableLocation()
|
||||
cliStatus = "Starting OpenClaw Gateway…"
|
||||
// The step checklist shows one spinner at a time: install first,
|
||||
// then the service start.
|
||||
self.cliInstallPhase = .startingService
|
||||
switch await CLIInstaller.activateLocalGateway() {
|
||||
case .ready:
|
||||
cliStatus = "OpenClaw Gateway is ready."
|
||||
|
||||
@@ -16,7 +16,7 @@ extension OnboardingView {
|
||||
case 2:
|
||||
self.cliPage()
|
||||
case 3:
|
||||
self.crestodianSetupPage()
|
||||
self.aiSetupPage()
|
||||
case 5:
|
||||
self.permissionsPage()
|
||||
case 8:
|
||||
@@ -31,15 +31,20 @@ extension OnboardingView {
|
||||
func welcomePage() -> some View {
|
||||
self.onboardingPage {
|
||||
VStack(spacing: 18) {
|
||||
Text("Welcome to OpenClaw")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
VStack(spacing: 8) {
|
||||
Text("Welcome to OpenClaw")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("Your personal AI assistant, living on your own Mac.")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(
|
||||
"OpenClaw is your personal AI assistant. It can answer questions, work with files and apps, " +
|
||||
"and connect to services like WhatsApp and Telegram through a Gateway you control.")
|
||||
"It answers questions, works with your files and apps, and can chat with you " +
|
||||
"on WhatsApp or Telegram. Setup takes about two minutes.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 560)
|
||||
.frame(maxWidth: 520)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
self.onboardingCard(spacing: 14, padding: 16) {
|
||||
@@ -48,12 +53,12 @@ extension OnboardingView {
|
||||
subtitle: "Give your assistant tasks and let it help across your Mac.",
|
||||
systemImage: "sparkles")
|
||||
self.featureRow(
|
||||
title: "Connect the tools you use",
|
||||
subtitle: "Bring conversations and services together in one assistant.",
|
||||
systemImage: "point.3.connected.trianglepath.dotted")
|
||||
title: "Chat wherever you like",
|
||||
subtitle: "This app, WhatsApp, Telegram, Discord, Slack — your choice.",
|
||||
systemImage: "bubble.left.and.bubble.right.fill")
|
||||
self.featureRow(
|
||||
title: "Stay in control",
|
||||
subtitle: "Choose where the Gateway runs and which permissions OpenClaw receives.",
|
||||
subtitle: "Everything runs where you decide, with permissions you grant.",
|
||||
systemImage: "hand.raised.fill")
|
||||
}
|
||||
.frame(maxWidth: 520)
|
||||
@@ -75,48 +80,72 @@ extension OnboardingView {
|
||||
|
||||
func connectionPage() -> some View {
|
||||
self.onboardingPage {
|
||||
Text("Where should OpenClaw run?")
|
||||
Text("Where should your assistant live?")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text(
|
||||
"For the simplest setup, run the Gateway on this Mac. " +
|
||||
"OpenClaw installs it and keeps it running for you.")
|
||||
"Most people pick this Mac — OpenClaw installs everything and keeps it " +
|
||||
"running in the background. You can change this anytime in Settings.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: 520)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
self.onboardingCard(spacing: 12, padding: 14) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.connectionChoiceButton(
|
||||
title: "This Mac",
|
||||
title: "On this Mac",
|
||||
badge: "Recommended",
|
||||
subtitle: self.localGatewaySubtitle,
|
||||
systemImage: "laptopcomputer",
|
||||
selected: self.selectedConnectionMode == .local)
|
||||
{
|
||||
self.selectLocalGateway()
|
||||
}
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
|
||||
self.gatewayDiscoverySection()
|
||||
|
||||
if self.shouldShowRemoteConnectionSection {
|
||||
Divider().padding(.vertical, 4)
|
||||
self.remoteConnectionSection()
|
||||
}
|
||||
|
||||
self.connectionChoiceButton(
|
||||
title: "Configure later",
|
||||
subtitle: "Don’t start the Gateway yet.",
|
||||
selected: self.selectedConnectionMode == .unconfigured)
|
||||
title: "On another computer",
|
||||
badge: nil,
|
||||
subtitle: self.remoteChoiceSubtitle,
|
||||
systemImage: "network",
|
||||
selected: self.selectedConnectionMode == .remote)
|
||||
{
|
||||
self.selectUnconfiguredGateway()
|
||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
||||
self.showRemoteChoices.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
self.advancedConnectionSection()
|
||||
if self.showRemoteChoices || self.selectedConnectionMode == .remote {
|
||||
self.gatewayDiscoverySection()
|
||||
|
||||
if self.shouldShowRemoteConnectionSection {
|
||||
Divider().padding(.vertical, 4)
|
||||
self.remoteConnectionSection()
|
||||
}
|
||||
|
||||
self.advancedConnectionSection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
Button("Set up later") {
|
||||
self.selectUnconfiguredGateway()
|
||||
}
|
||||
.buttonStyle(.link)
|
||||
.font(.callout)
|
||||
.foregroundStyle(self.selectedConnectionMode == .unconfigured ? Color.accentColor : .secondary)
|
||||
.help("Skip Gateway setup for now; pick Local or Remote later in Settings → General.")
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
if self.selectedConnectionMode == .unconfigured {
|
||||
Text("OK — OpenClaw won’t start anything yet. Pick Local or Remote later in Settings → General.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.onChange(of: self.state.connectionMode) { _, newValue in
|
||||
guard Self.shouldResetRemoteProbeFeedback(
|
||||
@@ -138,7 +167,7 @@ extension OnboardingView {
|
||||
|
||||
private var localGatewaySubtitle: String {
|
||||
guard let probe = self.localGatewayProbe else {
|
||||
return "Recommended — installs and starts automatically."
|
||||
return "Private to this computer. Installs and starts automatically."
|
||||
}
|
||||
let base = probe.expected
|
||||
? "Existing gateway detected"
|
||||
@@ -147,41 +176,46 @@ extension OnboardingView {
|
||||
return "\(base)\(command). Will attach."
|
||||
}
|
||||
|
||||
private var remoteChoiceSubtitle: String {
|
||||
let count = self.gatewayDiscovery.gateways.count
|
||||
if count > 0 {
|
||||
return count == 1
|
||||
? "1 gateway found on your network — click to choose it."
|
||||
: "\(count) gateways found on your network — click to choose one."
|
||||
}
|
||||
return "For advanced setups — use a gateway that runs elsewhere."
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func gatewayDiscoverySection() -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(self.gatewayDiscovery.statusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if self.gatewayDiscovery.gateways.isEmpty {
|
||||
ProgressView().controlSize(.small)
|
||||
Button("Refresh") {
|
||||
// Quiet by design: discovery runs in the background and must not make
|
||||
// the page read as "loading" — no spinner, just a status line.
|
||||
if self.gatewayDiscovery.gateways.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("No gateways found on your network yet.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Look again") {
|
||||
self.gatewayDiscovery.refreshRemoteFallbackNow(timeoutSeconds: 5.0)
|
||||
}
|
||||
.buttonStyle(.link)
|
||||
.help("Retry remote discovery (Tailscale DNS-SD + Serve probe).")
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
if self.gatewayDiscovery.gateways.isEmpty {
|
||||
Text("Searching for nearby gateways…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, 4)
|
||||
.help("Retry discovery (Bonjour + Tailscale DNS-SD).")
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Nearby gateways")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, 4)
|
||||
ForEach(self.gatewayDiscovery.gateways.prefix(6)) { gateway in
|
||||
self.connectionChoiceButton(
|
||||
title: gateway.displayName,
|
||||
badge: nil,
|
||||
subtitle: self.gatewaySubtitle(for: gateway),
|
||||
systemImage: "desktopcomputer",
|
||||
monospacedSubtitle: true,
|
||||
selected: self.isSelectedGateway(gateway))
|
||||
{
|
||||
self.selectRemoteGateway(gateway)
|
||||
@@ -561,7 +595,10 @@ extension OnboardingView {
|
||||
|
||||
func connectionChoiceButton(
|
||||
title: String,
|
||||
badge: String? = nil,
|
||||
subtitle: String?,
|
||||
systemImage: String? = nil,
|
||||
monospacedSubtitle: Bool = false,
|
||||
selected: Bool,
|
||||
action: @escaping () -> Void) -> some View
|
||||
{
|
||||
@@ -570,18 +607,35 @@ extension OnboardingView {
|
||||
action()
|
||||
}
|
||||
} label: {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
if let systemImage {
|
||||
Image(systemName: systemImage)
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(selected ? Color.accentColor : Color.secondary)
|
||||
.frame(width: 26)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.callout.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
HStack(spacing: 6) {
|
||||
Text(title)
|
||||
.font(.callout.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
if let badge {
|
||||
Text(badge)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Capsule().fill(Color.accentColor.opacity(0.16)))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.caption.monospaced())
|
||||
.font(monospacedSubtitle ? .caption.monospaced() : .caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
@@ -631,62 +685,124 @@ extension OnboardingView {
|
||||
|
||||
func cliPage() -> some View {
|
||||
self.onboardingPage {
|
||||
Text("Setting up OpenClaw")
|
||||
Text("Getting things ready")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("OpenClaw is installing the local Gateway and its managed runtime.")
|
||||
Text(
|
||||
"OpenClaw is setting up its background service on this Mac. " +
|
||||
"This usually takes under a minute — no Terminal, no administrator password.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 520)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
self.onboardingCard(spacing: 10) {
|
||||
if !self.cliStatusKnown {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Checking existing setup…")
|
||||
.font(.headline)
|
||||
}
|
||||
} else if self.installingCLI {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Installing the Gateway…")
|
||||
.font(.headline)
|
||||
}
|
||||
} else if self.cliInstalled, let loc = self.cliInstallLocation {
|
||||
Label("Gateway installed", systemImage: "checkmark.circle.fill")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.green)
|
||||
Text(loc)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
} else {
|
||||
HStack {
|
||||
Button("Retry setup", action: self.startCLIInstall)
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button("Check again") {
|
||||
Task { await self.refreshCLIStatus() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
self.onboardingCard(spacing: 14, padding: 16) {
|
||||
self.installStepRow(
|
||||
title: "Install OpenClaw",
|
||||
detail: self.cliInstalled
|
||||
? (self.cliInstallLocation ?? "Installed")
|
||||
: "A private copy inside your user folder.",
|
||||
state: self.installStepStateForInstall,
|
||||
monospacedDetail: self.cliInstalled && self.cliInstallLocation != nil)
|
||||
self.installStepRow(
|
||||
title: "Start the background service",
|
||||
detail: "Runs quietly and starts again after a restart.",
|
||||
state: self.installStepStateForService)
|
||||
self.installStepRow(
|
||||
title: "Ready for the next step",
|
||||
detail: "Once the service answers, you’ll connect your AI.",
|
||||
state: self.cliInstalled ? .done : .pending)
|
||||
|
||||
if let cliStatus {
|
||||
if self.installFailed {
|
||||
OnboardingErrorCard(
|
||||
title: "The Gateway didn’t start",
|
||||
message: self.cliStatus ?? "The installer did not finish.",
|
||||
docsSlug: "platforms/mac/bundled-gateway",
|
||||
retryTitle: "Try again")
|
||||
{
|
||||
self.startCLIInstall()
|
||||
}
|
||||
} else if let cliStatus, !self.cliInstalled {
|
||||
Text(cliStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text("Uses a private user-space install. No Terminal, administrator access, or Homebrew required.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var installFailed: Bool {
|
||||
self.cliStatusKnown && !self.installingCLI && !self.cliInstalled
|
||||
}
|
||||
|
||||
/// Exactly one spinner at a time: the install row finishes before the
|
||||
/// service row starts, mirroring the actual runCLIInstall phases.
|
||||
private var installStepStateForInstall: InstallStepState {
|
||||
if self.cliInstalled { return .done }
|
||||
if self.installingCLI {
|
||||
return self.cliInstallPhase == .startingService ? .done : .running
|
||||
}
|
||||
if self.installFailed { return .failed }
|
||||
return .running // status probe still deciding
|
||||
}
|
||||
|
||||
private var installStepStateForService: InstallStepState {
|
||||
if self.cliInstalled { return .done }
|
||||
if self.installingCLI {
|
||||
return self.cliInstallPhase == .startingService ? .running : .pending
|
||||
}
|
||||
if self.installFailed { return .failed }
|
||||
return .pending
|
||||
}
|
||||
|
||||
enum InstallStepState {
|
||||
case pending
|
||||
case running
|
||||
case done
|
||||
case failed
|
||||
}
|
||||
|
||||
private func installStepRow(
|
||||
title: String,
|
||||
detail: String,
|
||||
state: InstallStepState,
|
||||
monospacedDetail: Bool = false) -> some View
|
||||
{
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Group {
|
||||
switch state {
|
||||
case .pending:
|
||||
Image(systemName: "circle.dotted")
|
||||
.foregroundStyle(.tertiary)
|
||||
case .running:
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
case .done:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
case .failed:
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.font(.title3)
|
||||
.frame(width: 26, height: 22)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(state == .pending ? Color.secondary : Color.primary)
|
||||
Text(detail)
|
||||
.font(monospacedDetail ? .caption.monospaced() : .caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
func workspacePage() -> some View {
|
||||
self.onboardingPage {
|
||||
Text("Agent workspace")
|
||||
@@ -778,14 +894,13 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
func onboardingChatPage() -> some View {
|
||||
VStack(spacing: 16) {
|
||||
VStack(spacing: 12) {
|
||||
Text("Meet your agent")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text(
|
||||
"This is a dedicated onboarding chat. Your agent will introduce itself, " +
|
||||
"learn who you are, and help you connect Discord, Slack, Telegram, WhatsApp, " +
|
||||
"or another channel if you want.")
|
||||
.font(.body)
|
||||
"Your agent introduces itself, picks a name with you, and helps you " +
|
||||
"connect WhatsApp, Telegram, or another channel — just chat.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 520)
|
||||
@@ -803,8 +918,14 @@ extension OnboardingView {
|
||||
|
||||
func readyPage() -> some View {
|
||||
self.onboardingPage {
|
||||
Text("All set")
|
||||
Text("You’re all set!")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
if self.state.connectionMode != .unconfigured {
|
||||
Text("Finish opens the chat — say hi to your new agent.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
self.onboardingCard {
|
||||
if self.state.connectionMode == .unconfigured {
|
||||
self.featureRow(
|
||||
|
||||
@@ -40,7 +40,7 @@ extension OnboardingView {
|
||||
view.state.connectionMode = .local
|
||||
_ = view.welcomePage()
|
||||
_ = view.connectionPage()
|
||||
_ = view.crestodianSetupPage()
|
||||
_ = view.aiSetupPage()
|
||||
_ = view.permissionsPage()
|
||||
_ = view.cliPage()
|
||||
_ = view.workspacePage()
|
||||
|
||||
@@ -177,6 +177,8 @@ struct SettingsRootView: View {
|
||||
showOnboarding: { DebugActions.restartOnboarding() }))
|
||||
case .voiceWake:
|
||||
AnyView(VoiceWakeSettings(state: self.state, isActive: self.selectedTab == .voiceWake))
|
||||
case .crestodian:
|
||||
AnyView(CrestodianSettings(isActive: self.selectedTab == tab))
|
||||
case .channels:
|
||||
AnyView(ChannelsSettings(isActive: self.selectedTab == tab))
|
||||
case .skills:
|
||||
@@ -239,7 +241,7 @@ private struct SettingsTabGroup: Identifiable {
|
||||
|
||||
static func defaultGroups(showDebug: Bool) -> [SettingsTabGroup] {
|
||||
var groups = [
|
||||
SettingsTabGroup(title: "Basics", tabs: [.general, .connection, .permissions, .voiceWake]),
|
||||
SettingsTabGroup(title: "Basics", tabs: [.general, .connection, .permissions, .voiceWake, .crestodian]),
|
||||
SettingsTabGroup(title: "Automation", tabs: [.channels, .skills, .cron, .execApprovals]),
|
||||
SettingsTabGroup(title: "Data", tabs: [.sessions, .instances]),
|
||||
SettingsTabGroup(title: "Advanced", tabs: [.config]),
|
||||
@@ -255,7 +257,7 @@ private struct SettingsTabGroup: Identifiable {
|
||||
}
|
||||
|
||||
enum SettingsTab: CaseIterable, Identifiable, Hashable {
|
||||
case general, connection, permissions, voiceWake, channels, skills, cron
|
||||
case general, connection, permissions, voiceWake, crestodian, channels, skills, cron
|
||||
case execApprovals, sessions, instances, config, debug, about
|
||||
static let windowWidth: CGFloat = 1120
|
||||
static let windowHeight: CGFloat = 790
|
||||
@@ -270,6 +272,7 @@ enum SettingsTab: CaseIterable, Identifiable, Hashable {
|
||||
case .connection: "Connection"
|
||||
case .permissions: "Permissions"
|
||||
case .voiceWake: "Voice & Talk"
|
||||
case .crestodian: "Crestodian"
|
||||
case .channels: "Channels"
|
||||
case .skills: "Skills"
|
||||
case .cron: "Cron Jobs"
|
||||
@@ -288,6 +291,7 @@ enum SettingsTab: CaseIterable, Identifiable, Hashable {
|
||||
case .connection: "point.3.connected.trianglepath.dotted"
|
||||
case .permissions: "lock.shield"
|
||||
case .voiceWake: "waveform.circle"
|
||||
case .crestodian: "lifepreserver"
|
||||
case .channels: "link"
|
||||
case .skills: "sparkles"
|
||||
case .cron: "calendar.badge.clock"
|
||||
|
||||
@@ -3177,6 +3177,94 @@ public struct CrestodianChatResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CrestodianSetupDetectParams: Codable, Sendable {}
|
||||
|
||||
public struct CrestodianSetupDetectResult: Codable, Sendable {
|
||||
public let candidates: [[String: AnyCodable]]
|
||||
public let workspace: String
|
||||
public let configuredmodel: String?
|
||||
public let setupcomplete: Bool
|
||||
|
||||
public init(
|
||||
candidates: [[String: AnyCodable]],
|
||||
workspace: String,
|
||||
configuredmodel: String?,
|
||||
setupcomplete: Bool)
|
||||
{
|
||||
self.candidates = candidates
|
||||
self.workspace = workspace
|
||||
self.configuredmodel = configuredmodel
|
||||
self.setupcomplete = setupcomplete
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case candidates
|
||||
case workspace
|
||||
case configuredmodel = "configuredModel"
|
||||
case setupcomplete = "setupComplete"
|
||||
}
|
||||
}
|
||||
|
||||
public struct CrestodianSetupActivateParams: Codable, Sendable {
|
||||
public let kind: AnyCodable
|
||||
public let provider: String?
|
||||
public let apikey: String?
|
||||
public let workspace: String?
|
||||
|
||||
public init(
|
||||
kind: AnyCodable,
|
||||
provider: String?,
|
||||
apikey: String?,
|
||||
workspace: String?)
|
||||
{
|
||||
self.kind = kind
|
||||
self.provider = provider
|
||||
self.apikey = apikey
|
||||
self.workspace = workspace
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case kind
|
||||
case provider
|
||||
case apikey = "apiKey"
|
||||
case workspace
|
||||
}
|
||||
}
|
||||
|
||||
public struct CrestodianSetupActivateResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let modelref: String?
|
||||
public let latencyms: Double?
|
||||
public let lines: [String]?
|
||||
public let status: AnyCodable?
|
||||
public let error: String?
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
modelref: String?,
|
||||
latencyms: Double?,
|
||||
lines: [String]?,
|
||||
status: AnyCodable?,
|
||||
error: String?)
|
||||
{
|
||||
self.ok = ok
|
||||
self.modelref = modelref
|
||||
self.latencyms = latencyms
|
||||
self.lines = lines
|
||||
self.status = status
|
||||
self.error = error
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case modelref = "modelRef"
|
||||
case latencyms = "latencyMs"
|
||||
case lines
|
||||
case status
|
||||
case error
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardStartParams: Codable, Sendable {
|
||||
public let mode: AnyCodable?
|
||||
public let workspace: String?
|
||||
|
||||
@@ -113,8 +113,11 @@ When no model is configured, setup picks the first usable backend in this order
|
||||
3. `ANTHROPIC_API_KEY` -> `anthropic/claude-opus-4-8`
|
||||
4. Claude Code CLI -> `claude-cli/claude-opus-4-8`
|
||||
5. Codex -> `openai/gpt-5.5` through the Codex app-server harness
|
||||
6. Gemini CLI -> `google-gemini-cli/gemini-3.1-pro-preview`
|
||||
|
||||
If none are available, setup still writes the default workspace and leaves the model unset. Install or log into Codex/Claude Code, or expose `OPENAI_API_KEY`/`ANTHROPIC_API_KEY`, then run setup again.
|
||||
If none are available, setup still writes the default workspace and leaves the model unset. Install or log into Codex/Claude Code/Gemini CLI, or expose `OPENAI_API_KEY`/`ANTHROPIC_API_KEY`, then run setup again.
|
||||
|
||||
The macOS app drives the same ladder through the `crestodian.setup.detect` and `crestodian.setup.activate` gateway methods: detect lists every reusable backend it finds, activate live-tests one candidate (a real "reply with OK" completion) and only persists the model, workspace, and gateway defaults after the test passes. A failing candidate never changes config; the app automatically walks down the ladder and finally offers a manual API-key step (Anthropic, OpenAI, or Google) that is verified the same way before it is saved.
|
||||
|
||||
## Model-assisted planner
|
||||
|
||||
|
||||
@@ -51,10 +51,12 @@ CLI command docs: [`openclaw onboard`](/cli/onboard)
|
||||
## macOS app onboarding
|
||||
|
||||
Open the OpenClaw app. For local setup, the first-run flow starts the Gateway,
|
||||
then opens a Crestodian conversation that detects existing AI access, proposes
|
||||
the workspace and config, and applies the plan after approval. Sensitive
|
||||
credentials use masked input. Remote setup connects to an already-configured
|
||||
Gateway instead.
|
||||
detects existing AI access (Claude Code, Codex, Gemini CLI, or API keys),
|
||||
live-tests the best option, and saves it only after a real reply — falling
|
||||
back automatically and offering a verified manual API-key step when nothing is
|
||||
found. Sensitive credentials use masked input. Remote setup connects to an
|
||||
already-configured Gateway instead, and the same AI check runs against that
|
||||
Gateway.
|
||||
|
||||
Full reference: [Onboarding (macOS App)](/start/onboarding)
|
||||
|
||||
|
||||
+18
-12
@@ -7,9 +7,9 @@ title: "Onboarding (macOS app)"
|
||||
sidebarTitle: "Onboarding: macOS App"
|
||||
---
|
||||
|
||||
The macOS app's first-run flow: pick where the Gateway runs, complete local
|
||||
setup through a Crestodian conversation, grant permissions, and hand off to
|
||||
the agent's own bootstrap ritual.
|
||||
The macOS app's first-run flow: pick where the Gateway runs, connect a
|
||||
verified AI backend, grant permissions, and hand off to the agent's own
|
||||
bootstrap ritual.
|
||||
For CLI onboarding and a comparison of both paths, see [Onboarding Overview](/start/onboarding-overview).
|
||||
|
||||
<Steps>
|
||||
@@ -65,16 +65,22 @@ Where does the **Gateway** run?
|
||||
preferring npm first. Node remains the recommended runtime for the Gateway
|
||||
itself. Existing compatible installations are reused.
|
||||
</Step>
|
||||
<Step title="Talk to Crestodian">
|
||||
Local setup opens a dedicated conversation with Crestodian after the Gateway
|
||||
is ready. Crestodian detects an existing Claude Code or Codex login and
|
||||
supported API keys, proposes the workspace and configuration, then waits for
|
||||
approval before writing anything. Next remains locked until the conversation
|
||||
has authored setup state. Credential prompts use masked input; after an
|
||||
ambiguous transport failure, restart the setup conversation instead of
|
||||
replaying the previous turn.
|
||||
<Step title="Connect your AI">
|
||||
Once the Gateway is ready, onboarding looks for AI access you already have:
|
||||
a Claude Code, Codex, or Gemini CLI login, or `OPENAI_API_KEY` /
|
||||
`ANTHROPIC_API_KEY`. The best option is tested with a real completion and
|
||||
only saved after it answers; when a test fails the app automatically tries
|
||||
the next option and shows why the previous one failed. If several options
|
||||
are found you can switch between them before continuing.
|
||||
|
||||
Remote and Configure Later flows skip this local setup conversation.
|
||||
If nothing is found (or nothing works), a manual step accepts an API key for
|
||||
Anthropic, OpenAI, or Google, verifies it the same way, and stores it as an
|
||||
auth profile. Next remains locked until one backend has passed its live test,
|
||||
so the first agent chat can never start without working inference. The
|
||||
Crestodian chat stays available from this page (and later under
|
||||
Settings → Crestodian) for help in plain language.
|
||||
|
||||
Configure Later skips this step.
|
||||
</Step>
|
||||
<Step title="Permissions">
|
||||
|
||||
|
||||
@@ -509,6 +509,14 @@ import {
|
||||
CrestodianChatParamsSchema,
|
||||
type CrestodianChatResult,
|
||||
CrestodianChatResultSchema,
|
||||
type CrestodianSetupDetectParams,
|
||||
CrestodianSetupDetectParamsSchema,
|
||||
type CrestodianSetupDetectResult,
|
||||
CrestodianSetupDetectResultSchema,
|
||||
type CrestodianSetupActivateParams,
|
||||
CrestodianSetupActivateParamsSchema,
|
||||
type CrestodianSetupActivateResult,
|
||||
CrestodianSetupActivateResultSchema,
|
||||
type WizardCancelParams,
|
||||
WizardCancelParamsSchema,
|
||||
type WizardNextParams,
|
||||
@@ -756,6 +764,12 @@ export const validateConfigSchemaLookupResult = lazyCompile<ConfigSchemaLookupRe
|
||||
export const validateCrestodianChatParams = lazyCompile<CrestodianChatParams>(
|
||||
CrestodianChatParamsSchema,
|
||||
);
|
||||
export const validateCrestodianSetupDetectParams = lazyCompile<CrestodianSetupDetectParams>(
|
||||
CrestodianSetupDetectParamsSchema,
|
||||
);
|
||||
export const validateCrestodianSetupActivateParams = lazyCompile<CrestodianSetupActivateParams>(
|
||||
CrestodianSetupActivateParamsSchema,
|
||||
);
|
||||
export const validateWizardStartParams = lazyCompile<WizardStartParams>(WizardStartParamsSchema);
|
||||
export const validateWizardNextParams = lazyCompile<WizardNextParams>(WizardNextParamsSchema);
|
||||
export const validateWizardCancelParams = lazyCompile<WizardCancelParams>(WizardCancelParamsSchema);
|
||||
@@ -1142,6 +1156,10 @@ export {
|
||||
UpdateStatusParamsSchema,
|
||||
CrestodianChatParamsSchema,
|
||||
CrestodianChatResultSchema,
|
||||
CrestodianSetupDetectParamsSchema,
|
||||
CrestodianSetupDetectResultSchema,
|
||||
CrestodianSetupActivateParamsSchema,
|
||||
CrestodianSetupActivateResultSchema,
|
||||
WizardStartParamsSchema,
|
||||
WizardNextParamsSchema,
|
||||
WizardCancelParamsSchema,
|
||||
@@ -1317,6 +1335,10 @@ export type {
|
||||
ConfigSchemaResponse,
|
||||
CrestodianChatParams,
|
||||
CrestodianChatResult,
|
||||
CrestodianSetupDetectParams,
|
||||
CrestodianSetupDetectResult,
|
||||
CrestodianSetupActivateParams,
|
||||
CrestodianSetupActivateResult,
|
||||
WizardStartParams,
|
||||
WizardNextParams,
|
||||
WizardCancelParams,
|
||||
|
||||
@@ -37,3 +37,90 @@ export const CrestodianChatResultSchema = Type.Object(
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/**
|
||||
* Structured first-run inference setup for GUI clients: detect reusable AI
|
||||
* access (CLI logins, env keys, existing config), then activate one choice.
|
||||
* Activation live-tests the candidate and persists it only on success, so a
|
||||
* client can walk the ladder candidate-by-candidate without ever leaving a
|
||||
* broken default model behind.
|
||||
*/
|
||||
export const CrestodianSetupDetectParamsSchema = Type.Object({}, { additionalProperties: false });
|
||||
|
||||
const SetupInferenceKind = Type.Union([
|
||||
Type.Literal("existing-model"),
|
||||
Type.Literal("openai-api-key"),
|
||||
Type.Literal("anthropic-api-key"),
|
||||
Type.Literal("claude-cli"),
|
||||
Type.Literal("codex-cli"),
|
||||
Type.Literal("gemini-cli"),
|
||||
]);
|
||||
|
||||
export const CrestodianSetupDetectResultSchema = Type.Object(
|
||||
{
|
||||
candidates: Type.Array(
|
||||
Type.Object(
|
||||
{
|
||||
kind: SetupInferenceKind,
|
||||
label: NonEmptyString,
|
||||
detail: Type.String(),
|
||||
modelRef: NonEmptyString,
|
||||
recommended: Type.Boolean(),
|
||||
/** true: verified; false: definitively logged out; absent: unknown. */
|
||||
credentials: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
workspace: NonEmptyString,
|
||||
configuredModel: Type.Optional(Type.String()),
|
||||
setupComplete: Type.Boolean(),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const CrestodianSetupActivateParamsSchema = Type.Object(
|
||||
{
|
||||
kind: Type.Union([
|
||||
Type.Literal("existing-model"),
|
||||
Type.Literal("openai-api-key"),
|
||||
Type.Literal("anthropic-api-key"),
|
||||
Type.Literal("claude-cli"),
|
||||
Type.Literal("codex-cli"),
|
||||
Type.Literal("gemini-cli"),
|
||||
Type.Literal("api-key"),
|
||||
]),
|
||||
/** Manual step only: provider the pasted key belongs to (anthropic/openai/google). */
|
||||
provider: Type.Optional(Type.String()),
|
||||
/** Manual step only: the pasted API key; masked by clients, never echoed. */
|
||||
apiKey: Type.Optional(Type.String()),
|
||||
workspace: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const CrestodianSetupActivateResultSchema = Type.Object(
|
||||
{
|
||||
ok: Type.Boolean(),
|
||||
/** Present on success: the model ref that answered the live test. */
|
||||
modelRef: Type.Optional(Type.String()),
|
||||
latencyMs: Type.Optional(Type.Number()),
|
||||
/** Human-readable setup summary lines (workspace, model, gateway). */
|
||||
lines: Type.Optional(Type.Array(Type.String())),
|
||||
/** Present on failure: coarse bucket for client copy + docs links. */
|
||||
status: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("ok"),
|
||||
Type.Literal("auth"),
|
||||
Type.Literal("rate_limit"),
|
||||
Type.Literal("billing"),
|
||||
Type.Literal("timeout"),
|
||||
Type.Literal("format"),
|
||||
Type.Literal("unavailable"),
|
||||
Type.Literal("unknown"),
|
||||
]),
|
||||
),
|
||||
error: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
@@ -141,7 +141,14 @@ import {
|
||||
UpdateStatusParamsSchema,
|
||||
UpdateRunParamsSchema,
|
||||
} from "./config.js";
|
||||
import { CrestodianChatParamsSchema, CrestodianChatResultSchema } from "./crestodian.js";
|
||||
import {
|
||||
CrestodianChatParamsSchema,
|
||||
CrestodianChatResultSchema,
|
||||
CrestodianSetupActivateParamsSchema,
|
||||
CrestodianSetupActivateResultSchema,
|
||||
CrestodianSetupDetectParamsSchema,
|
||||
CrestodianSetupDetectResultSchema,
|
||||
} from "./crestodian.js";
|
||||
import {
|
||||
CronAddParamsSchema,
|
||||
CronGetParamsSchema,
|
||||
@@ -448,6 +455,10 @@ export const ProtocolSchemas = {
|
||||
ConfigSchemaLookupResult: ConfigSchemaLookupResultSchema,
|
||||
CrestodianChatParams: CrestodianChatParamsSchema,
|
||||
CrestodianChatResult: CrestodianChatResultSchema,
|
||||
CrestodianSetupDetectParams: CrestodianSetupDetectParamsSchema,
|
||||
CrestodianSetupDetectResult: CrestodianSetupDetectResultSchema,
|
||||
CrestodianSetupActivateParams: CrestodianSetupActivateParamsSchema,
|
||||
CrestodianSetupActivateResult: CrestodianSetupActivateResultSchema,
|
||||
WizardStartParams: WizardStartParamsSchema,
|
||||
WizardNextParams: WizardNextParamsSchema,
|
||||
WizardCancelParams: WizardCancelParamsSchema,
|
||||
|
||||
@@ -119,6 +119,10 @@ export type UpdateStatusParams = SchemaType<"UpdateStatusParams">;
|
||||
/** Crestodian chat payloads exchanged by clients and the gateway. */
|
||||
export type CrestodianChatParams = SchemaType<"CrestodianChatParams">;
|
||||
export type CrestodianChatResult = SchemaType<"CrestodianChatResult">;
|
||||
export type CrestodianSetupDetectParams = SchemaType<"CrestodianSetupDetectParams">;
|
||||
export type CrestodianSetupDetectResult = SchemaType<"CrestodianSetupDetectResult">;
|
||||
export type CrestodianSetupActivateParams = SchemaType<"CrestodianSetupActivateParams">;
|
||||
export type CrestodianSetupActivateResult = SchemaType<"CrestodianSetupActivateResult">;
|
||||
|
||||
/** Wizard setup flow payloads exchanged by CLI, UI, and gateway. */
|
||||
export type WizardStartParams = SchemaType<"WizardStartParams">;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
readClaudeCliCredentialsCached,
|
||||
readCodexCliCredentialsCached,
|
||||
readGeminiCliCredentialsCached,
|
||||
} from "../agents/cli-credentials.js";
|
||||
// Inference backend detection shared by onboarding bootstrap and Crestodian setup.
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||
@@ -18,13 +19,15 @@ export const OPENAI_API_DEFAULT_MODEL_REF = `${DEFAULT_PROVIDER}/${DEFAULT_MODEL
|
||||
export const ANTHROPIC_API_DEFAULT_MODEL_REF = "anthropic/claude-opus-4-8";
|
||||
export const CLAUDE_CLI_DEFAULT_MODEL_REF = "claude-cli/claude-opus-4-8";
|
||||
export const CODEX_APP_SERVER_DEFAULT_MODEL_REF = OPENAI_API_DEFAULT_MODEL_REF;
|
||||
export const GEMINI_CLI_DEFAULT_MODEL_REF = "google-gemini-cli/gemini-3.1-pro-preview";
|
||||
|
||||
export type InferenceBackendKind =
|
||||
| "existing-model"
|
||||
| "openai-api-key"
|
||||
| "anthropic-api-key"
|
||||
| "claude-cli"
|
||||
| "codex-cli";
|
||||
| "codex-cli"
|
||||
| "gemini-cli";
|
||||
|
||||
export type InferenceBackendCandidate = {
|
||||
kind: InferenceBackendKind;
|
||||
@@ -44,6 +47,7 @@ export type DetectInferenceBackendsDeps = {
|
||||
probeLocalCommand?: typeof probeLocalCommand;
|
||||
readClaudeCliCredentials?: () => { type: string } | null;
|
||||
readCodexCliCredentials?: () => { type: string } | null;
|
||||
readGeminiCliCredentials?: () => { type: string } | null;
|
||||
};
|
||||
|
||||
export type DetectInferenceBackendsOptions = {
|
||||
@@ -98,6 +102,9 @@ export async function detectInferenceBackends(
|
||||
const readCodex =
|
||||
options.deps?.readCodexCliCredentials ??
|
||||
(() => readCodexCliCredentialsCached({ allowKeychainPrompt: false, ttlMs: 60_000 }));
|
||||
const readGemini =
|
||||
options.deps?.readGeminiCliCredentials ??
|
||||
(() => readGeminiCliCredentialsCached({ ttlMs: 60_000 }));
|
||||
|
||||
const candidates: InferenceBackendCandidate[] = [];
|
||||
const existingModel = resolveAgentModelPrimaryValue(options.config?.agents?.defaults?.model);
|
||||
@@ -129,7 +136,11 @@ export async function detectInferenceBackends(
|
||||
});
|
||||
}
|
||||
|
||||
const [claudeProbe, codexProbe] = await Promise.all([probe("claude"), probe("codex")]);
|
||||
const [claudeProbe, codexProbe, geminiProbe] = await Promise.all([
|
||||
probe("claude"),
|
||||
probe("codex"),
|
||||
probe("gemini"),
|
||||
]);
|
||||
const cliCandidates: InferenceBackendCandidate[] = [];
|
||||
if (claudeProbe.found) {
|
||||
const credentials = detectCliCredentialState({
|
||||
@@ -159,8 +170,20 @@ export async function detectInferenceBackends(
|
||||
...(credentials === undefined ? {} : { credentials }),
|
||||
});
|
||||
}
|
||||
if (geminiProbe.found) {
|
||||
// Gemini CLI stores its OAuth login in a plain file on every platform (no
|
||||
// keychain), so a missing credential file is a definitive logout signal.
|
||||
const credentials = readGemini() !== null;
|
||||
cliCandidates.push({
|
||||
kind: "gemini-cli",
|
||||
modelRef: GEMINI_CLI_DEFAULT_MODEL_REF,
|
||||
label: "Gemini CLI",
|
||||
detail: describeCliDetail(credentials),
|
||||
credentials,
|
||||
});
|
||||
}
|
||||
// Stable partition: logged-out installs sink, ladder order preserved inside
|
||||
// each partition (claude before codex per the documented ladder).
|
||||
// each partition (claude before codex before gemini per the documented ladder).
|
||||
candidates.push(
|
||||
...cliCandidates.filter((candidate) => candidate.credentials !== false),
|
||||
...cliCandidates.filter((candidate) => candidate.credentials === false),
|
||||
|
||||
@@ -53,7 +53,8 @@ export function selectCrestodianLocalPlannerBackends(
|
||||
return backends;
|
||||
}
|
||||
|
||||
function buildCliPlannerConfig(workspaceDir: string, modelRef: string): OpenClawConfig {
|
||||
/** Minimal run config for a CLI-harness model scoped to one workspace. */
|
||||
export function buildCliPlannerConfig(workspaceDir: string, modelRef: string): OpenClawConfig {
|
||||
return {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -64,7 +65,8 @@ function buildCliPlannerConfig(workspaceDir: string, modelRef: string): OpenClaw
|
||||
};
|
||||
}
|
||||
|
||||
function buildCodexAppServerPlannerConfig(workspaceDir: string): OpenClawConfig {
|
||||
/** Run config for the Codex app-server harness (exec must be allowed to spawn it). */
|
||||
export function buildCodexAppServerPlannerConfig(workspaceDir: string): OpenClawConfig {
|
||||
return {
|
||||
agents: {
|
||||
defaults: {
|
||||
|
||||
@@ -11,16 +11,20 @@ import { formatCrestodianOnboardingWelcome } from "./overview.js";
|
||||
* On an already-configured install the welcome becomes the channels/handoff
|
||||
* guide instead of re-proposing setup.
|
||||
*/
|
||||
export async function buildOnboardingWelcome(params: {
|
||||
engine: CrestodianChatEngine;
|
||||
workspace?: string;
|
||||
}): Promise<string> {
|
||||
const overview = await params.engine.loadOverview();
|
||||
// "Configured" must match the app onboarding gate (wizard metadata or
|
||||
// gateway auth), not just a model: a model-only config would otherwise get
|
||||
// the ready-guide welcome while the gate stays locked, stranding the page.
|
||||
/**
|
||||
* "Configured" must match the app onboarding gate (wizard metadata or gateway
|
||||
* auth), not just a model: a model-only config would otherwise get the
|
||||
* ready-guide welcome while the gate stays locked, stranding the page.
|
||||
*/
|
||||
export async function loadAuthoredSetupConfig(params: {
|
||||
configExists: boolean;
|
||||
configValid: boolean;
|
||||
}): Promise<{
|
||||
authoredConfig?: import("../config/types.openclaw.js").OpenClawConfig;
|
||||
hasAuthoredSetup: boolean;
|
||||
}> {
|
||||
const authoredConfig = await (async () => {
|
||||
if (!overview.config.exists || !overview.config.valid) {
|
||||
if (!params.configExists || !params.configValid) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
@@ -38,10 +42,21 @@ export async function buildOnboardingWelcome(params: {
|
||||
normalizeSecretInputString(auth?.token) !== undefined ||
|
||||
isSecretRef(auth?.password) ||
|
||||
normalizeSecretInputString(auth?.password) !== undefined;
|
||||
const hasAuthoredSetup =
|
||||
(authoredConfig?.wizard && Object.keys(authoredConfig.wizard).length > 0) ||
|
||||
hasAuthMode ||
|
||||
hasAuthSecret;
|
||||
const hasWizardMetadata =
|
||||
authoredConfig?.wizard !== undefined && Object.keys(authoredConfig.wizard).length > 0;
|
||||
const hasAuthoredSetup = hasWizardMetadata || hasAuthMode || hasAuthSecret;
|
||||
return { ...(authoredConfig ? { authoredConfig } : {}), hasAuthoredSetup };
|
||||
}
|
||||
|
||||
export async function buildOnboardingWelcome(params: {
|
||||
engine: CrestodianChatEngine;
|
||||
workspace?: string;
|
||||
}): Promise<string> {
|
||||
const overview = await params.engine.loadOverview();
|
||||
const { authoredConfig, hasAuthoredSetup } = await loadAuthoredSetupConfig({
|
||||
configExists: overview.config.exists,
|
||||
configValid: overview.config.valid,
|
||||
});
|
||||
if (hasAuthoredSetup && overview.defaultModel) {
|
||||
const welcome = formatCrestodianOnboardingWelcome(overview);
|
||||
params.engine.noteAssistantMessage(welcome);
|
||||
|
||||
@@ -150,6 +150,7 @@ const INFERENCE_SOURCE_LABELS: Record<InferenceBackendKind, string> = {
|
||||
"anthropic-api-key": "ANTHROPIC_API_KEY",
|
||||
"claude-cli": "Claude Code CLI",
|
||||
"codex-cli": "Codex app-server",
|
||||
"gemini-cli": "Gemini CLI",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,7 +25,7 @@ export type CrestodianSetupApplyResult = {
|
||||
};
|
||||
|
||||
/** Prompter for quickstart-only flows: notes go to the log, prompts fail loud. */
|
||||
function createQuickstartNotePrompter(runtime: RuntimeEnv): WizardPrompter {
|
||||
export function createQuickstartNotePrompter(runtime: RuntimeEnv): WizardPrompter {
|
||||
const unexpected = (kind: string) => {
|
||||
throw new Error(`crestodian setup hit an interactive ${kind} prompt; quickstart must not ask`);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { activateSetupInference, detectSetupInference } from "./setup-inference.js";
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
readConfigFileSnapshot: vi.fn(async () => ({
|
||||
exists: false,
|
||||
valid: false,
|
||||
path: "/tmp/openclaw.json",
|
||||
issues: [],
|
||||
config: {},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../commands/onboard-inference.js", async (importActual) => {
|
||||
const actual = await importActual<typeof import("../commands/onboard-inference.js")>();
|
||||
return {
|
||||
...actual,
|
||||
detectInferenceBackends: vi.fn(async () => [
|
||||
{
|
||||
kind: "claude-cli",
|
||||
modelRef: "claude-cli/claude-opus-4-8",
|
||||
label: "Claude Code",
|
||||
detail: "logged in",
|
||||
credentials: true,
|
||||
},
|
||||
{
|
||||
kind: "codex-cli",
|
||||
modelRef: "openai/gpt-5.5",
|
||||
label: "Codex",
|
||||
detail: "installed, not logged in",
|
||||
credentials: false,
|
||||
},
|
||||
]),
|
||||
};
|
||||
});
|
||||
|
||||
const runtime = { log: () => {}, error: () => {}, exit: () => {} } as never;
|
||||
|
||||
async function makeTempDir(): Promise<string> {
|
||||
return await fs.mkdtemp(path.join(os.tmpdir(), "setup-inference-test-"));
|
||||
}
|
||||
|
||||
describe("detectSetupInference", () => {
|
||||
it("marks the first non-logged-out candidate recommended", async () => {
|
||||
const detection = await detectSetupInference();
|
||||
expect(detection.candidates).toHaveLength(2);
|
||||
expect(detection.candidates[0]).toMatchObject({ kind: "claude-cli", recommended: true });
|
||||
expect(detection.candidates[1]).toMatchObject({ kind: "codex-cli", recommended: false });
|
||||
expect(detection.setupComplete).toBe(false);
|
||||
expect(detection.workspace.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("activateSetupInference", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("persists setup only after the live test succeeds", async () => {
|
||||
const applySetup = vi.fn(async (_params: unknown) => ({
|
||||
configPath: "/tmp/openclaw.json",
|
||||
lines: ["ok"],
|
||||
}));
|
||||
const runCliAgent = vi.fn(async (_params: unknown) => ({
|
||||
meta: { finalAssistantVisibleText: "OK" },
|
||||
}));
|
||||
const result = await activateSetupInference({
|
||||
kind: "claude-cli",
|
||||
surface: "gateway",
|
||||
runtime,
|
||||
deps: {
|
||||
runCliAgent: runCliAgent as never,
|
||||
applySetup: applySetup as never,
|
||||
createTempDir: makeTempDir,
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.modelRef).toBe("claude-cli/claude-opus-4-8");
|
||||
expect(result.lines).toEqual(["ok"]);
|
||||
}
|
||||
expect(runCliAgent).toHaveBeenCalledOnce();
|
||||
expect(applySetup).toHaveBeenCalledOnce();
|
||||
expect(applySetup.mock.calls[0]?.[0]).toMatchObject({
|
||||
model: "claude-cli/claude-opus-4-8",
|
||||
surface: "gateway",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not touch config when the live test fails", async () => {
|
||||
const applySetup = vi.fn(async () => ({ configPath: "/tmp/openclaw.json", lines: [] }));
|
||||
const runCliAgent = vi.fn(async () => {
|
||||
throw new Error("401 invalid_api_key");
|
||||
});
|
||||
const result = await activateSetupInference({
|
||||
kind: "claude-cli",
|
||||
surface: "gateway",
|
||||
runtime,
|
||||
deps: {
|
||||
runCliAgent: runCliAgent as never,
|
||||
applySetup: applySetup as never,
|
||||
createTempDir: makeTempDir,
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("invalid_api_key");
|
||||
}
|
||||
expect(applySetup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats an empty model reply as a failure", async () => {
|
||||
const applySetup = vi.fn(async () => ({ configPath: "/tmp/openclaw.json", lines: [] }));
|
||||
const runEmbeddedAgent = vi.fn(async () => ({ payloads: [] }));
|
||||
const result = await activateSetupInference({
|
||||
kind: "anthropic-api-key",
|
||||
surface: "gateway",
|
||||
runtime,
|
||||
deps: {
|
||||
runEmbeddedAgent: runEmbeddedAgent as never,
|
||||
applySetup: applySetup as never,
|
||||
createTempDir: makeTempDir,
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, status: "format" });
|
||||
expect(applySetup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects manual activation without a supported provider", async () => {
|
||||
const result = await activateSetupInference({
|
||||
kind: "api-key",
|
||||
provider: "definitely-not-a-provider",
|
||||
apiKey: "sk-test",
|
||||
surface: "gateway",
|
||||
runtime,
|
||||
deps: { createTempDir: makeTempDir },
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, status: "unavailable" });
|
||||
});
|
||||
|
||||
it("runs the codex plugin ensure step only after a passing test", async () => {
|
||||
const applySetup = vi.fn(async () => ({ configPath: "/tmp/openclaw.json", lines: ["ok"] }));
|
||||
const ensureCodex = vi.fn(async () => ({
|
||||
cfg: {},
|
||||
required: false,
|
||||
installed: false,
|
||||
}));
|
||||
const runEmbeddedAgent = vi.fn(async (_params: unknown) => ({
|
||||
meta: { finalAssistantVisibleText: "OK" },
|
||||
}));
|
||||
const result = await activateSetupInference({
|
||||
kind: "codex-cli",
|
||||
surface: "gateway",
|
||||
runtime,
|
||||
deps: {
|
||||
runEmbeddedAgent: runEmbeddedAgent as never,
|
||||
applySetup: applySetup as never,
|
||||
ensureCodexRuntimePlugin: ensureCodex as never,
|
||||
createTempDir: makeTempDir,
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(ensureCodex).toHaveBeenCalledOnce();
|
||||
// Harness selection: codex tests run embedded with the codex harness.
|
||||
expect(runEmbeddedAgent.mock.calls[0]?.[0]).toMatchObject({
|
||||
agentHarnessId: "codex",
|
||||
provider: "openai",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,526 @@
|
||||
// First-run inference activation: detect candidates, live-test, persist only on success.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveAgentDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { upsertAuthProfileWithLock } from "../agents/auth-profiles/profiles.js";
|
||||
import { updateAuthProfileStoreWithLock } from "../agents/auth-profiles/store.js";
|
||||
import type { AuthProfileCredential } from "../agents/auth-profiles/types.js";
|
||||
import { describeFailoverError } from "../agents/failover-error.js";
|
||||
import {
|
||||
isCliProvider,
|
||||
normalizeProviderId,
|
||||
resolveDefaultModelForAgent,
|
||||
} from "../agents/model-selection.js";
|
||||
import {
|
||||
ANTHROPIC_API_DEFAULT_MODEL_REF,
|
||||
CLAUDE_CLI_DEFAULT_MODEL_REF,
|
||||
CODEX_APP_SERVER_DEFAULT_MODEL_REF,
|
||||
GEMINI_CLI_DEFAULT_MODEL_REF,
|
||||
OPENAI_API_DEFAULT_MODEL_REF,
|
||||
detectInferenceBackends,
|
||||
type InferenceBackendKind,
|
||||
} from "../commands/onboard-inference.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { buildCliPlannerConfig, buildCodexAppServerPlannerConfig } from "./assistant-backends.js";
|
||||
import { loadAuthoredSetupConfig } from "./onboarding-welcome.js";
|
||||
import { applyCrestodianSetup, createQuickstartNotePrompter } from "./setup-apply.js";
|
||||
|
||||
/**
|
||||
* Inference is the one required onboarding step (docs/cli/crestodian.md
|
||||
* "Setup bootstrap"). This module gives structured clients (macOS app) the
|
||||
* same ladder the conversation uses, with one hard guarantee: a candidate is
|
||||
* persisted as the default model only after a real completion round-trips.
|
||||
* A failing candidate must never leave config pointing at a broken model.
|
||||
*/
|
||||
export const SETUP_INFERENCE_TEST_TIMEOUT_MS = 90_000;
|
||||
const SETUP_INFERENCE_TEST_PROMPT = "Reply with the single word OK. Do not use tools.";
|
||||
const SETUP_INFERENCE_TEST_MAX_TOKENS = 32;
|
||||
const GOOGLE_API_DEFAULT_MODEL_REF = "google/gemini-3.1-pro-preview";
|
||||
|
||||
/** Providers accepted for the manual API-key step, mapped to a starter model. */
|
||||
const MANUAL_API_KEY_MODEL_REFS: Record<string, string> = {
|
||||
anthropic: ANTHROPIC_API_DEFAULT_MODEL_REF,
|
||||
openai: OPENAI_API_DEFAULT_MODEL_REF,
|
||||
google: GOOGLE_API_DEFAULT_MODEL_REF,
|
||||
};
|
||||
|
||||
export type SetupInferenceCandidate = {
|
||||
kind: InferenceBackendKind;
|
||||
label: string;
|
||||
detail: string;
|
||||
modelRef: string;
|
||||
recommended: boolean;
|
||||
credentials?: boolean;
|
||||
};
|
||||
|
||||
export type SetupInferenceDetection = {
|
||||
candidates: SetupInferenceCandidate[];
|
||||
/** Resolved workspace the setup apply would use (display + default). */
|
||||
workspace: string;
|
||||
configuredModel?: string;
|
||||
/** Config already carries authored setup and a default model. */
|
||||
setupComplete: boolean;
|
||||
};
|
||||
|
||||
export type SetupInferenceStatus =
|
||||
| "ok"
|
||||
| "auth"
|
||||
| "rate_limit"
|
||||
| "billing"
|
||||
| "timeout"
|
||||
| "format"
|
||||
| "unavailable"
|
||||
| "unknown";
|
||||
|
||||
export type ActivateSetupInferenceResult =
|
||||
| { ok: true; modelRef: string; latencyMs: number; lines: string[] }
|
||||
| { ok: false; status: SetupInferenceStatus; error: string };
|
||||
|
||||
export type ActivateSetupInferenceParams = {
|
||||
kind: InferenceBackendKind | "api-key";
|
||||
/** Manual step only: provider the pasted API key belongs to. */
|
||||
provider?: string;
|
||||
/** Manual step only: the pasted API key. Never logged. */
|
||||
apiKey?: string;
|
||||
workspace?: string;
|
||||
surface: "cli" | "gateway";
|
||||
runtime: RuntimeEnv;
|
||||
deps?: ActivateSetupInferenceDeps;
|
||||
};
|
||||
|
||||
export type ActivateSetupInferenceDeps = {
|
||||
readConfigFileSnapshot?: typeof import("../config/config.js").readConfigFileSnapshot;
|
||||
runEmbeddedAgent?: typeof import("../agents/embedded-agent.js").runEmbeddedAgent;
|
||||
runCliAgent?: typeof import("../agents/cli-runner.js").runCliAgent;
|
||||
applySetup?: typeof applyCrestodianSetup;
|
||||
ensureCodexRuntimePlugin?: typeof import("../commands/codex-runtime-plugin-install.js").ensureCodexRuntimePluginForModelSelection;
|
||||
updateConfig?: typeof import("../commands/models/shared.js").updateConfig;
|
||||
createTempDir?: () => Promise<string>;
|
||||
removeTempDir?: (dir: string) => Promise<void>;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export async function detectSetupInference(): Promise<SetupInferenceDetection> {
|
||||
const { readConfigFileSnapshot } = await import("../config/config.js");
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
const cfg = snapshot.exists && snapshot.valid ? (snapshot.runtimeConfig ?? snapshot.config) : {};
|
||||
const raw = await detectInferenceBackends({ config: cfg });
|
||||
// Recommended = the first candidate setup itself would bootstrap with; a
|
||||
// definitively logged-out CLI never gets the badge.
|
||||
const recommendedIndex = raw.findIndex((candidate) => candidate.credentials !== false);
|
||||
const candidates = raw.map((candidate, index) => ({
|
||||
...candidate,
|
||||
recommended: index === recommendedIndex,
|
||||
}));
|
||||
const { authoredConfig, hasAuthoredSetup } = await loadAuthoredSetupConfig({
|
||||
configExists: snapshot.exists,
|
||||
configValid: snapshot.valid,
|
||||
});
|
||||
const configuredModel = raw.find((candidate) => candidate.kind === "existing-model")?.modelRef;
|
||||
const { DEFAULT_WORKSPACE } = await import("../commands/onboard-helpers.js");
|
||||
const workspace = resolveUserPath(
|
||||
authoredConfig?.agents?.defaults?.workspace?.trim() || DEFAULT_WORKSPACE,
|
||||
);
|
||||
return {
|
||||
candidates,
|
||||
workspace,
|
||||
...(configuredModel ? { configuredModel } : {}),
|
||||
setupComplete: hasAuthoredSetup && Boolean(configuredModel),
|
||||
};
|
||||
}
|
||||
|
||||
type SetupInferenceTestPlan = {
|
||||
runner: "cli" | "embedded";
|
||||
provider: string;
|
||||
model: string;
|
||||
modelRef: string;
|
||||
config: OpenClawConfig;
|
||||
agentHarnessId?: string;
|
||||
authProfileId?: string;
|
||||
/** Model to persist as default on success; undefined keeps the current one. */
|
||||
persistModelRef?: string;
|
||||
};
|
||||
|
||||
type RunResult = {
|
||||
payloads?: Array<{ text?: string }>;
|
||||
meta?: { finalAssistantVisibleText?: string; finalAssistantRawText?: string };
|
||||
};
|
||||
|
||||
function extractRunText(result: RunResult): string | undefined {
|
||||
return (
|
||||
result.meta?.finalAssistantVisibleText ??
|
||||
result.meta?.finalAssistantRawText ??
|
||||
result.payloads
|
||||
?.map((payload) => payload.text?.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
function parseRef(modelRef: string): { provider: string; model: string } {
|
||||
const slash = modelRef.indexOf("/");
|
||||
return slash === -1
|
||||
? { provider: modelRef, model: "" }
|
||||
: { provider: modelRef.slice(0, slash), model: modelRef.slice(slash + 1) };
|
||||
}
|
||||
|
||||
function mapFailoverReasonToSetupStatus(reason?: string | null): SetupInferenceStatus {
|
||||
if (reason === "auth" || reason === "auth_permanent") {
|
||||
return "auth";
|
||||
}
|
||||
if (reason === "rate_limit" || reason === "overloaded") {
|
||||
return "rate_limit";
|
||||
}
|
||||
if (reason === "billing") {
|
||||
return "billing";
|
||||
}
|
||||
if (reason === "timeout") {
|
||||
return "timeout";
|
||||
}
|
||||
if (reason === "format" || reason === "model_not_found") {
|
||||
return "format";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
async function buildTestPlan(params: {
|
||||
kind: InferenceBackendKind | "api-key";
|
||||
provider?: string;
|
||||
cfg: OpenClawConfig;
|
||||
workspaceDir: string;
|
||||
}): Promise<SetupInferenceTestPlan | { error: string }> {
|
||||
const { kind, cfg, workspaceDir } = params;
|
||||
switch (kind) {
|
||||
case "existing-model": {
|
||||
const ref = resolveDefaultModelForAgent({ cfg, agentId: resolveDefaultAgentId(cfg) });
|
||||
const modelRef = `${ref.provider}/${ref.model}`;
|
||||
return {
|
||||
runner: isCliProvider(ref.provider, cfg) ? "cli" : "embedded",
|
||||
provider: ref.provider,
|
||||
model: ref.model,
|
||||
modelRef,
|
||||
config: cfg,
|
||||
};
|
||||
}
|
||||
case "claude-cli": {
|
||||
const ref = parseRef(CLAUDE_CLI_DEFAULT_MODEL_REF);
|
||||
return {
|
||||
runner: "cli",
|
||||
...ref,
|
||||
modelRef: CLAUDE_CLI_DEFAULT_MODEL_REF,
|
||||
config: buildCliPlannerConfig(workspaceDir, CLAUDE_CLI_DEFAULT_MODEL_REF),
|
||||
persistModelRef: CLAUDE_CLI_DEFAULT_MODEL_REF,
|
||||
};
|
||||
}
|
||||
case "gemini-cli": {
|
||||
const ref = parseRef(GEMINI_CLI_DEFAULT_MODEL_REF);
|
||||
return {
|
||||
runner: "cli",
|
||||
...ref,
|
||||
modelRef: GEMINI_CLI_DEFAULT_MODEL_REF,
|
||||
config: buildCliPlannerConfig(workspaceDir, GEMINI_CLI_DEFAULT_MODEL_REF),
|
||||
persistModelRef: GEMINI_CLI_DEFAULT_MODEL_REF,
|
||||
};
|
||||
}
|
||||
case "codex-cli": {
|
||||
const ref = parseRef(CODEX_APP_SERVER_DEFAULT_MODEL_REF);
|
||||
return {
|
||||
runner: "embedded",
|
||||
...ref,
|
||||
modelRef: CODEX_APP_SERVER_DEFAULT_MODEL_REF,
|
||||
config: buildCodexAppServerPlannerConfig(workspaceDir),
|
||||
agentHarnessId: "codex",
|
||||
persistModelRef: CODEX_APP_SERVER_DEFAULT_MODEL_REF,
|
||||
};
|
||||
}
|
||||
case "openai-api-key": {
|
||||
const ref = parseRef(OPENAI_API_DEFAULT_MODEL_REF);
|
||||
return {
|
||||
runner: "embedded",
|
||||
...ref,
|
||||
modelRef: OPENAI_API_DEFAULT_MODEL_REF,
|
||||
config: buildCliPlannerConfig(workspaceDir, OPENAI_API_DEFAULT_MODEL_REF),
|
||||
persistModelRef: OPENAI_API_DEFAULT_MODEL_REF,
|
||||
};
|
||||
}
|
||||
case "anthropic-api-key": {
|
||||
const ref = parseRef(ANTHROPIC_API_DEFAULT_MODEL_REF);
|
||||
return {
|
||||
runner: "embedded",
|
||||
...ref,
|
||||
modelRef: ANTHROPIC_API_DEFAULT_MODEL_REF,
|
||||
config: buildCliPlannerConfig(workspaceDir, ANTHROPIC_API_DEFAULT_MODEL_REF),
|
||||
persistModelRef: ANTHROPIC_API_DEFAULT_MODEL_REF,
|
||||
};
|
||||
}
|
||||
case "api-key": {
|
||||
const provider = normalizeProviderId(params.provider ?? "");
|
||||
const canonical = provider === "codex" || provider === "openai-codex" ? "openai" : provider;
|
||||
const modelRef = MANUAL_API_KEY_MODEL_REFS[canonical];
|
||||
if (!modelRef) {
|
||||
return {
|
||||
error: `Unsupported provider "${params.provider ?? ""}" — expected anthropic, openai, or google.`,
|
||||
};
|
||||
}
|
||||
const ref = parseRef(modelRef);
|
||||
return {
|
||||
runner: "embedded",
|
||||
...ref,
|
||||
modelRef,
|
||||
config: buildCliPlannerConfig(workspaceDir, modelRef),
|
||||
authProfileId: `${canonical}:manual`,
|
||||
persistModelRef: modelRef,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { error: `Unknown inference choice "${String(kind)}".` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test one candidate with a real completion, then persist it as the setup
|
||||
* default. Manual API keys are staged into the auth store for the test and
|
||||
* rolled back when the test fails, so a bad key leaves no trace.
|
||||
*/
|
||||
export async function activateSetupInference(
|
||||
params: ActivateSetupInferenceParams,
|
||||
): Promise<ActivateSetupInferenceResult> {
|
||||
const deps = params.deps ?? {};
|
||||
const readSnapshot =
|
||||
deps.readConfigFileSnapshot ?? (await import("../config/config.js")).readConfigFileSnapshot;
|
||||
const snapshot = await readSnapshot();
|
||||
const cfg: OpenClawConfig =
|
||||
snapshot.exists && snapshot.valid ? (snapshot.runtimeConfig ?? snapshot.config) : {};
|
||||
|
||||
const tempDir = await (
|
||||
deps.createTempDir ?? (() => fs.mkdtemp(path.join(os.tmpdir(), "openclaw-setup-inference-")))
|
||||
)();
|
||||
try {
|
||||
const plan = await buildTestPlan({
|
||||
kind: params.kind,
|
||||
...(params.provider !== undefined ? { provider: params.provider } : {}),
|
||||
cfg,
|
||||
workspaceDir: tempDir,
|
||||
});
|
||||
if ("error" in plan) {
|
||||
return { ok: false, status: "unavailable", error: plan.error };
|
||||
}
|
||||
|
||||
const agentDir = resolveAgentDir(cfg, resolveDefaultAgentId(cfg));
|
||||
let stagedProfile: { profileId: string; prior?: AuthProfileCredential } | null = null;
|
||||
if (plan.authProfileId) {
|
||||
const apiKey = params.apiKey?.trim();
|
||||
if (!apiKey) {
|
||||
return { ok: false, status: "unavailable", error: "Enter an API key first." };
|
||||
}
|
||||
stagedProfile = await stageManualApiKeyProfile({
|
||||
profileId: plan.authProfileId,
|
||||
provider: plan.provider,
|
||||
apiKey,
|
||||
agentDir,
|
||||
});
|
||||
if (!stagedProfile) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unknown",
|
||||
error: "Could not update the auth profile store; try again in a moment.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const test = await runSetupInferenceTest({ plan, tempDir, deps });
|
||||
if (!test.ok) {
|
||||
if (stagedProfile) {
|
||||
await rollbackManualApiKeyProfile({ ...stagedProfile, agentDir });
|
||||
}
|
||||
return test;
|
||||
}
|
||||
|
||||
// Test passed — persist. Codex routes openai/* through the Codex plugin,
|
||||
// so make sure it is installed/enabled before the model ref lands in config.
|
||||
if (params.kind === "codex-cli") {
|
||||
const ensureCodex =
|
||||
deps.ensureCodexRuntimePlugin ??
|
||||
(await import("../commands/codex-runtime-plugin-install.js"))
|
||||
.ensureCodexRuntimePluginForModelSelection;
|
||||
const ensured = await ensureCodex({
|
||||
cfg,
|
||||
model: plan.modelRef,
|
||||
prompter: createQuickstartNotePrompter(params.runtime),
|
||||
runtime: params.runtime,
|
||||
workspaceDir: tempDir,
|
||||
});
|
||||
if (ensured.required) {
|
||||
const updateConfig =
|
||||
deps.updateConfig ?? (await import("../commands/models/shared.js")).updateConfig;
|
||||
const { enablePluginInConfig } = await import("../plugins/enable.js");
|
||||
await updateConfig((current) => enablePluginInConfig(current, "codex").config);
|
||||
}
|
||||
}
|
||||
if (stagedProfile && plan.authProfileId) {
|
||||
const updateConfig =
|
||||
deps.updateConfig ?? (await import("../commands/models/shared.js")).updateConfig;
|
||||
const { applyAuthProfileConfig } = await import("../plugins/provider-auth-helpers.js");
|
||||
const profileId = plan.authProfileId;
|
||||
const provider = plan.provider;
|
||||
await updateConfig((current) =>
|
||||
applyAuthProfileConfig(current, { profileId, provider, mode: "api_key" }),
|
||||
);
|
||||
}
|
||||
|
||||
const applySetup = deps.applySetup ?? applyCrestodianSetup;
|
||||
const detection = params.workspace?.trim()
|
||||
? { workspace: resolveUserPath(params.workspace) }
|
||||
: { workspace: (await detectSetupInference()).workspace };
|
||||
const applied = await applySetup({
|
||||
workspace: detection.workspace,
|
||||
...(plan.persistModelRef ? { model: plan.persistModelRef } : {}),
|
||||
surface: params.surface,
|
||||
runtime: params.runtime,
|
||||
});
|
||||
return { ok: true, modelRef: plan.modelRef, latencyMs: test.latencyMs, lines: applied.lines };
|
||||
} finally {
|
||||
await (deps.removeTempDir ?? ((dir: string) => fs.rm(dir, { recursive: true, force: true })))(
|
||||
tempDir,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function stageManualApiKeyProfile(params: {
|
||||
profileId: string;
|
||||
provider: string;
|
||||
apiKey: string;
|
||||
agentDir: string;
|
||||
}): Promise<{ profileId: string; prior?: AuthProfileCredential } | null> {
|
||||
let prior: AuthProfileCredential | undefined;
|
||||
const updated = await updateAuthProfileStoreWithLock({
|
||||
agentDir: params.agentDir,
|
||||
saveOptions: { filterExternalAuthProfiles: false, syncExternalCli: false },
|
||||
updater: (store) => {
|
||||
prior = store.profiles[params.profileId];
|
||||
return false;
|
||||
},
|
||||
});
|
||||
if (updated === null) {
|
||||
return null;
|
||||
}
|
||||
const upserted = await upsertAuthProfileWithLock({
|
||||
profileId: params.profileId,
|
||||
credential: { type: "api_key", provider: params.provider, key: params.apiKey },
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
if (upserted === null) {
|
||||
return null;
|
||||
}
|
||||
return { profileId: params.profileId, ...(prior ? { prior } : {}) };
|
||||
}
|
||||
|
||||
async function rollbackManualApiKeyProfile(params: {
|
||||
profileId: string;
|
||||
prior?: AuthProfileCredential;
|
||||
agentDir: string;
|
||||
}): Promise<void> {
|
||||
await updateAuthProfileStoreWithLock({
|
||||
agentDir: params.agentDir,
|
||||
saveOptions: { filterExternalAuthProfiles: false, syncExternalCli: false },
|
||||
updater: (store) => {
|
||||
if (params.prior) {
|
||||
store.profiles[params.profileId] = params.prior;
|
||||
} else {
|
||||
delete store.profiles[params.profileId];
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function runSetupInferenceTest(params: {
|
||||
plan: SetupInferenceTestPlan;
|
||||
tempDir: string;
|
||||
deps: ActivateSetupInferenceDeps;
|
||||
}): Promise<
|
||||
{ ok: true; latencyMs: number } | { ok: false; status: SetupInferenceStatus; error: string }
|
||||
> {
|
||||
const { plan, tempDir, deps } = params;
|
||||
const runId = `setup-inference-${randomUUID()}`;
|
||||
const sessionId = `${runId}-session`;
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const timeoutMs = deps.timeoutMs ?? SETUP_INFERENCE_TEST_TIMEOUT_MS;
|
||||
const started = Date.now();
|
||||
try {
|
||||
let result: RunResult;
|
||||
if (plan.runner === "cli") {
|
||||
const runCli = deps.runCliAgent ?? (await import("../agents/cli-runner.js")).runCliAgent;
|
||||
result = (await runCli({
|
||||
sessionId,
|
||||
sessionKey: `temp:setup-inference:${runId}`,
|
||||
agentId: "crestodian",
|
||||
trigger: "manual",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
config: plan.config,
|
||||
prompt: SETUP_INFERENCE_TEST_PROMPT,
|
||||
provider: plan.provider,
|
||||
model: plan.model,
|
||||
timeoutMs,
|
||||
runId,
|
||||
messageChannel: "crestodian",
|
||||
messageProvider: "crestodian",
|
||||
cleanupCliLiveSessionOnRunEnd: true,
|
||||
})) as RunResult;
|
||||
} else {
|
||||
const runEmbedded =
|
||||
deps.runEmbeddedAgent ?? (await import("../agents/embedded-agent.js")).runEmbeddedAgent;
|
||||
result = (await runEmbedded({
|
||||
sessionId,
|
||||
sessionKey: `temp:setup-inference:${runId}`,
|
||||
agentId: "crestodian",
|
||||
trigger: "manual",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
config: plan.config,
|
||||
prompt: SETUP_INFERENCE_TEST_PROMPT,
|
||||
provider: plan.provider,
|
||||
model: plan.model,
|
||||
...(plan.authProfileId
|
||||
? { authProfileId: plan.authProfileId, authProfileIdSource: "user" as const }
|
||||
: {}),
|
||||
...(plan.agentHarnessId
|
||||
? { agentHarnessId: plan.agentHarnessId, cleanupBundleMcpOnRunEnd: true }
|
||||
: {}),
|
||||
timeoutMs,
|
||||
runId,
|
||||
lane: `setup-inference:${plan.provider}`,
|
||||
thinkLevel: "off",
|
||||
reasoningLevel: "off",
|
||||
verboseLevel: "off",
|
||||
streamParams: { maxTokens: SETUP_INFERENCE_TEST_MAX_TOKENS },
|
||||
disableTools: true,
|
||||
modelRun: true,
|
||||
messageChannel: "crestodian",
|
||||
messageProvider: "crestodian",
|
||||
})) as RunResult;
|
||||
}
|
||||
const text = extractRunText(result)?.trim();
|
||||
if (!text) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "format",
|
||||
error: "The model started but did not send a reply. Try again or pick another option.",
|
||||
};
|
||||
}
|
||||
return { ok: true, latencyMs: Date.now() - started };
|
||||
} catch (error) {
|
||||
const described = describeFailoverError(error);
|
||||
const { redactSecrets } = await import("../commands/status-all/format.js");
|
||||
return {
|
||||
ok: false,
|
||||
status: mapFailoverReasonToSetupStatus(described.reason),
|
||||
error: redactSecrets(described.message),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,8 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
|
||||
{ name: "plugins.uiDescriptors", scope: "operator.read" },
|
||||
{ name: "plugins.sessionAction", scope: "dynamic" },
|
||||
{ name: "crestodian.chat", scope: "operator.admin" },
|
||||
{ name: "crestodian.setup.detect", scope: "operator.admin" },
|
||||
{ name: "crestodian.setup.activate", scope: "operator.admin" },
|
||||
{ name: "wizard.start", scope: "operator.admin" },
|
||||
{ name: "wizard.next", scope: "operator.admin" },
|
||||
{ name: "wizard.cancel", scope: "operator.admin" },
|
||||
|
||||
@@ -435,7 +435,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
loadHandlers: loadWizardHandlers,
|
||||
}),
|
||||
...createLazyCoreHandlers({
|
||||
methods: ["crestodian.chat"],
|
||||
methods: ["crestodian.chat", "crestodian.setup.detect", "crestodian.setup.activate"],
|
||||
loadHandlers: loadCrestodianHandlers,
|
||||
}),
|
||||
...createLazyCoreHandlers({
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
// Crestodian gateway methods host the setup/repair conversation for clients.
|
||||
import { validateCrestodianChatParams } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
validateCrestodianChatParams,
|
||||
validateCrestodianSetupActivateParams,
|
||||
validateCrestodianSetupDetectParams,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { CrestodianChatEngine } from "../../crestodian/chat-engine.js";
|
||||
import { buildOnboardingWelcome } from "../../crestodian/onboarding-welcome.js";
|
||||
import { formatCrestodianStartupMessage } from "../../crestodian/overview.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
|
||||
@@ -43,6 +48,56 @@ async function evictOldestSession(sessions: Map<string, CrestodianChatSession>):
|
||||
}
|
||||
|
||||
export const crestodianHandlers: GatewayRequestHandlers = {
|
||||
/** Structured onboarding: list reusable AI access on this host. */
|
||||
"crestodian.setup.detect": async ({ params, respond }) => {
|
||||
if (
|
||||
!assertValidParams(
|
||||
params,
|
||||
validateCrestodianSetupDetectParams,
|
||||
"crestodian.setup.detect",
|
||||
respond,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const { detectSetupInference } = await import("../../crestodian/setup-inference.js");
|
||||
respond(true, await detectSetupInference(), undefined);
|
||||
},
|
||||
/**
|
||||
* Structured onboarding: live-test one candidate and persist it on success.
|
||||
* Serialized per gateway process implicitly by the app driving one attempt
|
||||
* at a time; a failed attempt never mutates config (see setup-inference.ts).
|
||||
*/
|
||||
"crestodian.setup.activate": async ({ params, respond }) => {
|
||||
if (
|
||||
!assertValidParams(
|
||||
params,
|
||||
validateCrestodianSetupActivateParams,
|
||||
"crestodian.setup.activate",
|
||||
respond,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const { activateSetupInference } = await import("../../crestodian/setup-inference.js");
|
||||
const runtime = {
|
||||
...defaultRuntime,
|
||||
// Setup runs inside the gateway process; a failing sub-step must reject
|
||||
// the RPC, never exit the daemon.
|
||||
exit: (code: number | undefined): never => {
|
||||
throw new Error(`setup step exited with code ${String(code)}`);
|
||||
},
|
||||
};
|
||||
const result = await activateSetupInference({
|
||||
kind: params.kind,
|
||||
...(params.provider !== undefined ? { provider: params.provider } : {}),
|
||||
...(params.apiKey !== undefined ? { apiKey: params.apiKey } : {}),
|
||||
...(params.workspace !== undefined ? { workspace: params.workspace } : {}),
|
||||
surface: "gateway",
|
||||
runtime,
|
||||
});
|
||||
respond(true, result, undefined);
|
||||
},
|
||||
"crestodian.chat": async ({ params, respond, context }) => {
|
||||
if (!assertValidParams(params, validateCrestodianChatParams, "crestodian.chat", respond)) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user