Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Multi-model LLM load balancing.** Shared load balancer that spreads AI work
across every enabled, healthy model using a least-busy policy with health-gated
failover. Three independent, persisted toggles compose the pool: all local
(Ollama + MLX), all frontier (OpenRouter, key in the macOS Keychain), and the
optional Nova Gateway. Nova is never required — the feature works with zero Nova.
New settings pane under Settings → AI Assist.
- **Describe-it-in-English rsync.** New field in the Job Editor's Basic tab: describe
a sync in plain English and the balanced LLM proposes a concrete rsync command.
The command is shown for review and pre-fills the builder on explicit action — it
is never auto-executed.
- **`parseRsyncSuggestion` output validator.** Pure, network-free sanitizer that
extracts only a valid rsync invocation and rejects shell chaining, command
substitution, redirection, non-rsync programs, and program-executing rsync flags
(`-e` / `--rsh` / `--rsync-path`) via a strict allow-list. Hardened with unit tests
covering injection rejection, clean parse, and the graceful no-backend path.

### Changed
- App sandbox confirmed disabled for the app target (Hardened Runtime / notarization
retained); required for local backend discovery and process-based MLX inference.

### Planned
- Performance improvements
- Additional features based on community feedback
Expand Down
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ A professional macOS GUI for rsync with real-time progress, AI-powered insights,
| SSH remote sync | Public key authentication with Keychain credential storage, connection testing, and key path validation |
| iCloud Drive sync | One-click iCloud destination setup with automatic `.icloud` placeholder exclusion |
| AI insights (10 features) | Error diagnosis, change summary, anomaly detection, smart scheduling, storage prediction, exclusion suggestions, NLP job creation, health scoring, recovery assistant, sensitive file detection |
| Multi-model load balancing | Spread AI work across every enabled, healthy model (local Ollama + MLX, frontier OpenRouter, optional Nova Gateway) with a least-busy policy and health-gated failover. Three independent toggles; Nova is never required |
| Describe-it-in-English rsync | Type your intent in plain English and a balanced LLM proposes a concrete rsync command. It is shown for review and pre-fills the builder — it is never auto-executed |
| Desktop widget | WidgetKit extension (Small / Medium / Large) showing health score, last sync, next sync, and recent activity |
| Menu bar integration | Status bar icon with quick job access and window toggle |
| Pre/post sync scripts | Run custom scripts with environment variables (JOB_NAME, JOB_STATUS, FILES_TRANSFERRED); only absolute paths accepted |
Expand Down Expand Up @@ -138,6 +140,62 @@ sequenceDiagram

---

## Multi-Model Load Balancing & Describe-it-in-English rsync

RsyncGUI ships the shared multi-model LLM load balancer. AI work (including the
natural-language rsync assistant) is spread across every **enabled, healthy** model
using a least-busy policy — the single-user version of how Nova's gateway balances
load. Three independent toggles compose the pool, and each backend is health-gated
so an unreachable one is simply skipped:

- **All local** — every discovered Ollama model (`/api/tags`) plus locally-installed MLX models.
- **All frontier** — OpenRouter models (bring-your-own-key, stored in the macOS Keychain).
- **Nova Gateway** — *optional* OpenAI-compatible backend at `http://127.0.0.1:18792`
(health on `/v1/models`). A failed health check just marks it unavailable; everything
else keeps working.

> Nova is **never** a hard requirement. With zero Nova the feature works on local
> models and/or an OpenRouter key alone. There is no dependency on Nova, PostgreSQL,
> or the gateway.

### Describe it in English

The Job Editor's **Basic** tab has a "Describe it in English (AI)" field. Type an
intent — for example *"mirror Photos to the NAS, skip video files, delete extras on
the destination"* — and the balanced LLM returns a concrete rsync command. It is
surfaced for **review** and, on an explicit click, pre-fills the command builder.

**Safety:** rsync is destructive (`--delete`), so the generated command is **never
run automatically**. A pure, network-free validator (`parseRsyncSuggestion`) extracts
*only* a valid rsync invocation and rejects everything else — shell chaining
(`;` `&&` `|`), command substitution (`` ` `` `$()`), redirection (`>` `<`), non-rsync
programs (`rm`, `sudo`, `cp`), and any program-executing rsync flag (`-e`, `--rsh`,
`--rsync-path`) via a strict flag allow-list. If no backend is enabled the feature
disables itself with a clear reason rather than failing.

```mermaid
graph TD
U["User intent (plain English)"] --> PB["RsyncPromptBuilder<br/>(pure, network-free)"]
PB --> LB["LLMLoadBalancerService"]

subgraph Pool["Enabled + health-gated pool"]
OL["Ollama (local)"]
MLX["MLX (local)"]
OR["OpenRouter (frontier)"]
NG["Nova Gateway (optional)"]
end

LB -->|"LoadBalancer.next()<br/>least-busy"| Pool
Pool -->|"raw LLM text"| PV["parseRsyncSuggestion()<br/>strict validator / sanitizer"]
PV -->|"rejected: injection / non-rsync"| X["Discarded, nothing shown"]
PV -->|"clean rsync only"| RC["RsyncCommand"]
RC --> RV["Review card (read-only)"]
RV -->|"explicit Apply"| CB["Command builder pre-filled"]
CB -.->|"user starts the job themselves"| RUN["rsync runs"]
```

---

## Fixes in 1.7.4

**Destination path editing (issue #4).** Browsing to a destination and clicking
Expand Down
40 changes: 40 additions & 0 deletions RsyncGUI.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@
W1DGET1MAINSRC1BLD123456 /* RsyncGUIWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = W1DGET1MAINSRC1FILE123456 /* RsyncGUIWidget.swift */; };
W1DGET1SHARED1BLD1234567 /* SharedDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = W1DGET1SHARED1FILE1234567 /* SharedDataManager.swift */; };
W1DGETDATASYNC1BLDFILE12 /* WidgetDataSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = W1DGETDATASYNC1FILEREF12 /* WidgetDataSync.swift */; };
MODELREG1BLDFILE12345678 /* ModelRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = MODELREG1FILEREF12345678 /* ModelRegistry.swift */; };
OPENROUTER1BLDFILE123456 /* OpenRouterProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = OPENROUTER1FILEREF123456 /* OpenRouterProvider.swift */; };
KEYCHAIN1BLDFILE12345678 /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = KEYCHAIN1FILEREF12345678 /* KeychainStore.swift */; };
LLMBALANCE1BLDFILE123456 /* LLMLoadBalancerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = LLMBALANCE1FILEREF123456 /* LLMLoadBalancerService.swift */; };
RSYNCSUGG1BLDFILE1234567 /* RsyncSuggestionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = RSYNCSUGG1FILEREF1234567 /* RsyncSuggestionService.swift */; };
LLMBTYPE1BLDFILE12345678 /* LLMBackendType.swift in Sources */ = {isa = PBXBuildFile; fileRef = LLMBTYPE1FILEREF12345678 /* LLMBackendType.swift */; };
LLMSUPPORT1BLDFILE123456 /* LLMSupportTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = LLMSUPPORT1FILEREF123456 /* LLMSupportTypes.swift */; };
LLMASSISTV1BLDFILE123456 /* LLMAssistViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = LLMASSISTV1FILEREF123456 /* LLMAssistViews.swift */; };
LBTESTS1BLDFILE123456789 /* LoadBalancerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = LBTESTS1FILEREF123456789 /* LoadBalancerTests.swift */; };
RSTESTS1BLDFILE123456789 /* RsyncSuggestionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = RSTESTS1FILEREF123456789 /* RsyncSuggestionTests.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -156,6 +166,16 @@
W1DGET1MAINSRC1FILE123456 /* RsyncGUIWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RsyncGUIWidget.swift; sourceTree = "<group>"; };
W1DGET1SHARED1FILE1234567 /* SharedDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedDataManager.swift; sourceTree = "<group>"; };
W1DGETDATASYNC1FILEREF12 /* WidgetDataSync.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WidgetDataSync.swift; path = RsyncGUI/Services/WidgetDataSync.swift; sourceTree = "<group>"; };
MODELREG1FILEREF12345678 /* ModelRegistry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ModelRegistry.swift; path = RsyncGUI/Services/ModelRegistry.swift; sourceTree = "<group>"; };
OPENROUTER1FILEREF123456 /* OpenRouterProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OpenRouterProvider.swift; path = RsyncGUI/Services/OpenRouterProvider.swift; sourceTree = "<group>"; };
KEYCHAIN1FILEREF12345678 /* KeychainStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = KeychainStore.swift; path = RsyncGUI/Services/KeychainStore.swift; sourceTree = "<group>"; };
LLMBALANCE1FILEREF123456 /* LLMLoadBalancerService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LLMLoadBalancerService.swift; path = RsyncGUI/Services/LLMLoadBalancerService.swift; sourceTree = "<group>"; };
RSYNCSUGG1FILEREF1234567 /* RsyncSuggestionService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RsyncSuggestionService.swift; path = RsyncGUI/Services/RsyncSuggestionService.swift; sourceTree = "<group>"; };
LLMBTYPE1FILEREF12345678 /* LLMBackendType.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LLMBackendType.swift; path = RsyncGUI/Models/LLMBackendType.swift; sourceTree = "<group>"; };
LLMSUPPORT1FILEREF123456 /* LLMSupportTypes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LLMSupportTypes.swift; path = RsyncGUI/Models/LLMSupportTypes.swift; sourceTree = "<group>"; };
LLMASSISTV1FILEREF123456 /* LLMAssistViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LLMAssistViews.swift; path = RsyncGUI/Views/LLMAssistViews.swift; sourceTree = "<group>"; };
LBTESTS1FILEREF123456789 /* LoadBalancerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadBalancerTests.swift; sourceTree = "<group>"; };
RSTESTS1FILEREF123456789 /* RsyncSuggestionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RsyncSuggestionTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -194,6 +214,8 @@
FB5B2B02DFC13B7EA918212F /* SyncJob.swift */,
4AB5A4475246EFD82B180AB5 /* ConnectionTest.swift */,
6078B9C270C3DFB1F180CA3F /* ExecutionHistory.swift */,
LLMBTYPE1FILEREF12345678 /* LLMBackendType.swift */,
LLMSUPPORT1FILEREF123456 /* LLMSupportTypes.swift */,
);
name = Models;
sourceTree = "<group>";
Expand Down Expand Up @@ -262,6 +284,11 @@
0D721D714FA57020723CD52D /* ScheduleManager.swift */,
0501948699D7168C7FAFCDDC /* MenuBarManager.swift */,
W1DGETDATASYNC1FILEREF12 /* WidgetDataSync.swift */,
MODELREG1FILEREF12345678 /* ModelRegistry.swift */,
OPENROUTER1FILEREF123456 /* OpenRouterProvider.swift */,
KEYCHAIN1FILEREF12345678 /* KeychainStore.swift */,
LLMBALANCE1FILEREF123456 /* LLMLoadBalancerService.swift */,
RSYNCSUGG1FILEREF1234567 /* RsyncSuggestionService.swift */,
);
name = Services;
sourceTree = "<group>";
Expand Down Expand Up @@ -298,6 +325,7 @@
41893A1D4E512F8F7C9BAC87 /* DeltaReportView.swift */,
F670173A93CD07D603A51D71 /* ExecutionHistoryView.swift */,
JHTVF123456789ABCDEF1234 /* JobHistoryTabView.swift */,
LLMASSISTV1FILEREF123456 /* LLMAssistViews.swift */,
);
name = Views;
sourceTree = "<group>";
Expand Down Expand Up @@ -330,6 +358,8 @@
TEST1WIDGETDT1FILEREF1234 /* WidgetDataTests.swift */,
TEST1NOVAAPI1FILEREF12345 /* NovaAPITests.swift */,
A5031762659B84C5717A6268 /* CommandBuilderTests.swift */,
LBTESTS1FILEREF123456789 /* LoadBalancerTests.swift */,
RSTESTS1FILEREF123456789 /* RsyncSuggestionTests.swift */,
);
path = RsyncGUITests;
sourceTree = "<group>";
Expand Down Expand Up @@ -509,6 +539,14 @@
445A92C3E85B0445DD3CE0F5 /* AIBackendManager+Enhanced.swift in Sources */,
A39615CCF66F3666DDCE7573 /* AIBackendStatusMenu.swift in Sources */,
DA4CAC4B477003225AE0008D /* NovaAPIServer.swift in Sources */,
MODELREG1BLDFILE12345678 /* ModelRegistry.swift in Sources */,
OPENROUTER1BLDFILE123456 /* OpenRouterProvider.swift in Sources */,
KEYCHAIN1BLDFILE12345678 /* KeychainStore.swift in Sources */,
LLMBALANCE1BLDFILE123456 /* LLMLoadBalancerService.swift in Sources */,
RSYNCSUGG1BLDFILE1234567 /* RsyncSuggestionService.swift in Sources */,
LLMBTYPE1BLDFILE12345678 /* LLMBackendType.swift in Sources */,
LLMSUPPORT1BLDFILE123456 /* LLMSupportTypes.swift in Sources */,
LLMASSISTV1BLDFILE123456 /* LLMAssistViews.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand All @@ -533,6 +571,8 @@
TEST1WIDGETDT1BLDFILE1234 /* WidgetDataTests.swift in Sources */,
TEST1NOVAAPI1BLDFILE12345 /* NovaAPITests.swift in Sources */,
D3BAA9CF67D89D0E88613C7D /* CommandBuilderTests.swift in Sources */,
LBTESTS1BLDFILE123456789 /* LoadBalancerTests.swift in Sources */,
RSTESTS1BLDFILE123456789 /* RsyncSuggestionTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
97 changes: 97 additions & 0 deletions RsyncGUI/Models/LLMBackendType.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//
// LLMBackendType.swift
// AIStudio
//
// Created by Jordan Koch on 2026-02-19.
// Copyright © 2026 Jordan Koch. All rights reserved.
//

import Foundation

/// LLM backend type identifier
enum LLMBackendType: String, CaseIterable, Codable, Sendable {
case ollama = "ollama"
case mlx = "mlx"
case tinyLLM = "tinyllm"
case tinyChat = "tinychat"
case openWebUI = "openwebui"
case openRouter = "openrouter"
case novaGateway = "novagateway"
case auto = "auto"

var displayName: String {
switch self {
case .ollama: return "Ollama"
case .mlx: return "MLX Native"
case .tinyLLM: return "TinyLLM"
case .tinyChat: return "TinyChat"
case .openWebUI: return "OpenWebUI"
case .openRouter: return "OpenRouter (Frontier Models)"
case .novaGateway: return "Nova Gateway"
case .auto: return "Auto (Prefer Ollama)"
}
}

var icon: String {
switch self {
case .ollama: return "network"
case .mlx: return "cpu"
case .tinyLLM: return "cube"
case .tinyChat: return "bubble.left.and.bubble.right.fill"
case .openWebUI: return "globe"
case .openRouter: return "cloud"
case .novaGateway: return "sparkle.magnifyingglass"
case .auto: return "sparkles"
}
}

var defaultURL: String {
switch self {
case .ollama: return "http://localhost:11434"
case .mlx: return ""
case .tinyLLM: return "http://localhost:8000"
case .tinyChat: return "http://localhost:8000"
case .openWebUI: return "http://localhost:8080"
case .openRouter: return OpenRouterProvider.baseURL
case .novaGateway: return ModelRegistry.novaGatewayDefaultURL
case .auto: return ""
}
}

var description: String {
switch self {
case .ollama: return "HTTP-based LLM API (localhost:11434)"
case .mlx: return "Apple Silicon native inference via MLX"
case .tinyLLM: return "TinyLLM lightweight server (localhost:8000)"
case .tinyChat: return "TinyChat by Jason Cox (localhost:8000)"
case .openWebUI: return "Self-hosted AI platform (localhost:8080)"
case .openRouter: return "Frontier cloud models via OpenRouter (bring your own key)"
case .novaGateway: return "Nova's gateway — OpenAI-compatible, inherits Nova's own routing (127.0.0.1:18792)"
case .auto: return "Automatically choose best available backend"
}
}

var attribution: String? {
switch self {
case .tinyLLM: return "TinyLLM by Jason Cox (https://github.com/jasonacox/TinyLLM)"
case .tinyChat: return "TinyChat by Jason Cox (https://github.com/jasonacox/tinychat)"
case .openWebUI: return "OpenWebUI Community Project (https://github.com/open-webui/open-webui)"
default: return nil
}
}
}

/// Configuration for a single LLM backend
struct LLMBackendConfiguration: Identifiable, Sendable {
let id: UUID
let type: LLMBackendType
var url: String
var status: BackendStatus

init(type: LLMBackendType, url: String? = nil) {
self.id = UUID()
self.type = type
self.url = url ?? type.defaultURL
self.status = .disconnected
}
}
91 changes: 91 additions & 0 deletions RsyncGUI/Models/LLMSupportTypes.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//
// LLMSupportTypes.swift
// RsyncGUI
//
// Supporting value types for the multi-model LLM load balancer.
// These mirror the small AIStudio types that the verbatim-shared services
// (ModelRegistry / OpenRouterProvider / KeychainStore / LLMBackendType) depend
// on, so the pure/network-free pieces port over unchanged.
//
// Author: Jordan Koch
//

import Foundation

// MARK: - Backend connection status

/// Connection status for a single LLM backend.
enum BackendStatus: Sendable, Equatable {
case connected
case disconnected
case checking
case error(String)

var displayText: String {
switch self {
case .connected: return "Connected"
case .disconnected: return "Disconnected"
case .checking: return "Checking..."
case .error(let msg): return "Error: \(msg)"
}
}

var isConnected: Bool {
if case .connected = self { return true }
return false
}
}

// MARK: - Chat message shape

/// Role in a chat conversation (OpenAI-compatible roles).
enum ChatRole: String, Codable, Sendable {
case system
case user
case assistant
}

/// A single chat message passed to the OpenAI-compatible request builders.
struct ChatMessage: Identifiable, Codable, Sendable {
let id: UUID
let role: ChatRole
var content: String
let timestamp: Date

init(role: ChatRole, content: String) {
self.id = UUID()
self.role = role
self.content = content
self.timestamp = Date()
}
}

// MARK: - LLM errors

/// Errors surfaced by the load-balanced LLM path. `noBackendAvailable` is the
/// signal the natural-language rsync feature uses to disable itself gracefully.
enum LLMError: LocalizedError, Sendable {
case noBackendAvailable
case invalidURL
case invalidResponse
case httpError(Int)
case noResponse
case mlxNotAvailable

var errorDescription: String? {
switch self {
case .noBackendAvailable:
return "No LLM backend is available. Start Ollama, add an OpenRouter key, or enable the Nova Gateway."
case .invalidURL:
return "Invalid backend URL configuration."
case .invalidResponse:
return "Received an invalid response from the LLM backend."
case .httpError(let code):
return "HTTP error \(code) from the LLM backend."
case .noResponse:
return "No response received from the LLM backend."
case .mlxNotAvailable:
return "MLX not available. Install with: pip install mlx-lm"
}
}
}
Loading