mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
feat: chat-start preflight, Nous catalog, remote-aware admin sheets
Three feature batches that were in progress on chat-resilience — all aligned with v2.5.2's remote-context theme. ## Chat-start model preflight When a chat-start hits a server whose config.yaml has no model.default / model.provider, the upstream provider returns an opaque "Model parameter is required" 400 only AFTER the user types a prompt and hits send. New ModelPreflight in ScarfCore catches the missing keys before any ACP work; ChatView presents the existing ModelPickerSheet via a thin ChatModelPreflightSheet wrapper so the picker / validation / Nous-catalog branch stay single-sourced. ChatViewModel persists the selection via `hermes config set` and replays the original startACPSession arguments — the chat the user originally opened lands without re-clicking the project row. ## Nous Portal live catalog NousModelCatalogService fetches `GET /v1/models` from inference-api.nousresearch.com using the bearer token in `auth.json`, caches to `~/.hermes/scarf/nous_models_cache.json` (new path on HermesPathSet) with a 24h TTL. Picker's nous-overlay detail switches from a free-form TextField to a real model list, with a "Custom…" escape hatch (nousManualEntry) for IDs not yet in the API response. ## Remote-aware admin sheets (mirror of #54's pattern) The Add Project sheet got context-aware Verify in v2.5.1 (#54); this batch extends the same shape to three more sheets: - Profiles: remote import/export. ProfilesView gains showRemoteImportSheet + pendingRemoteExport state; reuses the same path-input + verify + run-via-hermes pattern from AddProjectSheet. Drives `hermes profile import <zip>` / `hermes profile export <name> <zip>` over SSH. - Backup restore (Settings → Advanced): pickLocalBackupZip + new RemoteBackupPathSheet so the Restore action picks a local zip on local contexts and verifies a remote path on remote contexts. - Template install destination: TemplateInstallSheet's parent- directory picker now branches on context. ParentDirectoryStep with browseLocalDirectory + verifyRemotePath + RemoteVerification — same UX vocabulary as AddProjectSheet, applied to where the template gets installed. Plus a `runHermesWithStdin` helper on HermesFileService for the profile import flow (passing zip bytes through stdin rather than landing them on the remote disk first), and ProjectTemplateInstaller gains a remote-path-aware code path for the install destination. ## Localizations Localizable.xcstrings adds strings for all the new copy across seven supported locales (en, zh-Hans, de, fr, es, ja, pt-BR).
This commit is contained in:
@@ -81,6 +81,12 @@ public struct HermesPathSet: Sendable, Hashable {
|
||||
/// Maps Hermes session IDs to the Scarf project path a chat was
|
||||
/// started for. Scarf-owned; Hermes never touches this file.
|
||||
public nonisolated var sessionProjectMap: String { scarfDir + "/session_project_map.json" }
|
||||
/// Cached list of available Nous Portal models. Populated by
|
||||
/// `NousModelCatalogService` from `GET https://inference-api.nousresearch.com/v1/models`
|
||||
/// using the bearer token in `auth.json`. Refreshed on a 24h TTL or
|
||||
/// on user request from the model picker. Survives offline runs so
|
||||
/// the picker still has something to render.
|
||||
public nonisolated var nousModelsCache: String { scarfDir + "/nous_models_cache.json" }
|
||||
public nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
|
||||
|
||||
// MARK: - Binary resolution
|
||||
|
||||
@@ -25,6 +25,10 @@ public struct SSHConfig: Sendable, Hashable, Codable {
|
||||
/// `HermesPathSet.defaultRemoteHome` (`~/.hermes`, shell-expanded on the
|
||||
/// remote side).
|
||||
public var remoteHome: String?
|
||||
/// Override for where Scarf installs new project templates on this host.
|
||||
/// `nil` uses `~/projects` (unexpanded — remote shell resolves it).
|
||||
/// Created on first install if missing.
|
||||
public var projectsRoot: String?
|
||||
/// Resolved remote path to the `hermes` binary. Populated by
|
||||
/// `SSHTransport` after the first `command -v hermes` probe; cached here
|
||||
/// so subsequent calls skip the round trip.
|
||||
@@ -36,6 +40,7 @@ public struct SSHConfig: Sendable, Hashable, Codable {
|
||||
port: Int? = nil,
|
||||
identityFile: String? = nil,
|
||||
remoteHome: String? = nil,
|
||||
projectsRoot: String? = nil,
|
||||
hermesBinaryHint: String? = nil
|
||||
) {
|
||||
self.host = host
|
||||
@@ -43,6 +48,7 @@ public struct SSHConfig: Sendable, Hashable, Codable {
|
||||
self.port = port
|
||||
self.identityFile = identityFile
|
||||
self.remoteHome = remoteHome
|
||||
self.projectsRoot = projectsRoot
|
||||
self.hermesBinaryHint = hermesBinaryHint
|
||||
}
|
||||
}
|
||||
@@ -106,6 +112,27 @@ public struct ServerContext: Sendable, Hashable, Identifiable {
|
||||
return false
|
||||
}
|
||||
|
||||
/// Default parent directory under which `ProjectTemplateInstaller` lays
|
||||
/// out new projects. Per-host configurable on `.ssh` via
|
||||
/// `SSHConfig.projectsRoot`; local always resolves to `~/Projects` on the
|
||||
/// user's Mac. The remote default is left as an unexpanded `~/projects`
|
||||
/// — the remote shell resolves the tilde, same convention as
|
||||
/// `HermesPathSet.defaultRemoteHome`. The installer calls
|
||||
/// `transport.createDirectory(_:)` at install time so a missing dir on a
|
||||
/// fresh host is bootstrapped on first use rather than treated as an error.
|
||||
public nonisolated var defaultProjectsRoot: String {
|
||||
switch kind {
|
||||
case .local:
|
||||
return NSHomeDirectory() + "/Projects"
|
||||
case .ssh(let config):
|
||||
if let configured = config.projectsRoot,
|
||||
!configured.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
return configured
|
||||
}
|
||||
return "~/projects"
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct the `ServerTransport` for this context. Local contexts get
|
||||
/// a `LocalTransport`; SSH contexts get an `SSHTransport` configured
|
||||
/// from `SSHConfig` by default, OR whatever `sshTransportFactory`
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
|
||||
/// Pre-flight check used before opening an ACP session. Hermes resolves the
|
||||
/// model+provider from `config.yaml` at session boot; on a fresh install that
|
||||
/// file is missing or has neither key set, and the chat fails with an opaque
|
||||
/// "Model parameter is required" 400 from the upstream provider only after the
|
||||
/// user has typed a prompt and hit send. Catching the missing config here lets
|
||||
/// the UI surface a real "pick a model" sheet before any ACP work starts.
|
||||
///
|
||||
/// `HermesConfig.empty` (returned on read failure) and the YAML parser's
|
||||
/// missing-key fallback both use the literal string `"unknown"`, so the check
|
||||
/// has to treat `""` and `"unknown"` as equivalent. Anything else is
|
||||
/// considered configured — we don't try to validate the model against the
|
||||
/// provider's catalog here; that happens later in `ModelPickerSheet`.
|
||||
public enum ModelPreflight: Sendable {
|
||||
public enum Result: Equatable, Sendable {
|
||||
case configured
|
||||
case missingModel
|
||||
case missingProvider
|
||||
case missingBoth
|
||||
|
||||
public var isConfigured: Bool {
|
||||
self == .configured
|
||||
}
|
||||
|
||||
/// Short user-facing reason. Long enough to be honest, short enough
|
||||
/// for a sheet header — full messaging belongs to the picker UI.
|
||||
public var reason: String {
|
||||
switch self {
|
||||
case .configured: return ""
|
||||
case .missingModel: return "No primary model is set in this server's config."
|
||||
case .missingProvider:return "No primary provider is set in this server's config."
|
||||
case .missingBoth: return "No model is configured on this server yet."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Treat `""` and the YAML parser's `"unknown"` fallback as missing.
|
||||
/// Trim whitespace so a stray newline in a hand-edited config.yaml
|
||||
/// doesn't read as "configured."
|
||||
public static func check(_ config: HermesConfig) -> Result {
|
||||
let modelMissing = isUnset(config.model)
|
||||
let providerMissing = isUnset(config.provider)
|
||||
switch (modelMissing, providerMissing) {
|
||||
case (true, true): return .missingBoth
|
||||
case (true, false): return .missingModel
|
||||
case (false, true): return .missingProvider
|
||||
case (false, false): return .configured
|
||||
}
|
||||
}
|
||||
|
||||
private static func isUnset(_ value: String) -> Bool {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespaces).lowercased()
|
||||
return trimmed.isEmpty || trimmed == "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// One Nous Portal model as exposed by `GET /v1/models`. The shape
|
||||
/// mirrors the OpenAI-compatible response schema — Nous's inference
|
||||
/// API uses the same envelope. Optional fields stay optional because
|
||||
/// not every entry includes them; `id` is the only field we strictly
|
||||
/// need (it's what Hermes passes through to the provider).
|
||||
public struct NousModel: Codable, Equatable, Sendable, Identifiable {
|
||||
public let id: String
|
||||
public let owned_by: String?
|
||||
public let created: Int?
|
||||
/// Free-text description if the API ships one. Nous's current
|
||||
/// catalog doesn't include this, but the field is here so future
|
||||
/// shape changes don't drop user-visible context on the floor.
|
||||
public let description: String?
|
||||
|
||||
public init(id: String, owned_by: String? = nil, created: Int? = nil, description: String? = nil) {
|
||||
self.id = id
|
||||
self.owned_by = owned_by
|
||||
self.created = created
|
||||
self.description = description
|
||||
}
|
||||
}
|
||||
|
||||
/// On-disk cache shape. Versioned so a future schema change can lift
|
||||
/// stale caches gracefully — bump `version` and the loader rejects
|
||||
/// anything older without trying to migrate. Stored as JSON next to
|
||||
/// the projects registry so a Hermes wipe takes it with the rest of
|
||||
/// the Scarf-owned state.
|
||||
public struct NousModelsCache: Codable, Sendable {
|
||||
public static let currentVersion = 1
|
||||
public let version: Int
|
||||
public let fetchedAt: Date
|
||||
public let models: [NousModel]
|
||||
|
||||
public init(version: Int = NousModelsCache.currentVersion, fetchedAt: Date, models: [NousModel]) {
|
||||
self.version = version
|
||||
self.fetchedAt = fetchedAt
|
||||
self.models = models
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a `loadModels` call. Distinguishes "fetched fresh from
|
||||
/// the API" from "cache served, network failed" so the picker UI can
|
||||
/// surface a "could not refresh" hint without hiding the cached list.
|
||||
public enum NousModelsLoadResult: Sendable {
|
||||
case fresh(models: [NousModel], fetchedAt: Date)
|
||||
case cache(models: [NousModel], fetchedAt: Date, refreshError: String?)
|
||||
case fallback(models: [NousModel], reason: String)
|
||||
}
|
||||
|
||||
/// Fetches + caches the list of available Nous Portal models. Runs in
|
||||
/// the Scarf process (not on the remote), authenticated with the
|
||||
/// bearer token from `~/.hermes/auth.json` on the active server —
|
||||
/// `NousSubscriptionService` reads that file via the active transport,
|
||||
/// so a remote droplet's token comes back over SSH and the network
|
||||
/// call to Nous still happens from the user's Mac. That's correct:
|
||||
/// we want the model list visible whenever the user has subscription
|
||||
/// credentials, regardless of where Hermes will eventually run the
|
||||
/// chat from.
|
||||
public struct NousModelCatalogService: Sendable {
|
||||
public static let baseURL = URL(string: "https://inference-api.nousresearch.com/v1/models")!
|
||||
public static let cacheTTL: TimeInterval = 24 * 60 * 60 // 24h
|
||||
public static let requestTimeout: TimeInterval = 10 // seconds
|
||||
|
||||
/// Hard-coded fallback for offline-with-no-cache. Short on purpose
|
||||
/// — only the canonical Hermes models (the family the user is most
|
||||
/// likely to want) plus a reminder that fresh data is one
|
||||
/// successful refresh away. Update when Nous releases a new
|
||||
/// flagship; deliberately not exhaustive — the API is the source
|
||||
/// of truth, this just keeps the picker non-empty.
|
||||
public static let fallbackModels: [NousModel] = [
|
||||
NousModel(id: "Hermes-3-Llama-3.1-405B"),
|
||||
NousModel(id: "Hermes-3-Llama-3.1-70B"),
|
||||
NousModel(id: "Hermes-3-Llama-3.1-8B"),
|
||||
NousModel(id: "DeepHermes-3-Llama-3-8B-Preview")
|
||||
]
|
||||
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "NousModelCatalogService")
|
||||
|
||||
public let context: ServerContext
|
||||
private let session: URLSession
|
||||
private let cachePath: String
|
||||
|
||||
public init(context: ServerContext, session: URLSession = .shared) {
|
||||
self.context = context
|
||||
self.session = session
|
||||
self.cachePath = context.paths.nousModelsCache
|
||||
}
|
||||
|
||||
// MARK: - Cache I/O
|
||||
|
||||
/// Read the cache via the active transport (so a remote droplet's
|
||||
/// cache lands on the droplet, not the user's Mac). Missing or
|
||||
/// malformed cache → nil; the loader treats that as "no cache" and
|
||||
/// kicks off a fresh fetch.
|
||||
public func readCache() -> NousModelsCache? {
|
||||
let transport = context.makeTransport()
|
||||
guard transport.fileExists(cachePath) else { return nil }
|
||||
do {
|
||||
let data = try transport.readFile(cachePath)
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
let cache = try decoder.decode(NousModelsCache.self, from: data)
|
||||
guard cache.version == NousModelsCache.currentVersion else {
|
||||
Self.logger.info("nous models cache schema mismatch (got v\(cache.version), expected v\(NousModelsCache.currentVersion)); ignoring")
|
||||
return nil
|
||||
}
|
||||
return cache
|
||||
} catch {
|
||||
Self.logger.warning("couldn't decode nous models cache: \(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func writeCache(_ cache: NousModelsCache) {
|
||||
let transport = context.makeTransport()
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(cache)
|
||||
// Make sure the parent dir exists — fresh remote installs
|
||||
// may not yet have `~/.hermes/scarf/`. mkdir -p is cheap
|
||||
// and idempotent on both transports.
|
||||
let parent = (cachePath as NSString).deletingLastPathComponent
|
||||
if !parent.isEmpty {
|
||||
try? transport.createDirectory(parent)
|
||||
}
|
||||
try transport.writeFile(cachePath, data: data)
|
||||
} catch {
|
||||
Self.logger.warning("couldn't write nous models cache: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
public func isCacheStale(_ cache: NousModelsCache) -> Bool {
|
||||
Date().timeIntervalSince(cache.fetchedAt) > Self.cacheTTL
|
||||
}
|
||||
|
||||
// MARK: - Network fetch
|
||||
|
||||
/// Read the bearer token from `auth.json` on the active server.
|
||||
/// Returns nil when the user isn't signed in to Nous, in which
|
||||
/// case `loadModels` skips the network call and falls through to
|
||||
/// cache or fallback.
|
||||
private func bearerToken() -> String? {
|
||||
// The subscription service already checks for `present`; we
|
||||
// re-read the raw token here because we need the actual string,
|
||||
// not just a Bool. Mirrors the SubscriptionService parse path.
|
||||
let transport = context.makeTransport()
|
||||
guard transport.fileExists(context.paths.authJSON) else { return nil }
|
||||
guard let data = try? transport.readFile(context.paths.authJSON) else { return nil }
|
||||
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||
let providers = root["providers"] as? [String: Any] ?? [:]
|
||||
let nous = providers["nous"] as? [String: Any]
|
||||
let token = nous?["access_token"] as? String
|
||||
guard let token, !token.isEmpty else { return nil }
|
||||
return token
|
||||
}
|
||||
|
||||
/// Make the API call. Times out after `requestTimeout` so a hung
|
||||
/// network doesn't block the picker indefinitely. Returns the raw
|
||||
/// `[NousModel]` on success, throws on any HTTP / decode error so
|
||||
/// the caller can log + fall back.
|
||||
public func fetchModels() async throws -> [NousModel] {
|
||||
guard let token = bearerToken() else {
|
||||
throw NousModelCatalogError.notAuthenticated
|
||||
}
|
||||
var request = URLRequest(url: Self.baseURL)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = Self.requestTimeout
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw NousModelCatalogError.transport("non-HTTP response")
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw NousModelCatalogError.http(status: http.statusCode)
|
||||
}
|
||||
struct Envelope: Decodable { let data: [NousModel] }
|
||||
let envelope = try JSONDecoder().decode(Envelope.self, from: data)
|
||||
return envelope.data
|
||||
}
|
||||
|
||||
// MARK: - Public entry
|
||||
|
||||
/// Top-level "give me models" entry point. Cache-first: serve from
|
||||
/// cache if fresh, fetch + write through if stale or empty, fall
|
||||
/// back to the hard-coded list when both fail. The caller renders
|
||||
/// based on the case so it can show a "could not refresh" hint
|
||||
/// next to a stale-but-still-useful list.
|
||||
public func loadModels(forceRefresh: Bool = false) async -> NousModelsLoadResult {
|
||||
let cached = readCache()
|
||||
|
||||
if let cached, !forceRefresh, !isCacheStale(cached) {
|
||||
return .cache(models: cached.models, fetchedAt: cached.fetchedAt, refreshError: nil)
|
||||
}
|
||||
|
||||
do {
|
||||
let models = try await fetchModels()
|
||||
let now = Date()
|
||||
writeCache(NousModelsCache(fetchedAt: now, models: models))
|
||||
return .fresh(models: models, fetchedAt: now)
|
||||
} catch let error as NousModelCatalogError {
|
||||
// Fetch failed but we may still have *something* useful.
|
||||
if let cached {
|
||||
return .cache(
|
||||
models: cached.models,
|
||||
fetchedAt: cached.fetchedAt,
|
||||
refreshError: error.userMessage
|
||||
)
|
||||
}
|
||||
return .fallback(models: Self.fallbackModels, reason: error.userMessage)
|
||||
} catch {
|
||||
if let cached {
|
||||
return .cache(
|
||||
models: cached.models,
|
||||
fetchedAt: cached.fetchedAt,
|
||||
refreshError: error.localizedDescription
|
||||
)
|
||||
}
|
||||
return .fallback(models: Self.fallbackModels, reason: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum NousModelCatalogError: Error, Sendable {
|
||||
case notAuthenticated
|
||||
case http(status: Int)
|
||||
case transport(String)
|
||||
|
||||
public var userMessage: String {
|
||||
switch self {
|
||||
case .notAuthenticated:
|
||||
return "Sign in to Nous Portal to fetch the latest model list."
|
||||
case .http(let status) where status == 401:
|
||||
return "Nous rejected the saved token (401). Sign in again."
|
||||
case .http(let status):
|
||||
return "Nous returned HTTP \(status)."
|
||||
case .transport(let detail):
|
||||
return "Couldn't reach Nous: \(detail)."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user