diff --git a/.gitignore b/.gitignore index a3bca706..71b64ec4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,11 @@ node_modules/ web/dist/ internal/server/dist/* +# SwiftPM +macos/**/.build/ +macos/**/.swiftpm/ +/macos/build/ + # Dev-only UI prototypes (not shipped). See web/src/App.tsx SampleRoutes. web/src/sample/ diff --git a/HANDOFF.md b/HANDOFF.md index fd2ede5f..f15825b8 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -3,6 +3,15 @@ Last updated: 2026-08-08. Branch: `main`. Working tree: clean. **11 commits are committed locally but NOT pushed** (`dff21d7`..`0119b7a`). +## Current state (2026-09-18) + +macOS has an optional native SwiftUI dashboard in `macos/` (`make macos`): +`AntaresKit` plus the `Antares` SwiftUI app, talking HTTP/SSE to `:8787`. +The Mac app is optional; the CLI `antares` still serves the embedded React +dashboard for Linux, Windows, and anyone who does not build the app. The Go +backend was not rewritten. Session log below is from 2026-08-08 and is +otherwise unchanged. + ## Goal Ongoing maintenance of Antares (Go backend + React/Vite dashboard in `web/`). diff --git a/Makefile b/Makefile index 273bb54c..88aa7938 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,7 @@ tool = $(shell command -v $(1) 2>/dev/null || echo $(2)) GO ?= $(call tool,go,$(HOME)/.local/sdk/go/bin/go) BUN ?= $(call tool,bun,$(HOME)/.bun/bin/bun) +SWIFT ?= $(call tool,swift,/usr/bin/swift) # air lives in GOPATH/bin after `make install`; if GOPATH can't be read, use the # conventional ~/go. GOPATH ?= $(shell $(GO) env GOPATH 2>/dev/null || echo $(HOME)/go) @@ -20,6 +21,9 @@ LDFLAGS := -s -w \ -X github.com/enowdev/antares/internal/version.Version=$(VERSION) \ -X github.com/enowdev/antares/internal/version.Commit=$(COMMIT) \ -X github.com/enowdev/antares/internal/version.Date=$(DATE) +MACOS_APP := macos/build/Build/Products/Release/Antares.app +MACOS_APP_CONTENTS := $(MACOS_APP)/Contents +MACOS_APP_MACOS := $(MACOS_APP_CONTENTS)/MacOS .PHONY: help help: ## Show this help @@ -81,6 +85,18 @@ build-fixture: ## Build the smoke-only fixture helper (cmd/smokefixture) build-api: ## Build the backend only (no dashboard) @$(GO) build -trimpath -ldflags "$(LDFLAGS)" -o bin/antares ./cmd/antares +.PHONY: macos +macos: build ## Build Antares.app with the darwin antares binary inside + @test "$$(uname -s)" = Darwin || (echo "macos target requires Darwin" && exit 1) + @$(SWIFT) build -c release --package-path macos/Antares + @rm -rf "$(MACOS_APP)" + @mkdir -p "$(MACOS_APP_MACOS)" + @cp macos/Antares/.build/release/Antares "$(MACOS_APP_MACOS)/AntaresApp" + @cp bin/antares "$(MACOS_APP_MACOS)/antares" + @cp macos/Antares/Info.plist "$(MACOS_APP_CONTENTS)/Info.plist" + @chmod +x "$(MACOS_APP_MACOS)/AntaresApp" "$(MACOS_APP_MACOS)/antares" + @echo "built $(MACOS_APP)" + .PHONY: install-cli install-cli: build ## Build (with dashboard) and install `antares` to $(PREFIX)/bin @mkdir -p $(PREFIX)/bin @@ -139,6 +155,7 @@ release: ## Cross-compile release binaries for all platforms into dist/release .PHONY: clean clean: ## Remove build artefacts + @rm -rf bin .air web/dist dist macos/build @rm -rf bin .air web/dist dist desktop/bin @echo "cleaned" diff --git a/README.md b/README.md index 7a90209a..101a03c5 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,10 @@ antares setup # configure it, in the browser or the terminal Runs on **Linux**, **macOS**, and **Windows**. +Mac operators can also build an optional native dashboard companion with `make macos`. +The Mac app is optional; the CLI `antares` still serves the web UI for other +platforms. + --- ## A look at it diff --git a/docs/architecture.md b/docs/architecture.md index c5c97821..1bc4b21a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -23,6 +23,7 @@ internal/ wsutil/ a minimal RFC 6455 client config/ layered configuration and its schema web/ the React dashboard +macos/ AntaresKit + Antares SwiftUI companion (HTTP/SSE to :8787) ``` ## The turn @@ -81,6 +82,8 @@ everywhere and adding one is a single registration. **The dashboard embedded.** `make build` copies `web/dist` into the binary. One file to copy anywhere. In development Vite serves the UI and proxies the API. +On macOS, `make macos` can also build an optional native SwiftUI companion; it +does not replace the Go process or the embedded web dashboard. ## The dashboard @@ -92,6 +95,12 @@ load only when selected. Development sample routes are excluded from production. Built with React 19, Vite, Tailwind v4 with oklch tokens, Radix primitives, and Phosphor icons. Five languages, English by default. +macOS has an optional native companion in `macos/`: `AntaresKit` (HTTP, SSE, +process supervisor) plus the `Antares` SwiftUI app. It talks only to the +existing Go API at `http://127.0.0.1:8787`. The Mac app is optional; the CLI +`antares` still serves the web UI for other platforms. The Go backend is +unchanged. + ## Streaming Events flow the same way to every surface: diff --git a/docs/getting-started.md b/docs/getting-started.md index 34de5ed6..67079212 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -25,6 +25,21 @@ make build # one binary with the dashboard inside it ./bin/antares ``` +## macOS app + +The Mac app is optional. Building it requires Xcode or the Swift toolchain. +The CLI `antares` and `antares tui` are unchanged: they still start the Go +backend and serve the embedded web dashboard on every platform, including +Linux and Windows. + +```bash +make macos +open macos/build/Build/Products/Release/Antares.app +``` + +The app starts `antares --foreground` if nothing is listening on :8787. +Linux and Windows still use the web dashboard. + The first run has nothing configured, so it drops straight into setup: ``` diff --git a/docs/superpowers/plans/2026-09-18-macos-swiftui-dashboard.md b/docs/superpowers/plans/2026-09-18-macos-swiftui-dashboard.md new file mode 100644 index 00000000..72e407a3 --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-macos-swiftui-dashboard.md @@ -0,0 +1,780 @@ +# macOS SwiftUI Dashboard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a native macOS SwiftUI app that launches or attaches to the existing Go `antares` backend and covers every current dashboard page without wrapping the React SPA. + +**Architecture:** `macos/AntaresKit` (testable HTTP/SSE/process supervisor) + `macos/Antares` (SwiftUI). The app bundles `bin/antares` and talks only to `http://127.0.0.1:8787`. Web dashboard remains embedded for Linux/Windows. + +**Tech Stack:** Swift 6, macOS 14, SwiftUI `NavigationSplitView`, `URLSession`, XCTest via `swift test`, existing Go API (`internal/server/routes.go`). + +**Spec:** `docs/superpowers/specs/2026-09-18-macos-swiftui-dashboard-design.md` + +--- + +## File map + +``` +macos/AntaresKit/Package.swift +macos/AntaresKit/Sources/AntaresKit/ + APIClient.swift JSON GET/POST, ApiError, bearer + cookies + SSEClient.swift byte-accurate port of web/src/lib/sse.ts + RuntimeSupervisor.swift health probe, spawn --foreground, stop child + ChatEvents.swift decode chat SSE payloads + Models.swift shared Codable DTOs used by several pages +macos/AntaresKit/Tests/AntaresKitTests/ + SSEClientTests.swift + APIClientTests.swift + RuntimeSupervisorTests.swift + ChatEventsTests.swift +macos/Antares/Antares/ + AntaresApp.swift + AppModel.swift owns supervisor + client, login/setup gate + LaunchView.swift + LoginView.swift + SetupView.swift + AppShell.swift NavigationSplitView + sidebar + Pages/*.swift one file per routeManifest id +macos/Antares/Antares.xcodeproj generated or checked in; Kit is a local package +Makefile add `macos` target +docs/getting-started.md Mac app section +``` + +Do not delete `web/`. Do not add WKWebView. + +--- + +### Task 1: AntaresKit package skeleton + +**Files:** +- Create: `macos/AntaresKit/Package.swift` +- Create: `macos/AntaresKit/Sources/AntaresKit/AntaresKit.swift` +- Create: `macos/AntaresKit/Tests/AntaresKitTests/AntaresKitTests.swift` + +- [ ] **Step 1: Write Package.swift** + +```swift +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "AntaresKit", + platforms: [.macOS(.v14)], + products: [.library(name: "AntaresKit", targets: ["AntaresKit"])], + targets: [ + .target(name: "AntaresKit"), + .testTarget(name: "AntaresKitTests", dependencies: ["AntaresKit"]), + ] +) +``` + +Empty `AntaresKit.swift`: + +```swift +public enum AntaresKit { + public static let defaultBaseURL = URL(string: "http://127.0.0.1:8787")! +} +``` + +- [ ] **Step 2: Write a failing test** + +```swift +import XCTest +@testable import AntaresKit + +final class AntaresKitTests: XCTestCase { + func testDefaultBaseURLIsLoopback8787() { + XCTAssertEqual(AntaresKit.defaultBaseURL.absoluteString, "http://127.0.0.1:8787") + } +} +``` + +- [ ] **Step 3: Run tests** + +```bash +cd macos/AntaresKit && swift test +``` + +Expected: PASS (skeleton). If the URL constant is missing, FAIL then add it. + +- [ ] **Step 4: Commit** + +```bash +git add macos/AntaresKit +git commit -m "feat(macos): add AntaresKit Swift package skeleton" +``` + +--- + +### Task 2: SSE parser (TDD) + +**Files:** +- Create: `macos/AntaresKit/Sources/AntaresKit/SSEClient.swift` +- Create: `macos/AntaresKit/Tests/AntaresKitTests/SSEClientTests.swift` + +Port semantics from `web/src/lib/sse.ts`. + +- [ ] **Step 1: Write the failing test** + +```swift +import XCTest +@testable import AntaresKit + +final class SSEClientTests: XCTestCase { + func testDispatchesJSONOnBlankLineAndStripsLeadingSpace() throws { + let raw = "data: {\"delta\":\"Hel\"}\n\ndata: {\"delta\":\"lo\"}\r\n\r\n" + var events: [[String: String]] = [] + try SSEParser.parse(Data(raw.utf8)) { json in + events.append(json) + } + XCTAssertEqual(events, [["delta": "Hel"], ["delta": "lo"]]) + } + + func testIgnoresCommentsAndDoneSentinel() throws { + let raw = ": keep-alive\n\ndata: [DONE]\n\ndata: {\"ok\":true}\n\n" + var count = 0 + try SSEParser.parse(Data(raw.utf8)) { _ in count += 1 } + XCTAssertEqual(count, 1) + } + + func testDropsUnfinishedTrailingRecord() throws { + let raw = "data: {\"a\":1}\n\ndata: {\"b\":2}" + var count = 0 + try SSEParser.parse(Data(raw.utf8)) { _ in count += 1 } + XCTAssertEqual(count, 1) + } +} +``` + +- [ ] **Step 2: Run to confirm RED** + +```bash +cd macos/AntaresKit && swift test --filter SSEClientTests +``` + +Expected: FAIL, `SSEParser` not found. + +- [ ] **Step 3: Implement `SSEParser`** + +```swift +import Foundation + +public enum SSEParser { + public static func parse(_ data: Data, onEvent: ([String: Any]) throws -> Void) throws { + var buffer = String(decoding: data, as: UTF8.self) + if buffer.hasSuffix("\r") { buffer.removeLast() } + var dataLines: [String] = [] + // Split on \r\n | \n | \r. Walk character-by-character matching sse.ts. + var lineStart = buffer.startIndex + var i = buffer.startIndex + func flushLine(_ line: Substring) throws { + if line.hasPrefix(":") { return } + if line.isEmpty { + let payload = dataLines.joined(separator: "\n") + dataLines = [] + guard !payload.isEmpty, payload != "[DONE]" else { return } + let obj = try JSONSerialization.jsonObject(with: Data(payload.utf8)) + guard let dict = obj as? [String: Any] else { return } + try onEvent(dict) + return + } + if line.hasPrefix("data:") { + var v = String(line.dropFirst(5)) + if v.first == " " { v.removeFirst() } + dataLines.append(v) + } + } + while i < buffer.endIndex { + let ch = buffer[i] + if ch == "\r" { + try flushLine(buffer[lineStart.. URL { + URL(string: path, relativeTo: baseURL)! + } + + public func get(_ path: String) async throws -> T { try await send(path, method: "GET", body: nil as Data?) } + public func post(_ path: String, body: B) async throws -> T { + try await send(path, method: "POST", body: JSONEncoder().encode(body)) + } + + private func send(_ path: String, method: String, body: Data?) async throws -> T { + var req = URLRequest(url: url(path)) + req.httpMethod = method + req.httpBody = body + if body != nil { req.setValue("application/json", forHTTPHeaderField: "Content-Type") } + if let bearerToken, !bearerToken.isEmpty { + req.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") + } + let (data, resp) = try await session.data(for: req) + let status = (resp as? HTTPURLResponse)?.statusCode ?? 0 + guard (200..<300).contains(status) else { throw APIError(status: status, body: data) } + if T.self == Empty.self { return Empty() as! T } + return try JSONDecoder().decode(T.self, from: data) + } +} + +public struct Empty: Decodable {} +``` + +Health GET can decode `[String: String]` or a small `Health` struct `{"ok":true}` — inspect `handleHealth` and match the real JSON. + +- [ ] **Step 4: GREEN + commit** + +```bash +cd macos/AntaresKit && swift test --filter APIClientTests +git add macos/AntaresKit +git commit -m "feat(macos): add AntaresKit HTTP client and error envelope" +``` + +--- + +### Task 4: Runtime supervisor (spawn vs attach) + +**Files:** +- Create: `macos/AntaresKit/Sources/AntaresKit/RuntimeSupervisor.swift` +- Create: `macos/AntaresKit/Tests/AntaresKitTests/RuntimeSupervisorTests.swift` + +Decision table only in unit tests (do not spawn a real binary here). + +- [ ] **Step 1: Failing tests** + +```swift +func testHealthyServerMeansAttach() { + XCTAssertEqual(RuntimePlan.decide(healthOK: true, portBusy: false), .attach) +} + +func testUnhealthyAndFreePortMeansSpawn() { + XCTAssertEqual(RuntimePlan.decide(healthOK: false, portBusy: false), .spawn) +} + +func testUnhealthyBusyPortIsError() { + XCTAssertEqual(RuntimePlan.decide(healthOK: false, portBusy: true), .conflict) +} + +func testSpawnedChildIsStoppedOnShutdownAttachedIsNot() { + XCTAssertTrue(RuntimePlan.shouldStopChild(mode: .spawned)) + XCTAssertFalse(RuntimePlan.shouldStopChild(mode: .attached)) +} +``` + +- [ ] **Step 2: RED, then implement** + +```swift +public enum RuntimePlan { + public enum Mode: Equatable { case attach, spawn, conflict } + + public static func decide(healthOK: Bool, portBusy: Bool) -> Mode { + if healthOK { return .attach } + if portBusy { return .conflict } + return .spawn + } + + public static func shouldStopChild(mode: Mode) -> Bool { mode == .spawned } +} +``` + +Wait: `shouldStopChild` uses `.spawned` but `Mode` has `.spawn`. Use one name: + +```swift +public enum Mode: Equatable { case attached, spawned, conflict } +public static func decide(...) -> Mode { + if healthOK { return .attached } + if portBusy { return .conflict } + return .spawned +} +public static func shouldStopChild(mode: Mode) -> Bool { mode == .spawned } +``` + +Fix the tests to match `.attached` / `.spawned`. + +`RuntimeSupervisor` wraps `Process`: executable URL of bundled `antares`, arguments `["--foreground"]`, `environment["HOME"] = NSHomeDirectory()`, poll `GET /api/health` every 200ms up to 30s. Capture stderr into an actor-isolated `lastLog` string for `LaunchView`. + +- [ ] **Step 3: GREEN + commit** + +```bash +cd macos/AntaresKit && swift test --filter RuntimeSupervisorTests +git add macos/AntaresKit +git commit -m "feat(macos): decide spawn vs attach for the Go backend" +``` + +--- + +### Task 5: Chat SSE event reducer (TDD) + +**Files:** +- Create: `macos/AntaresKit/Sources/AntaresKit/ChatEvents.swift` +- Create: `macos/AntaresKit/Tests/AntaresKitTests/ChatEventsTests.swift` + +Match events in `docs/api.md`: `session`, `turn`, `text`, `reasoning`, `tool_call`, `tool_progress`, `tool_result`, `usage`, `notice`, `error`, `done`. + +- [ ] **Step 1: Failing test** + +```swift +func testTextDeltasAppendAndDoneMarksComplete() { + var s = ChatTranscript() + s.apply(["event": "session", "id": "s1", "title": "T"]) + s.apply(["event": "text", "delta": "Hel"]) + s.apply(["event": "text", "delta": "lo"]) + s.apply(["event": "done"]) + XCTAssertEqual(s.sessionID, "s1") + XCTAssertEqual(s.assistantText, "Hello") + XCTAssertTrue(s.done) +} +``` + +Inspect the real SSE frames from `handleChat` / `web/src/lib/useChatStream.ts` and decode the same field names (`event` vs `type`). The reducer must use the production wire format, not a guessed schema. + +- [ ] **Step 2: Implement `ChatTranscript.apply`**, GREEN, commit. + +```bash +git commit -m "feat(macos): reduce chat SSE events into transcript state" +``` + +--- + +### Task 6: SwiftUI app shell + launch gate + +**Files:** +- Create: `macos/Antares/Antares/AntaresApp.swift` +- Create: `macos/Antares/Antares/AppModel.swift` +- Create: `macos/Antares/Antares/LaunchView.swift` +- Create: `macos/Antares/Antares/AppShell.swift` +- Create: `macos/Antares/Antares/SidebarItem.swift` +- Add local package reference to `../AntaresKit` + +Xcode: File → New → Project → macOS App, bundle id `dev.enow.antares`, then add AntaresKit. Or generate with: + +```bash +cd macos && mkdir -p Antares/Antares +``` + +`SidebarItem` is the static list matching `ROUTE_MANIFEST` ids (chat first). + +- [ ] **Step 1: `SidebarItem.all` ordered like the spec table** + +```swift +struct SidebarItem: Identifiable, Hashable { + let id: String + let title: String + static let all: [SidebarItem] = [ + .init(id: "chat", title: "Chat"), + .init(id: "sessions", title: "Sessions"), + // … every id from the spec + .init(id: "social-media", title: "Social media"), + ] +} +``` + +- [ ] **Step 2: `AppModel.start()`** + +```swift +@MainActor +final class AppModel: ObservableObject { + @Published var phase: Phase = .launching + enum Phase { case launching, login, setup, ready, failed(String) } + + let client = APIClient(baseURL: AntaresKit.defaultBaseURL) + let runtime = RuntimeSupervisor() + + func start() async { + do { + try await runtime.start(binary: Bundle.main.url(forAuxiliaryExecutable: "antares")) + // GET /api/setup/status then /api/auth/status → set phase + phase = .ready + } catch { + phase = .failed(error.localizedDescription) + } + } +} +``` + +`AntaresApp`: + +```swift +@main +struct AntaresApp: App { + @StateObject private var model = AppModel() + var body: some Scene { + WindowGroup { + switch model.phase { + case .launching: LaunchView() + case .login: LoginView() + case .setup: SetupView() + case .ready: AppShell() + case .failed(let m): ContentUnavailableView("Backend", body: Text(m)) + } + } + .environmentObject(model) + } +} +``` + +`AppShell`: `NavigationSplitView` sidebar `List(SidebarItem.all)` and `detail` switch on `selection`. Unimplemented pages show `PageStub(id:)`. + +- [ ] **Step 3: Manual check** — run the app without a binary: LaunchView shows spawn error. With `antares --foreground` already on :8787: attach, skip spawn. + +- [ ] **Step 4: Commit** + +```bash +git add macos/Antares +git commit -m "feat(macos): add SwiftUI app shell and backend launch gate" +``` + +--- + +### Task 7: Bundle Go binary (`make macos`) + +**Files:** +- Modify: `Makefile` +- Modify: `docs/getting-started.md` +- Modify: `README.md` (one line under Two interfaces) + +- [ ] **Step 1: Makefile target** + +```make +.PHONY: macos +macos: build ## Build Antares.app with the darwin antares binary inside + @test "$(shell uname -s)" = Darwin || (echo "macos target requires Darwin" && exit 1) + @xcodebuild -project macos/Antares/Antares.xcodeproj -scheme Antares \ + -configuration Release -derivedDataPath macos/build \ + ANTARES_GO_BIN=$(CURDIR)/bin/antares + @cp bin/antares macos/build/Build/Products/Release/Antares.app/Contents/MacOS/antares + @echo "built macos/build/Build/Products/Release/Antares.app" +``` + +Copy step can also be an Xcode Run Script Build Phase: `cp "${ANTARES_GO_BIN}" "${BUILT_PRODUCTS_DIR}/${EXECUTABLE_FOLDER_PATH}/antares"`. Prefer the script so Debug runs from Xcode work: it should `make -C ../.. build` or copy `../../bin/antares`. + +- [ ] **Step 2: Document** in `docs/getting-started.md`: + +```markdown +## macOS app + +```bash +make macos +open macos/build/Build/Products/Release/Antares.app +``` + +The app starts `antares --foreground` if nothing is listening on :8787. +Linux and Windows still use the web dashboard. +``` + +- [ ] **Step 3: Commit** + +```bash +git add Makefile docs/getting-started.md README.md macos/Antares +git commit -m "build: bundle the Go binary inside Antares.app" +``` + +--- + +### Task 8: Login + setup + +**Files:** +- Create: `macos/Antares/Antares/LoginView.swift` +- Create: `macos/Antares/Antares/SetupView.swift` + +Endpoints: `GET /api/auth/status`, `POST /api/auth/login`, `POST /api/auth/password`, `GET /api/setup/status`, `POST /api/setup/test`, `POST /api/setup/complete`. + +- [ ] **Step 1: Codable structs matching the web pages** (`LoginPage.tsx`, `SetupPage.tsx`). Decode fixture JSON in Kit tests. + +- [ ] **Step 2: SwiftUI forms.** 428 → password bootstrap. Setup incomplete → provider + key, same fields as web. + +- [ ] **Step 3: Commit** + +```bash +git commit -m "feat(macos): native login and first-run setup" +``` + +--- + +### Task 9: Chat page (parity with ChatPage) + +**Files:** +- Create: `macos/Antares/Antares/Pages/ChatView.swift` +- Create: `macos/Antares/Antares/Pages/ChatComposer.swift` +- Create: `macos/Antares/Antares/Pages/MessageViews.swift` + +Use `ChatTranscript` from Task 5. `POST /api/chat`, `POST /api/chat/interrupt`, `GET /api/sessions/{id}`, `GET /api/chat/attach`, `POST /api/upload`, `POST /api/sessions/{id}/edit`. + +Sticky prompt behaviour (clip after first line when docked, 200ms, no fade on one line, inner gradient) is a SwiftUI overlay on the last user bubble — optional v1 polish; v1 must stream text and tools. + +- [ ] **Step 1: Kit tests for edit + interrupt request bodies.** + +- [ ] **Step 2: `ChatView`** `ScrollView` + composer. Virtualization (`List`) if a session exceeds a few hundred messages. + +- [ ] **Step 3: Manual:** send a message against a live backend, interrupt, reload session. + +- [ ] **Step 4: Commit** + +```bash +git commit -m "feat(macos): native chat with SSE streaming" +``` + +--- + +### Task 10: Sessions, providers, tools, memory + +**Files:** +- Create: `macos/Antares/Antares/Pages/SessionsView.swift` +- Create: `macos/Antares/Antares/Pages/ProvidersView.swift` +- Create: `macos/Antares/Antares/Pages/ToolsView.swift` +- Create: `macos/Antares/Antares/Pages/MemoryView.swift` + +Endpoints from `docs/api.md` (sessions list/search/delete, model options/set, tools toggle, memory CRUD/search). + +Each view: `task { try await client.get(...) }`, `List`, error `ContentUnavailableView`. + +Replace `PageStub` for these ids in `AppShell`. + +- [ ] **Step 1: Decode one fixture per DTO in Kit tests.** + +- [ ] **Step 2: Wire lists + primary actions (delete session, set model, toggle tool, add/delete memory).** + +- [ ] **Step 3: Commit** + +```bash +git commit -m "feat(macos): sessions, providers, tools, and memory screens" +``` + +--- + +### Task 11: Roles, soul, skills, cron, channels + +**Files:** +- Create: `macos/Antares/Antares/Pages/RolesView.swift` +- Create: `macos/Antares/Antares/Pages/SoulView.swift` +- Create: `macos/Antares/Antares/Pages/SkillsView.swift` +- Create: `macos/Antares/Antares/Pages/CronView.swift` +- Create: `macos/Antares/Antares/Pages/ChannelsView.swift` + +Read the matching `web/src/pages/*Page.tsx` and `internal/server/routes.go` before coding. Cron: list/create/toggle/run. Channels: token + pairing approve/revoke. Skills: hub install + toggle. Soul: GET/POST body. Roles: list + session role. + +- [ ] **Step 1: DTO tests.** + +- [ ] **Step 2: Screens + `AppShell` switch cases.** + +- [ ] **Step 3: Commit** + +```bash +git commit -m "feat(macos): roles, soul, skills, cron, and channels screens" +``` + +--- + +### Task 12: Autopilot, engagement, board + +**Files:** +- Create: `macos/Antares/Antares/Pages/AutopilotView.swift` +- Create: `macos/Antares/Antares/Pages/EngagementView.swift` +- Create: `macos/Antares/Antares/Pages/BoardView.swift` + +Board: `LazyVStack` columns; drag-and-drop via `.draggable` / `.dropDestination` if the web page supports move. If the API is status-only, show columns without DnD. + +- [ ] Commit: `feat(macos): autopilot, engagement, and board screens` + +--- + +### Task 13: Intercept + MCP + plugins + proxies + +**Files:** +- Create: `macos/Antares/Antares/Pages/InterceptView.swift` +- Create: `macos/Antares/Antares/Pages/MCPView.swift` +- Create: `macos/Antares/Antares/Pages/PluginsView.swift` +- Create: `macos/Antares/Antares/Pages/ProxiesView.swift` + +Intercept: start/stop, exchanges table, rules, breakpoint SSE (`GET /api/intercept/breakpoints/stream`). MCP: `GET /api/mcp`, hub install. Plugins/proxies: match web pages’ endpoints in `routes.go`. + +- [ ] Commit: `feat(macos): intercept, MCP, plugins, and proxies screens` + +--- + +### Task 14: VPS, analytics, files, logs, system + +**Files:** +- Create: `macos/Antares/Antares/Pages/VPSView.swift` +- Create: `macos/Antares/Antares/Pages/AnalyticsView.swift` +- Create: `macos/Antares/Antares/Pages/FilesView.swift` +- Create: `macos/Antares/Antares/Pages/LogsView.swift` +- Create: `macos/Antares/Antares/Pages/SystemView.swift` + +Logs: `GET /api/logs` then `GET /api/logs/stream` via `SSEClient`. Files: `GET /api/files`, `GET /api/files/read`. Analytics: `GET /api/analytics` (Swift Charts). VPS: poll `system`/VPS routes used by `VPSPage.tsx`. System: `GET /api/system/stats`, `GET /api/status`. + +- [ ] Commit: `feat(macos): VPS, analytics, files, logs, and system screens` + +--- + +### Task 15: Config (schema-driven) + +**Files:** +- Create: `macos/Antares/Antares/Pages/ConfigView.swift` +- Create: `macos/AntaresKit/Sources/AntaresKit/ConfigSchema.swift` +- Test: `macos/AntaresKit/Tests/AntaresKitTests/ConfigSchemaTests.swift` + +`GET /api/config/schema` drives the web Settings form. Decode schema, render `Form` sections, `POST /api/config` per path. Raw YAML tab: `GET/POST /api/config/raw`. Appearance palettes stay web-only until a later assets task. + +- [ ] **Step 1: Test that unknown field types fall back to a string field (do not crash).** + +- [ ] **Step 2: Build the form.** + +- [ ] **Step 3: Commit** `feat(macos): schema-driven config screen` + +--- + +### Task 16: Content creator + social media + +**Files:** +- Create: `macos/Antares/Antares/Pages/ContentCreatorView.swift` +- Create: `macos/Antares/Antares/Pages/SocialMediaView.swift` + +Creator endpoints in `docs/api.md` (projects CRUD, action, run, artifact, settings). Social: match `SocialMediaPage.tsx` routes (`/api/social/...` in `routes.go`). + +- [ ] Commit: `feat(macos): content creator and social media screens` + +--- + +### Task 17: Sidebar complete + smoke checklist + +**Files:** +- Modify: `macos/Antares/Antares/AppShell.swift` — no `PageStub` remains +- Create: `docs/superpowers/plans/macos-dashboard-smoke.md` OR a section at the bottom of this plan used as the test plan + +- [ ] **Step 1: Compile-time exhaustiveness** — `switch selection.id` must be exhaustive over `SidebarItem.all`. + +- [ ] **Step 2: Manual smoke against a live `antares --foreground`:** login/setup, every sidebar row loads without a crash, chat send+stream, config GET. + +- [ ] **Step 3: Commit** `feat(macos): complete native sidebar coverage` + +--- + +### Task 18: Docs + HANDOFF + +**Files:** +- Modify: `README.md` +- Modify: `docs/getting-started.md` +- Modify: `docs/architecture.md` (add `macos/` next to `web/`) +- Modify: `HANDOFF.md` if it still says the dashboard is React-only on Mac + +- [ ] State clearly: Mac app is optional; CLI `antares` still serves the web UI for other platforms. + +- [ ] Commit: `docs: document the macOS SwiftUI dashboard` + +--- + +## Out of scope (later) + +- getdesign palettes in Asset catalogs +- `surface=macos` command catalogue +- `antares://` deep links +- App Store sandbox +- Deleting `web/` + +--- + +## Execution notes + +Foundation (Tasks 1–7) must land before page tasks. Tasks 10–16 can proceed in parallel after Task 9 once `AppShell` + `APIClient` exist. Each page task starts by reading the corresponding `web/src/pages/*Page.tsx` and the routes in `internal/server/routes.go` so DTOs match production JSON. diff --git a/docs/superpowers/plans/macos-dashboard-smoke.md b/docs/superpowers/plans/macos-dashboard-smoke.md new file mode 100644 index 00000000..de2f9407 --- /dev/null +++ b/docs/superpowers/plans/macos-dashboard-smoke.md @@ -0,0 +1,74 @@ +# macOS dashboard smoke checklist + +Manual pass against a live `antares --foreground` (default `http://127.0.0.1:8787`). + +## Build and open + +```bash +make macos +open macos/build/Build/Products/Release/Antares.app +``` + +From the package (no app bundle): `cd macos/Antares && swift build`. Debug binary: `.build/debug/Antares`. + +If nothing is listening on `:8787`, the app should spawn the bundled `antares --foreground`. If a healthy backend is already up, it should attach and not spawn a second process. + +## Login / setup + +- [ ] Launch gate: wait for health (or a clear spawn error, not a crash). +- [ ] First-run setup (`GET /api/setup/status` incomplete): provider + key complete without crash. +- [ ] Login: password bootstrap on 428, then `POST /api/auth/login` when a dashboard password is set. +- [ ] After auth, `AppShell` appears with Chat selected. + +## Every sidebar row + +Click each row. The detail pane must load without crashing (empty/error states are OK). + +- [ ] Chat +- [ ] Sessions +- [ ] Providers +- [ ] Tools +- [ ] Memory +- [ ] Roles +- [ ] Soul +- [ ] Skills +- [ ] Cron +- [ ] Channels +- [ ] Autopilot +- [ ] Engagement +- [ ] Board +- [ ] Intercept +- [ ] MCP +- [ ] Plugins +- [ ] Proxies +- [ ] VPS +- [ ] Analytics +- [ ] Files +- [ ] Logs +- [ ] Config +- [ ] System +- [ ] Content creator +- [ ] Social media + +## Chat send + stream + +- [ ] Send a message from Chat. +- [ ] Assistant text streams (SSE) into the transcript. +- [ ] Optional: interrupt, then reload the session. + +## Config GET + +- [ ] Open Config. +- [ ] Schema-driven form loads from `GET /api/config/schema` (or a visible error, not a crash). +- [ ] Optional: Raw YAML tab from `GET /api/config/raw`. + +## Live backend notes (2026-09-18) + +Against an already-running `antares` on `http://127.0.0.1:8787` (HTTP only; GUI click-through was not run): + +- `GET /api/health` → 200 `{"ok":true}` +- `GET /api/setup/status` → 200, `needs_setup: false` +- `GET /api/auth/status` → 200, `password_required: true` (login gate would show) +- `GET /api/config/schema` → 401 without a session (expected; Config should show an error, not crash) + +Chat send+stream and authenticated config GET still need a logged-in GUI pass. diff --git a/docs/superpowers/specs/2026-09-18-macos-swiftui-dashboard-design.md b/docs/superpowers/specs/2026-09-18-macos-swiftui-dashboard-design.md new file mode 100644 index 00000000..f0f82b7d --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-macos-swiftui-dashboard-design.md @@ -0,0 +1,170 @@ +# macOS SwiftUI Dashboard Design + +## Summary + +Antares keeps the Go agent as the only backend. On macOS, the operator UI +becomes a native SwiftUI app that launches (or attaches to) that backend, then +talks to the existing HTTP/SSE API. Every page in the current React dashboard +is in scope. The React app stays embedded for Linux, Windows, and remote +browsers. The Mac app does not wrap the SPA in a web view. + +## Goals + +- Opening `Antares.app` starts a usable native dashboard without the operator + opening Chrome. +- The Go process is the same `antares` binary: agent, store, cron, gateways, + MCP, tools. +- Every route in `web/src/lib/routeManifest.ts` has a SwiftUI screen that + calls the same endpoints the web page uses. +- If a backend is already healthy on the configured loopback URL, the app + attaches and does not spawn a second process. +- If the app spawned the backend, quitting the app stops that child. +- Linux/Windows operators still get the embedded web dashboard. + +## Non-goals + +- Rewriting `internal/agent` (or any Go package) in Swift. +- Embedding the React SPA in `WKWebView`. +- An App Store sandboxed build in v1 (local companion app, Developer ID / + unsigned is enough). +- Pixel-identical CSS. Native controls, native navigation, native lists. +- Deleting `web/` or dropping `//go:embed` of the dashboard. +- Changing TUI behaviour. +- iOS / iPadOS / Menu Bar extra targets in v1. + +## Architecture + +``` +Antares.app + Contents/MacOS/Antares SwiftUI + Contents/MacOS/antares Go binary (same cmd/antares) + │ + │ spawn `antares --foreground` if /api/health is down + ▼ + 127.0.0.1:8787 + │ + ├─ JSON GET/POST /api/* + └─ SSE POST /api/chat, GET /api/chat/attach, + GET /api/logs/stream, intercept breakpoint stream +``` + +Three layers in `macos/`: + +| Layer | Path | Responsibility | +|-------|------|----------------| +| Kit | `macos/AntaresKit/` | Pure Swift: URL, JSON, SSE parser, process supervisor. XCTest / `swift test`. | +| App | `macos/Antares/` | SwiftUI windows, navigation, per-page views. | +| Bundle | `make macos` | Builds `bin/antares` (darwin) and copies it next to the app executable. | + +The SwiftUI app never imports Go. It never links the agent. It is a client. + +### Runtime supervisor + +On launch: + +1. Read `ANTARES_URL` or default `http://127.0.0.1:8787`. +2. `GET /api/health` (no auth). If 200, `mode = attached`, do not spawn. +3. Otherwise spawn the bundled `antares` with arguments `--foreground`, inherit + `HOME` so `~/.antares` is shared with CLI/TUI, `mode = spawned`. +4. Poll health every 200ms, timeout 30s. Show a launch screen with the last + stderr line if spawn fails. +5. On terminate: if `mode == spawned`, send SIGTERM to the child, wait, SIGKILL + after 5s. If `mode == attached`, leave the process running. + +Port clash: if health fails but the port is in use by something that is not +Antares, surface the error and do not spawn. + +### Auth + +Match the web client in `web/src/lib/api.ts`: + +- `credentials: include` equivalent: shared `URLSession` with cookie storage. +- Optional `Authorization: Bearer` from Keychain (`antares.token`). +- `401` on non-`/auth/` routes presents `LoginView`. +- `428` presents the set-dashboard-password flow. +- Setup: `GET /api/setup/status`; if needed, `SetupView` before the shell. + +### Streaming + +Port `web/src/lib/sse.ts` byte-for-byte semantics into `SSEClient`: + +- CRLF / LF / lone CR line endings +- `data:` join with `\n`, strip one leading space +- ignore `:` comments +- blank line dispatches JSON +- drop `[DONE]` +- unfinished trailing record discarded on EOS + +Chat uses `POST /api/chat` with `Accept: text/event-stream`. Attach uses +`GET /api/chat/attach`. Do not use `EventSource` (cannot set headers). + +Command catalogue calls `GET /api/commands?surface=web` until a dedicated +`surface=macos` exists. Slash commands stay server-defined. + +## Navigation + +`NavigationSplitView` sidebar, same information architecture as +`ROUTE_MANIFEST`: + +| id | path | Swift view | +|----|------|------------| +| chat | `/`, `/c/:id` | `ChatView` | +| sessions | `/sessions` | `SessionsView` | +| providers | `/providers` | `ProvidersView` | +| tools | `/tools` | `ToolsView` | +| memory | `/memory` | `MemoryView` | +| roles | `/roles` | `RolesView` | +| soul | `/soul` | `SoulView` | +| skills | `/skills` | `SkillsView` | +| cron | `/cron` | `CronView` | +| channels | `/channels` | `ChannelsView` | +| autopilot | `/autopilot` | `AutopilotView` | +| engagement | `/engagement` | `EngagementView` | +| board | `/board` | `BoardView` | +| intercept | `/intercept` | `InterceptView` | +| mcp | `/mcp` | `MCPView` | +| plugins | `/plugins` | `PluginsView` | +| proxies | `/proxies` | `ProxiesView` | +| vps | `/vps` | `VPSView` | +| analytics | `/analytics` | `AnalyticsView` | +| files | `/files` | `FilesView` | +| logs | `/logs` | `LogsView` | +| config | `/config` | `ConfigView` | +| system | `/system` | `SystemView` | +| content-creator | `/content-creator` | `ContentCreatorView` | +| social-media | `/social-media` | `SocialMediaView` | + +Plus `LoginView` and `SetupView` outside the split view. + +Chat is the default selection. Deep link `antares://c/` is optional +v1.1. + +## Appearance + +v1 uses system semantic colors (`Color.primary`, `Color.accentColor`) and +respects macOS light/dark. Porting the getdesign palettes (Claude, Facebook, +Pinterest, Supabase) onto Swift `Asset` catalogs is a follow-up, not a blocker +for page parity. + +## Testing + +- `AntaresKit` tests run with `swift test` (no GUI). SSE parser, JSON error + envelope, health URL join, spawn-vs-attach decision table. +- SwiftUI pages: smoke that each view constructs with fixture JSON. Full + interaction tests only where logic is non-trivial (chat event reducer, config + schema form). +- Do not require Xcode UI tests for v1. +- Go tests stay the source of truth for API behaviour. + +## Delivery + +- `make macos` produces `macos/Antares/build/Antares.app` (or xcodebuild + derived data) containing the Go binary. +- README / getting-started gain a macOS app section. `antares` CLI and + `antares tui` stay documented. +- Minimum OS: macOS 14. + +## Success + +An operator on a Mac can install the app, complete setup, chat, and reach +every sidebar page without a browser. Linux/Windows installs are unchanged. diff --git a/macos/Antares/Antares/AntaresApp.swift b/macos/Antares/Antares/AntaresApp.swift new file mode 100644 index 00000000..ff3ecc81 --- /dev/null +++ b/macos/Antares/Antares/AntaresApp.swift @@ -0,0 +1,43 @@ +import SwiftUI + +@main +struct AntaresApp: App { + @StateObject private var model = AppModel() + + var body: some Scene { + WindowGroup { + rootView + .environmentObject(model) + .task { + await model.start() + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in + // SIGTERM now; async wait/SIGKILL may still lose the race with process exit. + model.terminateSpawnedChild() + Task { + await model.stop() + } + } + } + } + + @ViewBuilder + private var rootView: some View { + switch model.phase { + case .launching: + LaunchView() + case .login: + LoginView() + case .setup: + SetupView() + case .ready: + AppShell() + case .failed(let message): + ContentUnavailableView( + "Backend", + systemImage: "exclamationmark.triangle", + description: Text(message) + ) + } + } +} diff --git a/macos/Antares/Antares/AppModel.swift b/macos/Antares/Antares/AppModel.swift new file mode 100644 index 00000000..d42abc05 --- /dev/null +++ b/macos/Antares/Antares/AppModel.swift @@ -0,0 +1,114 @@ +import AntaresKit +import Combine +import Foundation + +@MainActor +protocol RuntimeSupervising: AnyObject { + func start(binary: URL?) async throws + func stop() async + func terminateSpawnedChild() +} + +extension RuntimeSupervising { + func terminateSpawnedChild() {} +} + +extension RuntimeSupervisor: RuntimeSupervising {} + +@MainActor +final class AppModel: ObservableObject { + @Published var phase: Phase = .launching + + enum Phase { + case launching + case login + case setup + case ready + case failed(String) + } + + let client: APIClient + // RuntimeSupervisor is only touched from this @MainActor model to keep access serialized. + private let runtime: any RuntimeSupervising + + init( + baseURL: URL = AntaresKit.resolveBaseURL(), + runtime: (any RuntimeSupervising)? = nil, + session: URLSession = .shared + ) { + self.client = APIClient(baseURL: baseURL, session: session) + self.runtime = runtime ?? RuntimeSupervisor(baseURL: baseURL, session: session) + } + + func start() async { + phase = .launching + await ensureBackend() + if case .failed = phase { return } + await refreshPhase() + } + + /// Spawns or attaches the backend once. Do not call again after login or setup. + func ensureBackend() async { + do { + try await runtime.start(binary: Bundle.main.url(forAuxiliaryExecutable: "antares")) + } catch { + phase = .failed(error.localizedDescription) + } + } + + /// Reads setup/auth status only. A 401 on a gated route means the session + /// expired — go to login. `/api/auth/status` is public, so that 401 is not + /// expected and must not restart the backend. + func refreshPhase() async { + do { + let setup: SetupStatus = try await client.get("/api/setup/status") + let auth: AuthStatus = try await client.get("/api/auth/status") + + if setup.needsSetup { + phase = .setup + } else if auth.passwordRequired && !auth.authenticated { + phase = .login + } else { + phase = .ready + } + } catch let error as APIError where error.isUnauthorized { + phase = .login + } catch { + phase = .failed(error.localizedDescription) + } + } + + func stop() async { + runtime.terminateSpawnedChild() + await runtime.stop() + } + + func terminateSpawnedChild() { + runtime.terminateSpawnedChild() + } + + func login(password: String) async throws { + let _: EmptyResponse = try await client.post("/api/auth/login", body: LoginRequest(password: password)) + await refreshPhase() + } + + func setDashboardPassword(_ password: String, current: String? = nil) async throws { + let _: EmptyResponse = try await client.post( + "/api/auth/password", + body: SetPasswordRequest(password: password, current: current) + ) + } + + func setupStatus() async throws -> SetupStatus { + try await client.get("/api/setup/status") + } + + func testSetup(_ request: SetupTestRequest) async throws -> SetupTestResponse { + try await client.post("/api/setup/test", body: request) + } + + func completeSetup(_ request: CompleteSetupRequest) async throws { + let _: EmptyResponse = try await client.post("/api/setup/complete", body: request) + await refreshPhase() + } +} diff --git a/macos/Antares/Antares/AppShell.swift b/macos/Antares/Antares/AppShell.swift new file mode 100644 index 00000000..8fd385a2 --- /dev/null +++ b/macos/Antares/Antares/AppShell.swift @@ -0,0 +1,76 @@ +import SwiftUI + +struct AppShell: View { + @State private var selection: SidebarItem? = SidebarItem.all.first + + var body: some View { + NavigationSplitView { + List(SidebarItem.all, selection: $selection) { item in + Text(item.title) + .tag(item) + } + .navigationTitle("Antares") + } detail: { + detailView(for: selection ?? SidebarItem.all[0]) + } + .frame(minWidth: 900, minHeight: 560) + } + + @ViewBuilder + private func detailView(for item: SidebarItem) -> some View { + switch item.route { + case .chat: + ChatView() + case .sessions: + SessionsView { + selection = SidebarItem.all.first { $0.id == "chat" } + } + case .providers: + ProvidersView() + case .tools: + ToolsView() + case .memory: + MemoryView() + case .roles: + RolesView() + case .soul: + SoulView() + case .skills: + SkillsView() + case .cron: + CronView() + case .channels: + ChannelsView() + case .autopilot: + AutopilotView() + case .engagement: + EngagementView() + case .board: + BoardView() + case .intercept: + InterceptView() + case .mcp: + MCPView() + case .plugins: + PluginsView() + case .proxies: + ProxiesView() + case .vps: + VPSView() + case .analytics: + AnalyticsView() + case .files: + FilesView() + case .logs: + LogsView() + case .config: + ConfigView() + case .system: + SystemView() + case .contentCreator: + ContentCreatorView() + case .socialMedia: + SocialMediaView() + } + } +} diff --git a/macos/Antares/Antares/LaunchView.swift b/macos/Antares/Antares/LaunchView.swift new file mode 100644 index 00000000..bf2761ac --- /dev/null +++ b/macos/Antares/Antares/LaunchView.swift @@ -0,0 +1,13 @@ +import SwiftUI + +struct LaunchView: View { + var body: some View { + VStack(spacing: 12) { + ProgressView() + Text("Starting Antares backend...") + .foregroundStyle(.secondary) + } + .frame(minWidth: 420, minHeight: 260) + .padding() + } +} diff --git a/macos/Antares/Antares/LoginView.swift b/macos/Antares/Antares/LoginView.swift new file mode 100644 index 00000000..6337d918 --- /dev/null +++ b/macos/Antares/Antares/LoginView.swift @@ -0,0 +1,116 @@ +import SwiftUI +import AntaresKit + +struct LoginView: View { + @EnvironmentObject private var model: AppModel + @State private var password = "" + @State private var newPassword = "" + @State private var confirmPassword = "" + @State private var errorMessage: String? + @State private var isSubmitting = false + @State private var showsSetPassword = false + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + if showsSetPassword { + setPasswordForm + } else { + loginForm + } + } + .padding(28) + .frame(minWidth: 420, minHeight: 260) + } + + private var loginForm: some View { + Form { + Section { + SecureField("Password", text: $password) + .textContentType(.password) + .onSubmit(submitLogin) + + if let errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(.red) + } + } header: { + Text("Sign in to Antares") + .font(.title2) + .fontWeight(.semibold) + } footer: { + Text("Enter the dashboard password configured for this Antares server.") + } + + Button(isSubmitting ? "Signing In..." : "Sign In") { + submitLogin() + } + .disabled(password.isEmpty || isSubmitting) + } + } + + private var setPasswordForm: some View { + Form { + Section { + SecureField("New Password", text: $newPassword) + .textContentType(.newPassword) + SecureField("Confirm Password", text: $confirmPassword) + .textContentType(.newPassword) + .onSubmit(submitNewPassword) + + if let errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(.red) + } + } header: { + Text("Set a Dashboard Password") + .font(.title2) + .fontWeight(.semibold) + } footer: { + Text("This protects credentials and other sensitive dashboard actions.") + } + + Button(isSubmitting ? "Saving..." : "Set Password") { + submitNewPassword() + } + .disabled(newPassword.isEmpty || newPassword != confirmPassword || isSubmitting) + } + } + + private func submitLogin() { + guard !password.isEmpty, !isSubmitting else { return } + isSubmitting = true + errorMessage = nil + + Task { + do { + try await model.login(password: password) + } catch let error as APIError where error.isUnauthorized { + errorMessage = "Incorrect password." + } catch let error as APIError where error.isDashboardPasswordRequired { + showsSetPassword = true + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + isSubmitting = false + } + } + + private func submitNewPassword() { + guard !newPassword.isEmpty, newPassword == confirmPassword, !isSubmitting else { return } + isSubmitting = true + errorMessage = nil + + Task { + do { + try await model.setDashboardPassword(newPassword) + try await model.login(password: newPassword) + } catch { + errorMessage = error.localizedDescription + } + isSubmitting = false + } + } +} diff --git a/macos/Antares/Antares/Pages/AnalyticsView.swift b/macos/Antares/Antares/Pages/AnalyticsView.swift new file mode 100644 index 00000000..5e790d86 --- /dev/null +++ b/macos/Antares/Antares/Pages/AnalyticsView.swift @@ -0,0 +1,198 @@ +import AntaresKit +import Charts +import SwiftUI + +struct AnalyticsView: View { + @EnvironmentObject private var model: AppModel + + private enum RangeOption: String, CaseIterable, Identifiable { + case hours24 = "24h" + case days7 = "7d" + case days30 = "30d" + + var id: String { rawValue } + var title: String { + switch self { + case .hours24: "24h" + case .days7: "7d" + case .days30: "30d" + } + } + var bucket: String { self == .hours24 ? "hour" : "day" } + } + + @State private var range: RangeOption = .days7 + @State private var data: AnalyticsResponse? + @State private var errorMessage: String? + @State private var isLoading = false + + var body: some View { + Group { + if let errorMessage, data == nil, !isLoading { + ContentUnavailableView( + "Analytics", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else if let data, data.totals.calls == 0, !isLoading { + ContentUnavailableView( + "No Usage", + systemImage: "chart.xyaxis.line", + description: Text("Token usage will show up here after chats run.") + ) + } else if let data { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let errorMessage { + Text(errorMessage).foregroundStyle(.red) + } + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), spacing: 12) { + stat("Calls", formatCount(data.totals.calls)) + stat("Tokens in", formatCount(data.totals.tokensIn)) + stat("Tokens out", formatCount(data.totals.tokensOut)) + stat("Cost", String(format: "$%.4f", data.totals.cost)) + } + chart(data.series) + modelTable(data.byModel) + } + .padding(16) + } + } else { + Color.clear + } + } + .overlay { + if isLoading { ProgressView("Loading analytics…") } + } + .navigationTitle("Analytics") + .toolbar { + ToolbarItem(placement: .automatic) { + Picker("Range", selection: $range) { + ForEach(RangeOption.allCases) { option in + Text(option.title).tag(option) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 240) + } + } + .task(id: range) { await load() } + .refreshable { await load() } + } + + private func stat(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(label.uppercased()) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.title2.monospacedDigit().weight(.semibold)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(.background, in: RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.secondary.opacity(0.2)) + ) + } + + @ViewBuilder + private func chart(_ series: [AnalyticsUsagePoint]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Per period").font(.headline) + if series.isEmpty { + Text("No series for this range.") + .foregroundStyle(.secondary) + } else { + Chart { + ForEach(series) { point in + AreaMark( + x: .value("Period", point.bucket), + y: .value("Tokens", point.tokensOut), + series: .value("Kind", "Tokens out") + ) + .foregroundStyle(by: .value("Kind", "Tokens out")) + .opacity(0.28) + LineMark( + x: .value("Period", point.bucket), + y: .value("Tokens", point.tokensOut), + series: .value("Kind", "Tokens out") + ) + .foregroundStyle(by: .value("Kind", "Tokens out")) + .interpolationMethod(.catmullRom) + AreaMark( + x: .value("Period", point.bucket), + y: .value("Tokens", point.tokensIn), + series: .value("Kind", "Tokens in") + ) + .foregroundStyle(by: .value("Kind", "Tokens in")) + .opacity(0.22) + LineMark( + x: .value("Period", point.bucket), + y: .value("Tokens", point.tokensIn), + series: .value("Kind", "Tokens in") + ) + .foregroundStyle(by: .value("Kind", "Tokens in")) + .interpolationMethod(.catmullRom) + } + } + .chartForegroundStyleScale([ + "Tokens out": Color.accentColor, + "Tokens in": Color.green, + ]) + .chartLegend(position: .bottom) + .frame(height: 220) + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.background, in: RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.secondary.opacity(0.2)) + ) + } + + @ViewBuilder + private func modelTable(_ rows: [AnalyticsModelUsage]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("By model").font(.headline) + if rows.isEmpty { + Text("No per-model usage.") + .foregroundStyle(.secondary) + } else { + Table(rows) { + TableColumn("Model") { Text($0.model).font(.body.monospaced()) } + TableColumn("Provider") { Text($0.provider) }.width(100) + TableColumn("Calls") { Text("\($0.calls)") }.width(70) + TableColumn("In") { Text(formatCount($0.tokensIn)) }.width(80) + TableColumn("Out") { Text(formatCount($0.tokensOut)) }.width(80) + TableColumn("Cost") { Text(String(format: "$%.4f", $0.cost)) }.width(90) + } + .frame(minHeight: 160) + } + } + .padding(14) + .background(.background, in: RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.secondary.opacity(0.2)) + ) + } + + private func load() async { + isLoading = data == nil + errorMessage = nil + do { + data = try await model.client.analytics(range: range.rawValue, bucket: range.bucket) + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } + + private func formatCount(_ value: Int) -> String { + value.formatted(.number.notation(.compactName)) + } +} diff --git a/macos/Antares/Antares/Pages/AutopilotView.swift b/macos/Antares/Antares/Pages/AutopilotView.swift new file mode 100644 index 00000000..e76fc975 --- /dev/null +++ b/macos/Antares/Antares/Pages/AutopilotView.swift @@ -0,0 +1,362 @@ +import AntaresKit +import SwiftUI + +struct AutopilotView: View { + @EnvironmentObject private var model: AppModel + + @State private var cards: [AutopilotCard] = [] + @State private var errorMessage: String? + @State private var isLoading = false + @State private var isRunning = false + @State private var showingAdd = false + @State private var detail: AutopilotCard? + @State private var statusFilter = "" + @State private var draftTitle = "" + @State private var draftPrompt = "" + @State private var isSaving = false + @State private var busyID: String? + + private static let statuses = ["pending", "running", "verified", "merged", "failed"] + + private var pendingCount: Int { + cards.filter { $0.status == "pending" }.count + } + + private var hasRunning: Bool { + cards.contains { $0.status == "running" } + } + + private var shown: [AutopilotCard] { + statusFilter.isEmpty ? cards : cards.filter { $0.status == statusFilter } + } + + var body: some View { + Group { + if let errorMessage, cards.isEmpty, !isLoading { + ContentUnavailableView( + "Autopilot", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else if !isLoading && cards.isEmpty { + ContentUnavailableView( + "No Cards", + systemImage: "airplane", + description: Text("Add a card and run Autopilot to work through the queue unattended.") + ) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + } + + if !cards.isEmpty { + filterChips + } + + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 240), spacing: 12)], + spacing: 12 + ) { + ForEach(shown) { card in + cardCell(card) + } + } + } + .padding(16) + } + } + } + .overlay { + if isLoading { ProgressView("Loading autopilot…") } + } + .navigationTitle("Autopilot") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + Task { await run() } + } label: { + Label( + pendingCount > 0 ? "Run (\(pendingCount))" : "Run", + systemImage: "play.fill" + ) + } + .disabled(pendingCount == 0 || isRunning) + } + ToolbarItem(placement: .primaryAction) { + Button("Add") { + draftTitle = "" + draftPrompt = "" + showingAdd = true + } + } + } + .sheet(isPresented: $showingAdd) { addSheet } + .sheet(item: $detail) { card in + detailSheet(card) + } + .task { await load() } + .task(id: hasRunning) { + guard hasRunning else { return } + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(2500)) + await load(quiet: true) + } + } + .refreshable { await load() } + } + + private var filterChips: some View { + HStack(spacing: 8) { + chip("All \(cards.count)", selected: statusFilter.isEmpty) { + statusFilter = "" + } + ForEach(Self.statuses, id: \.self) { status in + let count = cards.filter { $0.status == status }.count + if count > 0 { + chip("\(label(for: status)) \(count)", selected: statusFilter == status) { + statusFilter = status + } + } + } + Spacer() + } + } + + private func chip(_ title: String, selected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(title) + .font(.caption) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(selected ? Color.accentColor.opacity(0.15) : Color.clear, in: Capsule()) + .overlay(Capsule().stroke(selected ? Color.accentColor : Color.secondary.opacity(0.35))) + } + .buttonStyle(.plain) + } + + private func cardCell(_ card: AutopilotCard) -> some View { + VStack(alignment: .leading, spacing: 8) { + Button { + detail = card + } label: { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top) { + Text(card.title) + .font(.headline) + .lineLimit(1) + Spacer() + Text(label(for: card.status)) + .font(.caption) + .foregroundStyle(color(for: card.status)) + } + Text(card.prompt) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(2) + if let error = card.error, !error.isEmpty { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + + HStack { + if let pr = card.pr, let url = URL(string: pr) { + Link("PR", destination: url) + .font(.caption) + } + Spacer() + if !card.updatedAt.isEmpty { + Text(card.updatedAt) + .font(.caption2) + .foregroundStyle(.secondary) + } + if card.status == "pending" || card.status == "failed" { + Button("Remove", role: .destructive) { + Task { await remove(card) } + } + .disabled(busyID != nil) + } + } + } + .padding(12) + .background(.background, in: RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.secondary.opacity(0.2)) + ) + } + + private var addSheet: some View { + NavigationStack { + Form { + TextField("Title", text: $draftTitle) + TextEditor(text: $draftPrompt) + .frame(minHeight: 120) + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + } + } + .navigationTitle("Add card") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { showingAdd = false } + } + ToolbarItem(placement: .confirmationAction) { + Button(isSaving ? "Adding…" : "Add") { + Task { await add() } + } + .disabled(!canAdd || isSaving) + } + } + } + .frame(minWidth: 420, minHeight: 320) + } + + private func detailSheet(_ card: AutopilotCard) -> some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text(label(for: card.status)) + .foregroundStyle(color(for: card.status)) + Spacer() + Text(card.id) + .font(.caption) + .fontDesign(.monospaced) + .foregroundStyle(.secondary) + } + block("Prompt", card.prompt) + block("Error", card.error) + block("Result", card.result) + block("Verify output", card.verifyOutput, mono: true) + if let branch = card.branch, !branch.isEmpty { + LabeledContent("Branch") { + Text(branch).fontDesign(.monospaced) + } + } + if let pr = card.pr, let url = URL(string: pr) { + Link(pr, destination: url) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + } + .navigationTitle(card.title) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { detail = nil } + } + } + } + .frame(minWidth: 520, minHeight: 400) + } + + @ViewBuilder + private func block(_ title: String, _ value: String?, mono: Bool = false) -> some View { + if let value, !value.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(mono ? .caption.monospaced() : .body) + .textSelection(.enabled) + } + } + } + + private var canAdd: Bool { + !draftTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !draftPrompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private func label(for status: String) -> String { + switch status { + case "pending": "Pending" + case "running": "Running" + case "verified": "Verified" + case "merged": "Merged" + case "failed": "Failed" + default: status + } + } + + private func color(for status: String) -> Color { + switch status { + case "running": .orange + case "verified", "merged": .green + case "failed": .red + default: .secondary + } + } + + private func load(quiet: Bool = false) async { + if !quiet { + isLoading = cards.isEmpty + errorMessage = nil + } + do { + let response = try await model.client.listAutopilotCards() + cards = response.cards + if let current = detail, let updated = cards.first(where: { $0.id == current.id }) { + detail = updated + } + if !quiet { errorMessage = nil } + } catch { + if !quiet || cards.isEmpty { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } + isLoading = false + } + + private func add() async { + let title = draftTitle.trimmingCharacters(in: .whitespacesAndNewlines) + let prompt = draftPrompt.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty, !prompt.isEmpty else { return } + isSaving = true + errorMessage = nil + do { + _ = try await model.client.addAutopilotCard(title: title, prompt: prompt) + showingAdd = false + await load() + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isSaving = false + } + + private func run() async { + isRunning = true + errorMessage = nil + do { + _ = try await model.client.runAutopilot() + await load() + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isRunning = false + } + + private func remove(_ card: AutopilotCard) async { + busyID = card.id + defer { busyID = nil } + errorMessage = nil + do { + _ = try await model.client.removeAutopilotCard(card.id) + if detail?.id == card.id { detail = nil } + await load() + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } +} diff --git a/macos/Antares/Antares/Pages/BoardView.swift b/macos/Antares/Antares/Pages/BoardView.swift new file mode 100644 index 00000000..26f17abd --- /dev/null +++ b/macos/Antares/Antares/Pages/BoardView.swift @@ -0,0 +1,264 @@ +import AntaresKit +import SwiftUI + +struct BoardView: View { + @EnvironmentObject private var model: AppModel + + @State private var sessions: [BoardSession] = [] + @State private var selectedID = "" + @State private var columns: [BoardColumn] = [] + @State private var errorMessage: String? + @State private var isLoading = false + @State private var busyID: String? + + private var active: BoardSession? { + sessions.first { $0.id == selectedID } ?? sessions.first + } + + private var totalCards: Int { + columns.reduce(0) { $0 + $1.cards.count } + } + + private var doneCount: Int { + columns.first { $0.name == "done" }?.cards.count ?? 0 + } + + var body: some View { + Group { + if let errorMessage, sessions.isEmpty, !isLoading { + ContentUnavailableView( + "Board", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else if !isLoading && sessions.isEmpty { + ContentUnavailableView( + "No Boards", + systemImage: "rectangle.split.3x1", + description: Text("Session boards appear when the agent lays out work as cards.") + ) + } else { + VStack(alignment: .leading, spacing: 12) { + header + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + .padding(.horizontal, 16) + } + if totalCards == 0 { + ContentUnavailableView( + "No Cards", + systemImage: "rectangle.split.3x1", + description: Text("This session has a board, but no cards yet.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + boardColumns + } + } + .padding(.top, 12) + } + } + .overlay { + if isLoading { ProgressView("Loading board…") } + } + .navigationTitle("Board") + .task { await loadSessions() } + .task(id: selectedID) { + guard !selectedID.isEmpty else { return } + await loadBoard() + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(2500)) + await loadBoard(quiet: true) + } + } + .refreshable { await loadSessions() } + } + + private var header: some View { + HStack(spacing: 12) { + Picker("Session", selection: $selectedID) { + ForEach(sessions) { session in + Text(session.title.isEmpty ? session.id : session.title).tag(session.id) + } + } + .labelsHidden() + .frame(minWidth: 180) + + if totalCards > 0 { + Text("\(doneCount) of \(totalCards) done") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Button("Clear", role: .destructive) { + Task { await clear() } + } + .disabled(active == nil || totalCards == 0 || busyID != nil) + } + .padding(.horizontal, 16) + } + + private var boardColumns: some View { + ScrollView(.horizontal) { + HStack(alignment: .top, spacing: 12) { + ForEach(columns) { column in + VStack(alignment: .leading, spacing: 8) { + HStack { + Circle() + .fill(dotColor(column.name)) + .frame(width: 8, height: 8) + Text(title(for: column.name)) + .font(.caption) + .fontWeight(.semibold) + .textCase(.uppercase) + Spacer() + Text("\(column.cards.count)") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 4) + + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + if column.cards.isEmpty { + Text("Empty") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(style: StrokeStyle(lineWidth: 1, dash: [4])) + .foregroundStyle(.secondary.opacity(0.4)) + ) + } else { + ForEach(column.cards) { card in + boardCard(card, done: column.name == "done") + } + } + } + .padding(4) + } + } + .frame(width: 240) + .padding(10) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 12)) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + } + + private func boardCard(_ card: BoardCard, done: Bool) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top) { + Text(card.title) + .font(.callout) + .fontWeight(.medium) + .strikethrough(done) + .foregroundStyle(done ? .secondary : .primary) + Spacer() + Button { + Task { await remove(card) } + } label: { + Image(systemName: "xmark") + .font(.caption2) + } + .buttonStyle(.borderless) + .disabled(busyID != nil) + .help("Delete") + } + if let note = card.note, !note.isEmpty { + Text(note) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.background, in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.secondary.opacity(0.2)) + ) + } + + private func title(for name: String) -> String { + switch name { + case "todo": "To do" + case "doing": "Doing" + case "done": "Done" + default: name + } + } + + private func dotColor(_ name: String) -> Color { + switch name { + case "doing": .accentColor + case "done": .green + default: .secondary + } + } + + private func loadSessions() async { + isLoading = sessions.isEmpty + errorMessage = nil + do { + let response = try await model.client.listBoardSessions() + sessions = response.sessions + if selectedID.isEmpty || !sessions.contains(where: { $0.id == selectedID }) { + selectedID = sessions.first?.id ?? "" + } + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } + + private func loadBoard(quiet: Bool = false) async { + guard !selectedID.isEmpty else { + columns = [] + return + } + do { + let snapshot = try await model.client.board(sessionID: selectedID) + columns = snapshot.columns + if !quiet { errorMessage = nil } + } catch { + if !quiet || columns.isEmpty { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } + } + + private func remove(_ card: BoardCard) async { + guard let active else { return } + busyID = card.id + defer { busyID = nil } + errorMessage = nil + do { + _ = try await model.client.deleteBoardCard(sessionID: active.id, id: card.id) + await loadBoard() + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } + + private func clear() async { + guard let active else { return } + busyID = active.id + defer { busyID = nil } + errorMessage = nil + do { + _ = try await model.client.clearBoard(sessionID: active.id) + columns = [] + await loadSessions() + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } +} diff --git a/macos/Antares/Antares/Pages/ChannelsView.swift b/macos/Antares/Antares/Pages/ChannelsView.swift new file mode 100644 index 00000000..6f1ffba8 --- /dev/null +++ b/macos/Antares/Antares/Pages/ChannelsView.swift @@ -0,0 +1,256 @@ +import AntaresKit +import SwiftUI + +struct ChannelsView: View { + @EnvironmentObject private var model: AppModel + + @State private var channels: [ChannelInfo] = [] + @State private var pairings: [PairingInfo] = [] + @State private var errorMessage: String? + @State private var isLoading = false + @State private var busyID: String? + @State private var configFor: ChannelInfo? + @State private var fieldValues: [String: String] = [:] + @State private var revealedKeys: Set = [] + + var body: some View { + Group { + if let errorMessage, channels.isEmpty, pairings.isEmpty, !isLoading { + ContentUnavailableView( + "Channels", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else if !isLoading && channels.isEmpty && pairings.isEmpty { + ContentUnavailableView( + "No Channels", + systemImage: "antenna.radiowaves.left.and.right", + description: Text("Messaging channels will appear here.") + ) + } else { + List { + if let errorMessage, configFor == nil { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + } + + Section("Channels") { + ForEach(channels) { channel in + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(channel.label) + .font(.headline) + Spacer() + Text(statusLabel(channel)) + .font(.caption) + .foregroundStyle(channel.connected ? .green : .secondary) + } + if let botName = channel.botName, !botName.isEmpty, channel.connected { + Text(botName) + .font(.caption) + .foregroundStyle(.secondary) + } else if !channel.detail.isEmpty { + Text(channel.detail) + .font(.caption) + .foregroundStyle(.secondary) + } + Button(channel.configured ? "Change token" : "Connect") { + openConfig(channel) + } + } + } + } + + Section("Devices") { + if pairings.isEmpty { + Text("No paired devices.") + .foregroundStyle(.secondary) + } else { + ForEach(pairings) { pairing in + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(pairing.platform) + .font(.caption) + .foregroundStyle(.secondary) + Text(pairing.displayName.isEmpty ? pairing.externalID : pairing.displayName) + .font(.headline) + } + Text("\(pairing.externalID) · \(pairing.status)") + .font(.caption) + .fontDesign(.monospaced) + .foregroundStyle(.secondary) + } + Spacer() + if pairing.status == "pending" { + Button("Approve") { + Task { await setPairing(pairing, approve: true) } + } + .disabled(busyID != nil) + } else { + Button("Revoke", role: .destructive) { + Task { await setPairing(pairing, approve: false) } + } + .disabled(busyID != nil) + } + } + } + } + } + } + } + } + .overlay { + if isLoading { ProgressView("Loading channels…") } + } + .navigationTitle("Channels") + .sheet(item: $configFor) { channel in + NavigationStack { + Form { + if !channel.detail.isEmpty { + Text(channel.detail) + .foregroundStyle(.secondary) + } + + ForEach(channel.fields) { field in + fieldRow(field) + } + + if let docs = channel.docs, let url = URL(string: docs) { + Link("Get a token for \(channel.label)", destination: url) + } + + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + } + } + .navigationTitle(channel.configured ? "Change token" : "Connect \(channel.label)") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { configFor = nil } + } + ToolbarItem(placement: .confirmationAction) { + Button(busyID == channel.id ? "Saving…" : "Verify and save") { + Task { await saveConfig(channel) } + } + .disabled( + ChannelConfigForm.hasMissingRequired(channel.fields, values: fieldValues) + || busyID != nil + ) + } + } + } + .frame(minWidth: 460, minHeight: 360) + } + .task { await load() } + .refreshable { await load() } + } + + @ViewBuilder + private func fieldRow(_ field: ChannelField) -> some View { + let binding = Binding( + get: { fieldValues[field.key] ?? "" }, + set: { fieldValues[field.key] = $0 } + ) + let placeholder = field.placeholder ?? (field.set ? "••••••••" : "") + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(field.label) + if field.set { + Text("· set") + .foregroundStyle(.secondary) + } + } + HStack { + if field.secret && !revealedKeys.contains(field.key) { + SecureField(placeholder, text: binding) + .textContentType(.password) + } else { + TextField(placeholder, text: binding) + } + if field.secret { + Button(revealedKeys.contains(field.key) ? "Hide" : "Show") { + if revealedKeys.contains(field.key) { + revealedKeys.remove(field.key) + } else { + revealedKeys.insert(field.key) + } + } + .buttonStyle(.borderless) + } + } + } + } + + private func statusLabel(_ channel: ChannelInfo) -> String { + if channel.connected { return "Connected" } + if channel.enabled { return "Connecting" } + if channel.configured { return "Token set" } + return "Disabled" + } + + private func openConfig(_ channel: ChannelInfo) { + fieldValues = [:] + revealedKeys = [] + errorMessage = nil + configFor = channel + } + + private func load() async { + isLoading = channels.isEmpty && pairings.isEmpty + errorMessage = nil + do { + let response: ChannelsResponse = try await model.client.get("/api/channels") + channels = response.channels + pairings = response.pairings + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } + + private func saveConfig(_ channel: ChannelInfo) async { + let fields = ChannelConfigForm.collectedFields(fieldValues) + busyID = channel.id + defer { busyID = nil } + errorMessage = nil + do { + let result: ChannelMutationResponse = try await model.client.post( + "/api/channels/\(channel.id)/config", + body: ChannelConfigRequest( + fields: fields, + enabled: channel.configured ? nil : true + ) + ) + if result.ok { + configFor = nil + fieldValues = [:] + revealedKeys = [] + await load() + } else { + errorMessage = result.error ?? "Token rejected." + } + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } + + private func setPairing(_ pairing: PairingInfo, approve: Bool) async { + busyID = pairing.id + defer { busyID = nil } + errorMessage = nil + do { + let path = approve ? "/api/pairing/approve" : "/api/pairing/revoke" + let _: PairingInfo = try await model.client.post( + path, + body: PairingIDRequest(id: pairing.id) + ) + await load() + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } +} diff --git a/macos/Antares/Antares/Pages/ChatComposer.swift b/macos/Antares/Antares/Pages/ChatComposer.swift new file mode 100644 index 00000000..7486abf9 --- /dev/null +++ b/macos/Antares/Antares/Pages/ChatComposer.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct ChatComposer: View { + @Binding var text: String + var isStreaming: Bool + var isDisabled: Bool = false + var onSend: () -> Void + var onInterrupt: () -> Void + + private var canSend: Bool { + !isDisabled && !isStreaming && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + HStack(alignment: .bottom, spacing: 10) { + TextEditor(text: $text) + .font(.body) + .frame(minHeight: 52, maxHeight: 140) + .scrollContentBackground(.hidden) + .padding(8) + .background(.background, in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(.separator) + ) + .disabled(isDisabled || isStreaming) + .accessibilityLabel("Message") + + if isStreaming { + Button("Interrupt", role: .destructive, action: onInterrupt) + .keyboardShortcut(.cancelAction) + .disabled(isDisabled) + } else { + Button("Send", action: onSend) + .keyboardShortcut(.return, modifiers: .command) + .disabled(!canSend) + } + } + .padding(12) + .background(.bar) + } +} diff --git a/macos/Antares/Antares/Pages/ChatView.swift b/macos/Antares/Antares/Pages/ChatView.swift new file mode 100644 index 00000000..c25ae492 --- /dev/null +++ b/macos/Antares/Antares/Pages/ChatView.swift @@ -0,0 +1,354 @@ +import AntaresKit +import SwiftUI + +struct ChatView: View { + @EnvironmentObject private var model: AppModel + @StateObject private var chat = ChatController() + + var body: some View { + VStack(spacing: 0) { + transcript + if let errorMessage = chat.errorMessage { + Text(errorMessage) + .font(.callout) + .foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + ChatComposer( + text: $chat.draft, + isStreaming: chat.isStreaming, + isDisabled: chat.isLoading, + onSend: { chat.send() }, + onInterrupt: { chat.interrupt() } + ) + } + .navigationTitle(chat.title) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("New Chat") { + chat.newChat() + } + } + } + .task { + await chat.start(client: model.client) + } + } + + private var transcript: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 14) { + if chat.visibleMessages.isEmpty && !chat.isLoading && !chat.isStreaming { + ContentUnavailableView( + "Chat", + systemImage: "bubble.left.and.bubble.right", + description: Text("Send a message to start a conversation.") + ) + .frame(maxWidth: .infinity, minHeight: 240) + } + + ForEach(chat.visibleMessages) { message in + messageBlock(message) + .id(message.id) + } + + Color.clear + .frame(height: 1) + .id(ChatController.bottomID) + } + .padding(16) + } + .onChange(of: chat.scrollToken) { _, _ in + proxy.scrollTo(ChatController.bottomID, anchor: .bottom) + } + .overlay { + if chat.isLoading { + ProgressView("Loading conversation…") + } + } + } + } + + @ViewBuilder + private func messageBlock(_ message: ChatHistoryMessage) -> some View { + switch message.role { + case "user": + UserPromptView(text: message.content) + default: + VStack(alignment: .leading, spacing: 8) { + AssistantTextView( + text: message.content, + reasoning: message.reasoning, + error: message.error, + isStreamingPlaceholder: chat.isStreaming && message.id == ChatController.liveAssistantID + ) + ForEach(message.toolCalls) { call in + ToolCallRow(call: call) + } + if let notice = chat.live.notice, chat.isStreaming, message.id == ChatController.liveAssistantID { + Text(notice) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } +} + +@MainActor +final class ChatController: ObservableObject { + static let liveAssistantID = "live-assistant" + static let bottomID = "chat-bottom" + static let lastSessionKey = "antares.lastSession" + static let lastRoleKey = "antares.lastRole" + + @Published var draft = "" + @Published var messages: [ChatHistoryMessage] = [] + @Published var live = ChatTranscript() + @Published var isStreaming = false + @Published var isLoading = false + @Published var errorMessage: String? + @Published var sessionID: String? + @Published var title = "Chat" + @Published private(set) var scrollToken = 0 + + private var client: APIClient? + private var streamTask: Task? + private var streamGeneration: UInt64 = 0 + private var receivedStreamContent = false + + var visibleMessages: [ChatHistoryMessage] { + var rows = messages + if isStreaming { + rows.append( + ChatHistoryMessage( + id: Self.liveAssistantID, + role: "assistant", + content: live.assistantText, + reasoning: live.reasoning, + toolCalls: live.toolCalls, + error: live.error + ) + ) + } + return rows + } + + func start(client: APIClient) async { + self.client = client + if sessionID == nil { + sessionID = UserDefaults.standard.string(forKey: Self.lastSessionKey).flatMap { + $0.isEmpty ? nil : $0 + } + } + guard let sessionID else { return } + await loadSession(sessionID) + guard self.sessionID == sessionID, streamTask == nil, !isStreaming else { return } + let generation = bumpGeneration() + let task = Task { await attachLive(sessionID, generation: generation) } + streamTask = task + await task.value + } + + func send() { + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !isStreaming else { return } + draft = "" + errorMessage = nil + receivedStreamContent = false + messages.append( + ChatHistoryMessage(id: "local-user-\(UUID().uuidString)", role: "user", content: text) + ) + live = ChatTranscript() + isStreaming = true + bumpScroll() + let generation = bumpGeneration() + streamTask?.cancel() + streamTask = Task { await runStream(message: text, generation: generation) } + } + + func interrupt() { + guard isStreaming else { return } + let id = sessionID + bumpGeneration() + isStreaming = false + finishLiveIfNeeded() + streamTask?.cancel() + streamTask = nil + Task { + guard let id, let client else { return } + _ = try? await client.interruptChat(sessionID: id) + } + } + + func newChat() { + let id = sessionID + let wasStreaming = isStreaming + bumpGeneration() + streamTask?.cancel() + streamTask = nil + sessionID = nil + messages = [] + live = ChatTranscript() + title = "Chat" + errorMessage = nil + isStreaming = false + isLoading = false + receivedStreamContent = false + UserDefaults.standard.removeObject(forKey: Self.lastSessionKey) + if wasStreaming, let id { + Task { + guard let client else { return } + _ = try? await client.interruptChat(sessionID: id) + } + } + } + + private func runStream(message: String, generation: UInt64) async { + guard let client else { return } + do { + try await client.streamChat( + ChatSendRequest(sessionID: sessionID, message: message, role: currentRole()) + ) { [weak self] event in + await self?.applyLive(event, generation: generation) + } + } catch is CancellationError { + // Local interrupt; the interrupt POST is fired separately. + } catch { + guard isCurrent(generation), !Task.isCancelled else { return } + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + guard isCurrent(generation) else { return } + finishLiveIfNeeded() + if receivedStreamContent { + await refreshSession() + guard isCurrent(generation) else { return } + } + bumpScroll() + } + + private func loadSession(_ id: String) async { + guard let client else { return } + isLoading = true + defer { isLoading = false } + do { + let detail = try await client.getSession(id) + messages = hydrateChat(detail) + title = detail.session.title.isEmpty ? "Chat" : detail.session.title + persistSession(id) + await refreshSessionRole(id) + errorMessage = nil + bumpScroll() + } catch let error as APIError where error.status == 404 { + newChat() + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } + + private func attachLive(_ id: String, generation: UInt64) async { + guard let client else { return } + receivedStreamContent = false + live = ChatTranscript() + do { + try await client.attachChat(sessionID: id) { [weak self] event in + await self?.applyLive(event, generation: generation) + } + } catch is CancellationError { + return + } catch { + if Task.isCancelled || !isCurrent(generation) { return } + // No live turn, or the attach failed; keep hydrated history. + } + guard isCurrent(generation) else { return } + finishLiveIfNeeded() + await refreshSession() + guard isCurrent(generation) else { return } + bumpScroll() + } + + private func applyLive(_ event: [String: String], generation: UInt64) { + guard isCurrent(generation) else { return } + live.apply(event) + if event["type"] != "done" { + isStreaming = true + receivedStreamContent = true + } + if let id = live.sessionID, !id.isEmpty { + persistSession(id) + } + if let title = live.title, !title.isEmpty { + self.title = title + } + bumpScroll() + } + + private func finishLiveIfNeeded() { + if live.hasDisplayableContent { + messages.append( + ChatHistoryMessage( + id: "asst-\(UUID().uuidString)", + role: "assistant", + content: live.assistantText, + reasoning: live.reasoning, + toolCalls: live.toolCalls, + error: live.error + ) + ) + } + live = ChatTranscript() + isStreaming = false + } + + @discardableResult + private func bumpGeneration() -> UInt64 { + streamGeneration += 1 + return streamGeneration + } + + private func isCurrent(_ generation: UInt64) -> Bool { + generation == streamGeneration + } + + private func refreshSession() async { + guard let client, let sessionID else { return } + do { + let detail = try await client.getSession(sessionID) + messages = hydrateChat(detail) + if !detail.session.title.isEmpty { + title = detail.session.title + } + } catch { + // Keep the locally assembled transcript if refresh fails. + } + } + + private func persistSession(_ id: String) { + sessionID = id + UserDefaults.standard.set(id, forKey: Self.lastSessionKey) + } + + private func currentRole() -> String? { + let role = UserDefaults.standard.string(forKey: Self.lastRoleKey) ?? "" + return role.isEmpty ? nil : role + } + + private func refreshSessionRole(_ id: String) async { + guard let client else { return } + do { + let response = try await client.sessionRole(id) + if !response.role.isEmpty { + UserDefaults.standard.set(response.role, forKey: Self.lastRoleKey) + } + } catch { + // Keep the remembered last-used role if the session has none yet. + } + } + + private func bumpScroll() { + scrollToken += 1 + } +} diff --git a/macos/Antares/Antares/Pages/ConfigView.swift b/macos/Antares/Antares/Pages/ConfigView.swift new file mode 100644 index 00000000..fa6c5c65 --- /dev/null +++ b/macos/Antares/Antares/Pages/ConfigView.swift @@ -0,0 +1,460 @@ +import AntaresKit +import SwiftUI + +struct ConfigView: View { + private static let essentialsID = "__essentials" + private static let yamlID = "__yaml" + + @EnvironmentObject private var model: AppModel + + @State private var response: ConfigResponse? + @State private var edits: [String: ConfigJSON] = [:] + @State private var listDrafts: [String: String] = [:] + @State private var revealed = Set() + @State private var section = ConfigView.essentialsID + @State private var filter = "" + @State private var showAdvanced = false + @State private var yaml = "" + @State private var yamlDraft: String? + @State private var restartFields: [String] = [] + @State private var errorMessage: String? + @State private var statusMessage: String? + @State private var isLoading = false + @State private var isSaving = false + @State private var didLoad = false + + private var fields: [ConfigField] { + response?.formFields ?? [] + } + + private var groups: [String] { + var seen: [String] = [] + for field in fields where !seen.contains(field.group) { + seen.append(field.group) + } + return seen + } + + private var query: String { + filter.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private var isSearching: Bool { !query.isEmpty } + + private var visibleFields: [ConfigField] { + if isSearching { + return fields.filter { + $0.path.lowercased().contains(query) || $0.label.lowercased().contains(query) + } + } + let sectionFields: [ConfigField] + if section == Self.essentialsID { + sectionFields = fields.filter { $0.tier == "essential" } + } else { + sectionFields = fields.filter { $0.group == section } + } + if showAdvanced { + return sectionFields + } + return sectionFields.filter { $0.tier != "advanced" } + } + + private var hiddenAdvancedCount: Int { + guard !isSearching, section != Self.yamlID else { return 0 } + let sectionFields: [ConfigField] + if section == Self.essentialsID { + sectionFields = fields.filter { $0.tier == "essential" } + } else { + sectionFields = fields.filter { $0.group == section } + } + return max(0, sectionFields.count - visibleFields.count) + } + + private var dirtyCount: Int { edits.count } + private var yamlDirty: Bool { yamlDraft != nil } + + var body: some View { + Group { + if let errorMessage, !didLoad, !isLoading { + ContentUnavailableView( + "Config", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else { + formContent + } + } + .overlay { + if isLoading { ProgressView("Loading config…") } + } + .navigationTitle("Config") + .searchable(text: $filter, prompt: "Search settings") + .toolbar { + ToolbarItem(placement: .principal) { + if !isSearching { + Picker("Section", selection: $section) { + Text("Essentials").tag(Self.essentialsID) + ForEach(groups, id: \.self) { group in + Text(humanizeGroup(group)).tag(group) + } + Text("YAML").tag(Self.yamlID) + } + .pickerStyle(.menu) + .frame(maxWidth: 240) + } + } + ToolbarItem(placement: .primaryAction) { + if section == Self.yamlID, !isSearching { + Button(isSaving ? "Saving…" : "Save YAML") { + Task { await saveYAML() } + } + .disabled(!yamlDirty || isSaving || isLoading) + } else { + Button(saveLabel) { + Task { await save() } + } + .disabled(dirtyCount == 0 || isSaving || isLoading) + } + } + } + .onChange(of: section) { _, newValue in + showAdvanced = false + if newValue == Self.yamlID { + Task { await loadRaw(quiet: yaml.isEmpty == false) } + } + } + .task { await load() } + .refreshable { await load() } + } + + @ViewBuilder + private var formContent: some View { + Form { + if let errorMessage, didLoad { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + } + if let statusMessage { + Section { + Text(statusMessage) + .foregroundStyle(.secondary) + } + } + if !restartFields.isEmpty { + Section { + Text("Restart required for: \(restartFields.joined(separator: ", "))") + } + } + + if isSearching { + searchSection + } else if section == Self.yamlID { + yamlSection + } else { + settingsSection + } + } + .formStyle(.grouped) + } + + @ViewBuilder + private var searchSection: some View { + Section(visibleFields.isEmpty ? "No matches" : "\(visibleFields.count) matches") { + if visibleFields.isEmpty { + Text("Nothing matched the filter.") + .foregroundStyle(.secondary) + } else { + ForEach(visibleFields) { field in + fieldRow(field, showGroup: true) + } + } + } + } + + @ViewBuilder + private var settingsSection: some View { + let title = section == Self.essentialsID ? "Essentials" : humanizeGroup(section) + Section { + if section == Self.essentialsID { + Text("Core settings needed to get Antares answering.") + .foregroundStyle(.secondary) + } + if visibleFields.isEmpty { + Text("Nothing here.") + .foregroundStyle(.secondary) + } else { + ForEach(visibleFields) { field in + fieldRow(field, showGroup: section == Self.essentialsID) + } + } + if hiddenAdvancedCount > 0 { + Button("Show \(hiddenAdvancedCount) advanced") { + showAdvanced = true + } + } else if showAdvanced { + Button("Hide advanced") { + showAdvanced = false + } + } + } header: { + Text(title) + } + } + + @ViewBuilder + private var yamlSection: some View { + Section("Raw YAML") { + Text("Direct edits write the whole config file.") + .foregroundStyle(.secondary) + TextEditor(text: Binding( + get: { yamlDraft ?? yaml }, + set: { yamlDraft = $0 } + )) + .font(.body.monospaced()) + .frame(minHeight: 360) + .disabled(isLoading || isSaving) + if let path = response?.path, !path.isEmpty { + Text(path) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } + + @ViewBuilder + private func fieldRow(_ field: ConfigField, showGroup: Bool) -> some View { + let dirty = edits[field.path] != nil + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(field.label) + .fontWeight(dirty ? .semibold : .regular) + .foregroundStyle(dirty ? Color.accentColor : Color.primary) + if showGroup { + Text(humanizeGroup(field.group)) + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.quaternary, in: Capsule()) + } + if dirty { + Text("Changed") + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + if let reload = field.reload, !reload.isEmpty { + Text(reloadLabel(reload)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + Text(field.path) + .font(.caption2.monospaced()) + .foregroundStyle(.tertiary) + if let help = field.help, !help.isEmpty { + Text(help) + .font(.caption) + .foregroundStyle(.secondary) + } + control(for: field) + } + .padding(.vertical, 4) + } + + @ViewBuilder + private func control(for field: ConfigField) -> some View { + let current = valueOf(field) + if field.type == .boolean { + Toggle(field.label, isOn: Binding( + get: { current.boolValue }, + set: { setValue(field.path, .bool($0)) } + )) + .labelsHidden() + } else if let options = field.enumValues, !options.isEmpty { + Picker(field.label, selection: Binding( + get: { current.stringValue }, + set: { setValue(field.path, .string($0)) } + )) { + ForEach(options, id: \.self) { option in + Text(option).tag(option) + } + } + .labelsHidden() + } else if field.type == .stringArray { + TextField("Comma-separated", text: listBinding(for: field, current: current)) + } else if field.secret { + HStack { + if revealed.contains(field.path) { + TextField("Not set", text: stringBinding(for: field, current: current)) + } else { + SecureField("Not set", text: stringBinding(for: field, current: current)) + } + Button { + if revealed.contains(field.path) { + revealed.remove(field.path) + } else { + revealed.insert(field.path) + } + } label: { + Image(systemName: revealed.contains(field.path) ? "eye.slash" : "eye") + } + .buttonStyle(.borderless) + .help("Reveal") + } + } else if field.type == .number { + TextField("0", text: Binding( + get: { current.stringValue }, + set: { text in + if let intValue = Int(text) { + setValue(field.path, .int(intValue)) + } else if let doubleValue = Double(text) { + setValue(field.path, .double(doubleValue)) + } else { + setValue(field.path, .string(text)) + } + } + )) + } else { + TextField("", text: stringBinding(for: field, current: current)) + } + } + + private var saveLabel: String { + if isSaving { return "Saving…" } + if dirtyCount > 0 { return "Save \(dirtyCount)" } + return "Save" + } + + private func valueOf(_ field: ConfigField) -> ConfigJSON { + if let edited = edits[field.path] { + return edited + } + if let value = response?.value(at: field.path) { + return value.configJSON + } + return field.defaultValue?.configJSON ?? .null + } + + private func setValue(_ path: String, _ value: ConfigJSON) { + edits[path] = value + statusMessage = nil + } + + private func stringBinding(for field: ConfigField, current: ConfigJSON) -> Binding { + Binding( + get: { current.stringValue }, + set: { setValue(field.path, .string($0)) } + ) + } + + private func listBinding(for field: ConfigField, current: ConfigJSON) -> Binding { + Binding( + get: { + if let draft = listDrafts[field.path] { + return draft + } + return current.stringArray.joined(separator: ", ") + }, + set: { text in + listDrafts[field.path] = text + let parsed = text.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } + setValue(field.path, .strings(parsed)) + } + ) + } + + private func humanizeGroup(_ name: String) -> String { + let acronyms = ["rag": "RAG", "mcp": "MCP", "yaml": "YAML", "dsn": "DSN", "api": "API"] + return name.split(separator: "_").enumerated().map { index, part in + let key = part.lowercased() + if let acronym = acronyms[key] { return acronym } + let word = String(part) + if index == 0 { + return word.prefix(1).uppercased() + word.dropFirst() + } + return word + }.joined(separator: " ") + } + + private func reloadLabel(_ reload: String) -> String { + switch reload { + case "restart_required": + return "Restart" + case "reconciled": + return "Reconciled" + default: + return "Live" + } + } + + private func load() async { + isLoading = !didLoad + errorMessage = nil + do { + let loaded = try await model.client.getConfig() + response = loaded + restartFields = loaded.restartFields + edits = [:] + listDrafts = [:] + didLoad = true + if section == Self.yamlID { + await loadRaw(quiet: true) + } + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } + + private func loadRaw(quiet: Bool) async { + if !quiet { isLoading = yaml.isEmpty } + do { + let raw = try await model.client.getRawConfig() + yaml = raw.yaml + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + if !quiet { isLoading = false } + } + + private func save() async { + let payload = edits.filter { !$0.value.isMaskedSecret } + guard !payload.isEmpty else { return } + isSaving = true + errorMessage = nil + statusMessage = nil + do { + let result = try await model.client.updateConfig(payload) + restartFields = result.restartFields + edits = [:] + listDrafts = [:] + statusMessage = "Saved." + await load() + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isSaving = false + } + + private func saveYAML() async { + guard let yamlDraft else { return } + isSaving = true + errorMessage = nil + statusMessage = nil + do { + let result = try await model.client.saveRawConfig(yamlDraft) + restartFields = result.restartFields + yaml = yamlDraft + self.yamlDraft = nil + statusMessage = "Saved YAML." + await load() + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isSaving = false + } +} diff --git a/macos/Antares/Antares/Pages/ContentCreatorView.swift b/macos/Antares/Antares/Pages/ContentCreatorView.swift new file mode 100644 index 00000000..2bcec880 --- /dev/null +++ b/macos/Antares/Antares/Pages/ContentCreatorView.swift @@ -0,0 +1,1191 @@ +import AntaresKit +import AVKit +import AppKit +import SwiftUI + +struct ContentCreatorView: View { + private static let platforms = ["youtube", "tiktok", "instagram", "facebook", "x", "threads"] + private static let sizes = ["720x1280", "1280x720", "1024x1792", "1792x1024"] + private static let stages = ["research", "plan", "produce", "publish", "full"] + private static let kinds = ["character", "setting", "style", "prop"] + + private enum Tab: String, CaseIterable, Identifiable { + case brief, references, shots, research, publish, schedule, models + var id: String { rawValue } + var title: String { rawValue.capitalized } + } + + @EnvironmentObject private var model: AppModel + + @State private var projects: [CreatorProject] = [] + @State private var project: CreatorProject? + @State private var accounts: [SocialAccount] = [] + @State private var jobs: [CronJob] = [] + @State private var settings: CreatorSettings? + @State private var imagePatch = CreatorProviderPatch() + @State private var videoPatch = CreatorProviderPatch() + @State private var tab: Tab = .brief + @State private var errorMessage: String? + @State private var isLoading = false + @State private var busy = "" + @State private var dirty = false + @State private var showingCreate = false + @State private var proof = "" + @State private var scheduleName = "" + @State private var scheduleCron = "0 8 * * *" + @State private var scheduleStage = "research" + @State private var schedulePublish = "draft" + @State private var pendingReset: (kind: String, id: String)? + @State private var pendingStage: String? + @State private var pendingSwitchID: String? + @State private var confirmPublishSchedule = false + + private var selectedID: String { project?.id ?? "" } + private var locked: Bool { !busy.isEmpty || project?.isRunning == true } + private var mediaLocked: Bool { locked || dirty } + + var body: some View { + Group { + if let errorMessage, projects.isEmpty, project == nil, !isLoading, tab != .models { + ContentUnavailableView( + "Content Creator", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else { + VStack(alignment: .leading, spacing: 0) { + Picker("Tab", selection: $tab) { + ForEach(Tab.allCases) { item in + Text(item.title).tag(item) + } + } + .pickerStyle(.segmented) + .padding(12) + + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + + if tab == .models { + modelsForm + } else { + HSplitView { + projectList + .frame(minWidth: 200, idealWidth: 240) + detail + .frame(minWidth: 420) + } + } + } + } + } + .overlay { + if isLoading { ProgressView("Loading video projects…") } + } + .navigationTitle("Content creator") + .toolbar { + ToolbarItem(placement: .automatic) { + Button("Refresh") { + Task { await refreshSelected() } + } + .disabled(project == nil || dirty || !busy.isEmpty) + } + ToolbarItem(placement: .automatic) { + Button("Save plan") { + Task { await save() } + } + .disabled(project == nil || locked || !dirty) + } + ToolbarItem(placement: .primaryAction) { + Button("New video") { showingCreate = true } + } + } + .sheet(isPresented: $showingCreate) { + CreateCreatorProjectSheet { created in + accept(created) + } + .environmentObject(model) + } + .confirmationDialog( + "Discard this generated asset?", + isPresented: Binding( + get: { pendingReset != nil }, + set: { if !$0 { pendingReset = nil } } + ), + titleVisibility: .visible + ) { + Button("Reset", role: .destructive) { + if let pendingReset { + Task { await reset(pendingReset.kind, pendingReset.id) } + } + } + Button("Cancel", role: .cancel) { pendingReset = nil } + } message: { + Text("Regeneration may incur provider charges. For an uncertain submission, first verify the provider did not already accept a job.") + } + .confirmationDialog( + "Allow this run to publish?", + isPresented: Binding( + get: { pendingStage != nil }, + set: { if !$0 { pendingStage = nil } } + ), + titleVisibility: .visible + ) { + Button("Publish") { + if let pendingStage { + Task { await run(pendingStage, confirmed: true) } + } + } + Button("Cancel", role: .cancel) { pendingStage = nil } + } message: { + Text("Allow this run to publish the final video to the selected social account?") + } + .confirmationDialog( + "Discard unsaved changes?", + isPresented: Binding( + get: { pendingSwitchID != nil }, + set: { if !$0 { pendingSwitchID = nil } } + ), + titleVisibility: .visible + ) { + Button("Discard", role: .destructive) { + if let pendingSwitchID { + Task { await choose(pendingSwitchID, force: true) } + } + } + Button("Cancel", role: .cancel) { pendingSwitchID = nil } + } + .confirmationDialog( + "Authorize publishing?", + isPresented: $confirmPublishSchedule, + titleVisibility: .visible + ) { + Button("Authorize") { + Task { await createSchedule(confirmed: true) } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Authorize this recurring job to publish to the project account?") + } + .task { await load() } + .task(id: selectedID) { + guard !selectedID.isEmpty else { return } + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(5)) + if Task.isCancelled || dirty || !busy.isEmpty { continue } + await refreshSelected(quiet: true) + } + } + .refreshable { await load() } + } + + private var projectList: some View { + List(selection: Binding( + get: { project?.id }, + set: { id in + if let id { Task { await choose(id) } } + } + )) { + Section("Video projects") { + if projects.isEmpty { + Text("No projects yet. Create a video to start.") + .foregroundStyle(.secondary) + } else { + ForEach(projects) { item in + VStack(alignment: .leading, spacing: 4) { + Text(item.title) + .font(.headline) + Text("\(item.platform) · \(item.status)") + .font(.caption) + .foregroundStyle(.secondary) + } + .tag(item.id) + } + } + } + } + } + + @ViewBuilder + private var detail: some View { + if let project { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + header(project) + switch tab { + case .brief: briefTab(project) + case .references: referencesTab(project) + case .shots: shotsTab(project) + case .research: researchTab(project) + case .publish: publishTab(project) + case .schedule: scheduleTab(project) + case .models: EmptyView() + } + } + .padding(16) + } + } else { + ContentUnavailableView( + "Create your first video", + systemImage: "film", + description: Text("Start with a brief, then plan references and shots. Configure image and video models before production.") + ) + } + } + + private func header(_ project: CreatorProject) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(project.title) + .font(.title2.weight(.semibold)) + Text(statusLine(project)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + inlineError(project.error.isEmpty ? project.lastRunError : project.error) + HStack { + ForEach(Self.stages, id: \.self) { stage in + Button("Run \(stage)") { + Task { await run(stage) } + } + .disabled( + mediaLocked + || (stage == "publish" && project.publishMode != "auto") + ) + } + } + if !project.runSessionID.isEmpty { + Text("Agent run \(project.runSessionID)") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + if !busy.isEmpty { + Text("Working: \(busy). Media generation may take several minutes.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private func briefTab(_ project: CreatorProject) -> some View { + Form { + TextField("Title", text: stringBinding(\.title)) + .disabled(locked) + TextField("Target duration (seconds)", value: intBinding(\.targetSeconds), format: .number) + .disabled(locked) + LabeledContent("Creative brief") { + TextEditor(text: stringBinding(\.brief)) + .font(.body) + .frame(minHeight: 90) + .disabled(locked) + } + LabeledContent("Visual style") { + TextEditor(text: stringBinding(\.style)) + .font(.body) + .frame(minHeight: 60) + .disabled(locked) + } + TextField("Resolution", text: stringBinding(\.size)) + .disabled(locked) + } + .formStyle(.grouped) + } + + private func referencesTab(_ project: CreatorProject) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("Generate each character, setting, prop, or style once. Shots reuse these IDs for continuity.") + .foregroundStyle(.secondary) + ForEach(project.references) { ref in + referenceCard(ref) + } + Button("Add reference") { + edit { + $0.references.append( + CreatorReference(id: uid("ref_")) + ) + } + } + .disabled(locked) + } + } + + private func referenceCard(_ ref: CreatorReference) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 12) { + artifactImage(ref.path, label: ref.name.isEmpty ? ref.id : ref.name) + VStack(alignment: .leading, spacing: 8) { + Text("\(ref.id) · \(ref.status)") + .font(.caption) + .foregroundStyle(.secondary) + TextField("Name", text: refString(ref.id, \.name)) + .disabled(locked) + Picker("Kind", selection: refString(ref.id, \.kind)) { + ForEach(Self.kinds, id: \.self) { kind in + Text(kind).tag(kind) + } + } + .disabled(locked) + } + } + TextEditor(text: refString(ref.id, \.prompt)) + .font(.body) + .frame(minHeight: 60) + .disabled(locked) + inlineError(ref.error) + HStack { + Button("Generate") { + Task { await media("generate_reference", ref.id) } + } + .disabled(mediaLocked || ref.status == "ready") + Button("Reset") { pendingReset = ("reset_reference", ref.id) } + .disabled(mediaLocked) + Button("Remove", role: .destructive) { + edit { $0.references.removeAll { $0.id == ref.id } } + } + .disabled(locked) + } + } + .padding(12) + .background(.background, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.2))) + } + + private func shotsTab(_ project: CreatorProject) -> some View { + let planned = project.shots.reduce(0) { $0 + $1.durationSeconds } + return VStack(alignment: .leading, spacing: 12) { + Text("\(planned) planned seconds / \(project.targetSeconds) target. A continuation starts from the previous clip’s last frame.") + .foregroundStyle(.secondary) + ForEach(Array(project.shots.enumerated()), id: \.element.id) { index, shot in + shotCard(shot, index: index, total: project.shots.count) + } + HStack { + Button("Add shot") { + edit { + $0.shots.append( + CreatorShot( + id: uid("shot_"), + referenceIDs: $0.references.map(\.id), + durationSeconds: settings?.video.seconds ?? 8, + continuity: $0.shots.isEmpty ? "cut" : "continue" + ) + ) + } + } + .disabled(locked) + Button("Assemble final video") { + Task { await media("assemble", nil) } + } + .disabled( + mediaLocked + || project.shots.isEmpty + || project.shots.contains { $0.status != "ready" } + ) + } + if !project.finalPath.isEmpty { + artifactVideo(project.finalPath) + } + } + } + + private func shotCard(_ shot: CreatorShot, index: Int, total: Int) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Shot \(index + 1) · \(shot.status)\(shot.videoJobID.isEmpty ? "" : " (\(shot.progress)%)")") + .font(.headline) + Spacer() + Button("Move up") { moveShot(index, -1) } + .disabled(locked || index == 0) + Button("Move down") { moveShot(index, 1) } + .disabled(locked || index == total - 1) + } + TextEditor(text: shotString(shot.id, \.prompt)) + .font(.body) + .frame(minHeight: 60) + .disabled(locked) + HStack { + TextField("Clip seconds", value: shotInt(shot.id, \.durationSeconds), format: .number) + .disabled(locked) + Picker("Continuity", selection: shotString(shot.id, \.continuity)) { + Text("New composition").tag("cut") + Text("Continue previous clip").tag("continue") + } + .disabled(locked || index == 0) + TextField("Notes", text: shotString(shot.id, \.notes)) + .disabled(locked) + } + if let project, !project.references.isEmpty { + Text("Visual references") + .font(.caption) + .foregroundStyle(.secondary) + ForEach(project.references) { ref in + Toggle(ref.name.isEmpty ? ref.id : ref.name, isOn: shotHasRef(shot.id, ref.id)) + .disabled(locked) + } + } + HStack(alignment: .top) { + artifactImage(shot.keyframePath, label: "Keyframe") + artifactVideo(shot.videoPath) + artifactImage(shot.lastFramePath, label: "Last frame") + } + inlineError(shot.error) + HStack { + Button("Create keyframe") { + Task { await media("generate_keyframe", shot.id) } + } + .disabled(mediaLocked || !shot.keyframePath.isEmpty) + Button("Generate clip") { + Task { await media("generate_video", shot.id) } + } + .disabled(mediaLocked || !shot.videoJobID.isEmpty || shot.status == "submission_unknown") + Button("Check render") { + Task { await media("poll_video", shot.id) } + } + .disabled(mediaLocked || shot.videoJobID.isEmpty || shot.status == "ready") + Button("Reset shot") { pendingReset = ("reset_shot", shot.id) } + .disabled(mediaLocked) + Button("Remove", role: .destructive) { + edit { $0.shots.removeAll { $0.id == shot.id } } + } + .disabled(locked) + } + } + .padding(12) + .background(.background, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.2))) + } + + private func researchTab(_ project: CreatorProject) -> some View { + VStack(alignment: .leading, spacing: 16) { + Text("Observed trends") + .font(.headline) + if project.research.isEmpty { + Text("Run research to collect source URLs, observation times, and available metrics.") + .foregroundStyle(.secondary) + } else { + ForEach(project.research) { trend in + VStack(alignment: .leading, spacing: 4) { + if let url = URL(string: trend.url), !trend.url.isEmpty { + Button(trend.title.isEmpty ? trend.url : trend.title) { + NSWorkspace.shared.open(url) + } + .buttonStyle(.link) + } else { + Text(trend.title) + } + Text("Observed \(trend.observedAt)") + .font(.caption) + .foregroundStyle(.secondary) + if !trend.notes.isEmpty { + Text(trend.notes) + } + if let metrics = trend.metrics, !metrics.isEmpty { + Text(metrics.map { "\($0.key): \(formatMetric($0.value))" }.joined(separator: " · ")) + .font(.caption) + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.background, in: RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.2))) + } + } + + Text("Original ideas") + .font(.headline) + if project.ideas.isEmpty { + Text("Run plan after research to propose ideas and build the shot list.") + .foregroundStyle(.secondary) + } else { + ForEach(project.ideas) { idea in + Button { + edit { + $0.ideas = $0.ideas.map { item in + var next = item + next.selected = item.id == idea.id + return next + } + } + } label: { + HStack(alignment: .top) { + Image(systemName: idea.selected ? "largecircle.fill.circle" : "circle") + VStack(alignment: .leading, spacing: 4) { + Text(idea.title).font(.headline) + Text(idea.hook) + Text(idea.rationale) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + } + .buttonStyle(.plain) + .disabled(locked) + } + } + } + } + + private func publishTab(_ project: CreatorProject) -> some View { + let matching = accounts.filter { $0.platform == project.platform } + return VStack(alignment: .leading, spacing: 12) { + Picker("Platform", selection: platformBinding) { + ForEach(Self.platforms, id: \.self) { item in + Text(item).tag(item) + } + } + .disabled(locked) + + Picker("Existing social account", selection: stringBinding(\.accountID)) { + Text("Select a connected account").tag("") + ForEach(matching) { account in + Text("\(account.label) (\(account.status))").tag(account.id) + } + } + .disabled(locked) + + Text("Manage accounts and browser in Social media.") + .font(.caption) + .foregroundStyle(.secondary) + + LabeledContent("Caption") { + TextEditor(text: stringBinding(\.caption)) + .frame(minHeight: 80) + .disabled(locked) + } + + Picker("Publishing permission", selection: stringBinding(\.publishMode)) { + Text("Draft only").tag("draft") + Text("Allow publishing to selected account").tag("auto") + } + .disabled(locked) + + Text("Publication: \(project.publication.status)") + inlineError(project.publication.error) + if let url = URL(string: project.publication.postURL), !project.publication.postURL.isEmpty { + Button("Open published video") { NSWorkspace.shared.open(url) } + .buttonStyle(.link) + } + if project.finalPath.isEmpty { + Text("Assemble the shots before uploading.") + .foregroundStyle(.secondary) + } else { + artifactVideo(project.finalPath) + Button("Download final MP4") { + NSWorkspace.shared.open(model.client.creatorArtifactURL(projectID: project.id, path: project.finalPath)) + } + } + if project.publication.status == "blocked" || project.publication.status == "uploading" { + VStack(alignment: .leading, spacing: 8) { + Text("An uncertain upload must be checked on the account before allowing another attempt.") + TextEditor(text: $proof) + .frame(minHeight: 70) + Button("Confirm not published") { + Task { await reconcile() } + } + .disabled(locked || proof.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .padding(12) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))) + } + } + } + + private func scheduleTab(_ project: CreatorProject) -> some View { + let linked = jobs.filter { $0.contentProjectID == project.id } + return VStack(alignment: .leading, spacing: 12) { + Form { + TextField("Schedule name", text: $scheduleName) + TextField("Cron expression", text: $scheduleCron) + .fontDesign(.monospaced) + Picker("Stage", selection: $scheduleStage) { + ForEach(Self.stages, id: \.self) { stage in + Text(stage).tag(stage) + } + } + Picker("Publishing mode", selection: $schedulePublish) { + Text("Draft only").tag("draft") + Text("Allow publishing").tag("auto") + } + Button("Create schedule") { + Task { await createSchedule() } + } + .disabled( + !busy.isEmpty + || scheduleName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || (scheduleStage == "publish" && schedulePublish == "draft") + ) + } + .formStyle(.grouped) + + ForEach(linked) { job in + HStack { + VStack(alignment: .leading) { + Text(job.name).font(.headline) + Text("\(job.schedule) · \(job.contentStage ?? "") · \(job.publishMode ?? "") · \(job.enabled ? "enabled" : "disabled")") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button("Remove", role: .destructive) { + Task { await removeSchedule(job) } + } + .disabled(!busy.isEmpty) + } + .padding(10) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.2))) + } + } + } + + private var modelsForm: some View { + Form { + Text("OpenAI-compatible image generation/edits and asynchronous video endpoints. Changing a provider does not migrate in-flight video jobs.") + .foregroundStyle(.secondary) + providerFields("Image model", patch: $imagePatch, hasKey: settings?.image.hasKey ?? false, video: false) + providerFields("Video model", patch: $videoPatch, hasKey: settings?.video.hasKey ?? false, video: true) + Text("FFmpeg: \(settings?.ffmpeg == true ? "available" : "not installed") · FFprobe: \(settings?.ffprobe == true ? "available" : "not installed")") + Button("Save media settings") { + Task { await saveSettings() } + } + .disabled(!busy.isEmpty) + } + .formStyle(.grouped) + } + + private func providerFields( + _ title: String, + patch: Binding, + hasKey: Bool, + video: Bool + ) -> some View { + Section(title) { + Toggle("Enable \(video ? "video" : "image") generation", isOn: boolPatch(patch, \.enabled)) + Text(hasKey ? "Credential configured" : "No credential configured") + .foregroundStyle(.secondary) + TextField("Provider ID (optional)", text: stringPatch(patch, \.provider)) + TextField("Model ID", text: stringPatch(patch, \.model)) + TextField("Base URL", text: stringPatch(patch, \.baseURL)) + SecureField("API key (blank keeps current)", text: stringPatch(patch, \.apiKey)) + TextField("Default resolution", text: stringPatch(patch, \.size)) + if video { + TextField("Default clip seconds", value: intPatch(patch, \.seconds), format: .number) + } + } + } + + @ViewBuilder + private func artifactImage(_ path: String, label: String) -> some View { + if path.isEmpty { + RoundedRectangle(cornerRadius: 8) + .fill(.quaternary) + .frame(width: 140, height: 100) + .overlay { + Text("Not generated") + .font(.caption) + .foregroundStyle(.secondary) + } + } else if let project { + CreatorArtifactImage(projectID: project.id, path: path, label: label) + .environmentObject(model) + } + } + + @ViewBuilder + private func artifactVideo(_ path: String) -> some View { + if !path.isEmpty, let project { + CreatorArtifactVideo(url: model.client.creatorArtifactURL(projectID: project.id, path: path)) + } + } + + @ViewBuilder + private func inlineError(_ text: String) -> some View { + if !text.isEmpty { + Text(text) + .font(.caption) + .foregroundStyle(.red) + .textSelection(.enabled) + } + } + + private func statusLine(_ project: CreatorProject) -> String { + let status = project.isRunning ? "Running \(project.runStage)" : project.status + return "\(status) · revision \(project.revision)\(dirty ? " · unsaved changes" : "")" + } + + private func load() async { + isLoading = projects.isEmpty && project == nil + errorMessage = nil + do { + async let listed = model.client.listCreatorProjects() + async let loadedSettings = model.client.creatorSettings() + async let loadedAccounts = model.client.listSocialAccounts() + async let loadedJobs = model.client.listCronJobs() + let response = try await listed + settings = try await loadedSettings + accounts = try await loadedAccounts + jobs = try await loadedJobs.jobs + if let settings { + imagePatch = settings.image.patch + videoPatch = settings.video.patch + } + projects = response.projects + if let current = project, let latest = projects.first(where: { $0.id == current.id }) { + if !dirty && busy.isEmpty { + project = try await model.client.creatorProject(latest.id) + } + } else if let first = projects.first { + project = try await model.client.creatorProject(first.id) + dirty = false + } + } catch { + errorMessage = describe(error) + } + isLoading = false + } + + private func choose(_ id: String, force: Bool = false) async { + pendingSwitchID = nil + if dirty && !force { + pendingSwitchID = id + return + } + await act("load") { + project = try await model.client.creatorProject(id) + dirty = false + } + } + + private func refreshSelected(quiet: Bool = false) async { + guard let id = project?.id, !dirty, busy.isEmpty else { return } + do { + let latest = try await model.client.creatorProject(id) + if selectedID == id, !dirty, busy.isEmpty { + project = latest + accept(latest) + } + } catch { + if !quiet { errorMessage = describe(error) } + } + } + + private func save() async { + guard let project else { return } + await act("save") { + accept(try await model.client.updateCreatorProject(project)) + } + } + + private func media(_ action: String, _ targetID: String?) async { + guard let project else { return } + if dirty { + errorMessage = "Save your plan before generating media." + return + } + await act(action) { + accept(try await model.client.creatorAction( + project.id, + CreatorActionRequest(action: action, targetID: targetID) + )) + } + } + + private func reset(_ kind: String, _ target: String) async { + pendingReset = nil + guard let project else { return } + await act(kind) { + accept(try await model.client.creatorAction( + project.id, + CreatorActionRequest(action: kind, targetID: target, confirmed: true) + )) + } + } + + private func run(_ stage: String, confirmed: Bool = false) async { + pendingStage = nil + guard let project else { return } + if dirty { + errorMessage = "Save your plan first." + return + } + if (stage == "publish" || stage == "full") && project.publishMode == "auto" && !confirmed { + pendingStage = stage + return + } + await act("run-\(stage)") { + let result = try await model.client.runCreatorProject( + project.id, + stage: stage, + publishMode: project.publishMode + ) + var latest = try await model.client.creatorProject(project.id) + latest.runSessionID = result.sessionID + accept(latest) + } + } + + private func reconcile() async { + guard let project else { return } + await act("reconcile publication") { + accept(try await model.client.reconcileCreatorProject(project.id, proof: proof)) + proof = "" + } + } + + private func createSchedule(confirmed: Bool = false) async { + guard let project else { return } + if schedulePublish == "auto" && !confirmed { + confirmPublishSchedule = true + return + } + confirmPublishSchedule = false + await act("schedule") { + _ = try await model.client.createCreatorCronJob( + CreatorCronJobRequest( + name: scheduleName.trimmingCharacters(in: .whitespacesAndNewlines), + schedule: scheduleCron.trimmingCharacters(in: .whitespacesAndNewlines), + role: "content-creator", + contentProjectID: project.id, + contentStage: scheduleStage, + publishMode: schedulePublish + ) + ) + jobs = try await model.client.listCronJobs().jobs + scheduleName = "" + } + } + + private func removeSchedule(_ job: CronJob) async { + await act("remove schedule") { + let _: DeletedFlagResponse = try await model.client.delete("/api/cron/jobs/\(job.id)") + jobs = try await model.client.listCronJobs().jobs + } + } + + private func saveSettings() async { + await act("settings") { + let saved = try await model.client.saveCreatorSettings( + CreatorSettingsPatch(image: imagePatch, video: videoPatch) + ) + settings = saved + imagePatch = saved.image.patch + videoPatch = saved.video.patch + imagePatch.apiKey = nil + videoPatch.apiKey = nil + } + } + + private func accept(_ next: CreatorProject) { + project = next + dirty = false + if let index = projects.firstIndex(where: { $0.id == next.id }) { + projects[index] = next + projects.move(fromOffsets: IndexSet(integer: index), toOffset: 0) + } else { + projects.insert(next, at: 0) + } + } + + private func edit(_ mutate: (inout CreatorProject) -> Void) { + guard var project else { return } + mutate(&project) + self.project = project + dirty = true + } + + private func moveShot(_ index: Int, _ delta: Int) { + edit { + let to = index + delta + guard $0.shots.indices.contains(to) else { return } + $0.shots.swapAt(index, to) + } + } + + private func act(_ name: String, _ work: () async throws -> Void) async { + guard busy.isEmpty else { return } + busy = name + errorMessage = nil + defer { busy = "" } + do { + try await work() + } catch { + errorMessage = describe(error) + if let id = project?.id, !dirty { + if let latest = try? await model.client.creatorProject(id) { + project = latest + } + } + } + } + + private func uid(_ prefix: String) -> String { + prefix + UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(12) + } + + private func formatMetric(_ value: Double) -> String { + value.rounded() == value ? String(Int(value)) : String(value) + } + + private var platformBinding: Binding { + Binding( + get: { project?.platform ?? "youtube" }, + set: { value in + guard project?.platform != value else { return } + edit { + $0.platform = value + $0.accountID = "" + } + } + ) + } + + private func stringBinding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { project?[keyPath: keyPath] ?? "" }, + set: { value in edit { $0[keyPath: keyPath] = value } } + ) + } + + private func intBinding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { project?[keyPath: keyPath] ?? 0 }, + set: { value in edit { $0[keyPath: keyPath] = value } } + ) + } + + private func refString(_ id: String, _ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { project?.references.first { $0.id == id }?[keyPath: keyPath] ?? "" }, + set: { value in + edit { + if let index = $0.references.firstIndex(where: { $0.id == id }) { + $0.references[index][keyPath: keyPath] = value + } + } + } + ) + } + + private func shotString(_ id: String, _ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { project?.shots.first { $0.id == id }?[keyPath: keyPath] ?? "" }, + set: { value in + edit { + if let index = $0.shots.firstIndex(where: { $0.id == id }) { + $0.shots[index][keyPath: keyPath] = value + } + } + } + ) + } + + private func shotInt(_ id: String, _ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { project?.shots.first { $0.id == id }?[keyPath: keyPath] ?? 0 }, + set: { value in + edit { + if let index = $0.shots.firstIndex(where: { $0.id == id }) { + $0.shots[index][keyPath: keyPath] = value + } + } + } + ) + } + + private func shotHasRef(_ shotID: String, _ refID: String) -> Binding { + Binding( + get: { project?.shots.first { $0.id == shotID }?.referenceIDs.contains(refID) ?? false }, + set: { enabled in + edit { + guard let index = $0.shots.firstIndex(where: { $0.id == shotID }) else { return } + if enabled { + if !$0.shots[index].referenceIDs.contains(refID) { + $0.shots[index].referenceIDs.append(refID) + } + } else { + $0.shots[index].referenceIDs.removeAll { $0 == refID } + } + } + } + ) + } + + private func stringPatch( + _ patch: Binding, + _ keyPath: WritableKeyPath + ) -> Binding { + Binding( + get: { patch.wrappedValue[keyPath: keyPath] ?? "" }, + set: { patch.wrappedValue[keyPath: keyPath] = $0 } + ) + } + + private func boolPatch( + _ patch: Binding, + _ keyPath: WritableKeyPath + ) -> Binding { + Binding( + get: { patch.wrappedValue[keyPath: keyPath] ?? false }, + set: { patch.wrappedValue[keyPath: keyPath] = $0 } + ) + } + + private func intPatch( + _ patch: Binding, + _ keyPath: WritableKeyPath + ) -> Binding { + Binding( + get: { patch.wrappedValue[keyPath: keyPath] ?? 8 }, + set: { patch.wrappedValue[keyPath: keyPath] = $0 } + ) + } +} + +private struct CreateCreatorProjectSheet: View { + @EnvironmentObject private var model: AppModel + var onCreated: (CreatorProject) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var title = "" + @State private var brief = "" + @State private var platform = "youtube" + @State private var size = "720x1280" + @State private var targetSeconds = 24 + @State private var errorMessage: String? + @State private var isSaving = false + + var body: some View { + NavigationStack { + Form { + TextField("Video title", text: $title) + Picker("Platform", selection: $platform) { + ForEach(ContentCreatorViewPlatforms.platforms, id: \.self) { item in + Text(item).tag(item) + } + } + TextEditor(text: $brief) + .frame(minHeight: 90) + TextField("Target duration (seconds)", value: $targetSeconds, format: .number) + Picker("Resolution", selection: $size) { + ForEach(ContentCreatorViewPlatforms.sizes, id: \.self) { item in + Text(item).tag(item) + } + } + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + } + } + .navigationTitle("New video") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + .disabled(isSaving) + } + ToolbarItem(placement: .confirmationAction) { + Button(isSaving ? "Creating…" : "Create") { + Task { await create() } + } + .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || brief.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || isSaving) + } + } + } + .frame(minWidth: 460, minHeight: 380) + } + + private func create() async { + isSaving = true + errorMessage = nil + defer { isSaving = false } + do { + let created = try await model.client.createCreatorProject( + CreatorProjectDraft( + title: title.trimmingCharacters(in: .whitespacesAndNewlines), + brief: brief.trimmingCharacters(in: .whitespacesAndNewlines), + platform: platform, + size: size, + targetSeconds: targetSeconds, + style: "", + publishMode: "draft" + ) + ) + onCreated(created) + dismiss() + } catch { + errorMessage = describe(error) + } + } +} + +private enum ContentCreatorViewPlatforms { + static let platforms = ["youtube", "tiktok", "instagram", "facebook", "x", "threads"] + static let sizes = ["720x1280", "1280x720", "1024x1792", "1792x1024"] +} + +private struct CreatorArtifactImage: View { + @EnvironmentObject private var model: AppModel + let projectID: String + let path: String + let label: String + + @State private var image: NSImage? + @State private var failed = false + + var body: some View { + Group { + if let image { + Image(nsImage: image) + .resizable() + .scaledToFit() + .frame(maxWidth: 180, maxHeight: 140) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityLabel(label) + } else if failed { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 140, height: 100) + } else { + ProgressView() + .frame(width: 140, height: 100) + } + } + .task(id: path) { await load() } + } + + private func load() async { + failed = false + image = nil + do { + let data = try await model.client.creatorArtifact(projectID: projectID, path: path) + image = NSImage(data: data) + failed = image == nil + } catch { + failed = true + } + } +} + +private struct CreatorArtifactVideo: View { + let url: URL + @State private var player: AVPlayer? + + var body: some View { + VideoPlayer(player: player) + .frame(minHeight: 160, maxHeight: 240) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .onAppear { player = AVPlayer(url: url) } + .onDisappear { + player?.pause() + player = nil + } + } +} diff --git a/macos/Antares/Antares/Pages/CronView.swift b/macos/Antares/Antares/Pages/CronView.swift new file mode 100644 index 00000000..991f8646 --- /dev/null +++ b/macos/Antares/Antares/Pages/CronView.swift @@ -0,0 +1,199 @@ +import AntaresKit +import SwiftUI + +struct CronView: View { + @EnvironmentObject private var model: AppModel + + @State private var jobs: [CronJob] = [] + @State private var errorMessage: String? + @State private var isLoading = false + @State private var busyID: String? + @State private var showingCreate = false + @State private var draftName = "" + @State private var draftSchedule = "0 8 * * *" + @State private var draftPrompt = "" + @State private var isSaving = false + + var body: some View { + Group { + if let errorMessage, jobs.isEmpty, !isLoading { + ContentUnavailableView( + "Cron", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else if !isLoading && jobs.isEmpty { + ContentUnavailableView( + "No Schedules", + systemImage: "clock.arrow.2.circlepath", + description: Text("Create a job to run a prompt on a cron schedule.") + ) + } else { + List { + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + } + + Section("Jobs") { + ForEach(jobs) { job in + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(job.name) + .font(.headline) + Spacer() + if !job.lastState.isEmpty { + Text(job.lastState) + .font(.caption) + .foregroundStyle(job.lastState == "ok" ? .green : .red) + } + } + Text(job.schedule) + .font(.caption) + .fontDesign(.monospaced) + Text(job.prompt) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(2) + HStack { + Toggle( + "Enabled", + isOn: Binding( + get: { job.enabled }, + set: { enabled in + Task { await toggle(job, enabled: enabled) } + } + ) + ) + .labelsHidden() + .disabled(busyID != nil) + Text(job.enabled ? "On" : "Off") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button("Run") { + Task { await run(job) } + } + .disabled(busyID != nil) + } + } + .opacity(job.enabled ? 1 : 0.6) + } + } + } + } + } + .overlay { + if isLoading { ProgressView("Loading cron…") } + } + .navigationTitle("Cron") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("New") { + draftName = "" + draftSchedule = "0 8 * * *" + draftPrompt = "" + showingCreate = true + } + } + } + .sheet(isPresented: $showingCreate) { + NavigationStack { + Form { + TextField("Name", text: $draftName) + TextField("Schedule", text: $draftSchedule) + .fontDesign(.monospaced) + TextEditor(text: $draftPrompt) + .frame(minHeight: 100) + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + } + } + .navigationTitle("New schedule") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { showingCreate = false } + } + ToolbarItem(placement: .confirmationAction) { + Button(isSaving ? "Creating…" : "Create") { + Task { await create() } + } + .disabled(!canCreate || isSaving) + } + } + } + .frame(minWidth: 420, minHeight: 320) + } + .task { await load() } + .refreshable { await load() } + } + + private var canCreate: Bool { + !draftName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !draftPrompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !draftSchedule.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private func load() async { + isLoading = jobs.isEmpty + errorMessage = nil + do { + let response: CronJobsResponse = try await model.client.get("/api/cron/jobs") + jobs = response.jobs + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } + + private func create() async { + let name = draftName.trimmingCharacters(in: .whitespacesAndNewlines) + let schedule = draftSchedule.trimmingCharacters(in: .whitespacesAndNewlines) + let prompt = draftPrompt.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, !prompt.isEmpty else { return } + isSaving = true + errorMessage = nil + do { + let _: CronJob = try await model.client.post( + "/api/cron/jobs", + body: CreateCronRequest(name: name, schedule: schedule, prompt: prompt) + ) + showingCreate = false + await load() + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isSaving = false + } + + private func toggle(_ job: CronJob, enabled: Bool) async { + busyID = job.id + defer { busyID = nil } + do { + let _: CronJob = try await model.client.post( + "/api/cron/jobs/\(job.id)/toggle", + body: ToggleEnabledRequest(enabled: enabled) + ) + await load() + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } + + private func run(_ job: CronJob) async { + busyID = job.id + defer { busyID = nil } + do { + let _: StartedFlagResponse = try await model.client.post( + "/api/cron/jobs/\(job.id)/run", + body: EmptyBody() + ) + await load() + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } +} diff --git a/macos/Antares/Antares/Pages/EngagementView.swift b/macos/Antares/Antares/Pages/EngagementView.swift new file mode 100644 index 00000000..4f224707 --- /dev/null +++ b/macos/Antares/Antares/Pages/EngagementView.swift @@ -0,0 +1,397 @@ +import AntaresKit +import AppKit +import SwiftUI + +struct EngagementView: View { + @EnvironmentObject private var model: AppModel + + @State private var sessions: [EngagementSession] = [] + @State private var selectedID = "" + @State private var snapshot: EngagementSnapshot? + @State private var rawReport: String? + @State private var tab = EngagementTab.overview + @State private var errorMessage: String? + @State private var isLoading = false + @State private var isDeleting = false + @State private var pendingDelete: EngagementSession? + @State private var copied = false + + private var active: EngagementSession? { + sessions.first { $0.id == selectedID } ?? sessions.first + } + + var body: some View { + Group { + if let errorMessage, sessions.isEmpty, !isLoading { + ContentUnavailableView( + "Engagement", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else if !isLoading && sessions.isEmpty { + ContentUnavailableView( + "No Engagements", + systemImage: "checkmark.shield", + description: Text("Security findings and intel from chat sessions will appear here.") + ) + } else { + List { + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + } + + Section { + Picker("Session", selection: $selectedID) { + ForEach(sessions) { session in + Text(sessionLabel(session)).tag(session.id) + } + } + Picker("View", selection: $tab) { + Text("Overview").tag(EngagementTab.overview) + Text("Raw").tag(EngagementTab.raw) + } + .pickerStyle(.segmented) + } + + if tab == .raw { + rawSection + } else { + overviewSections + } + } + } + } + .overlay { + if isLoading { ProgressView("Loading engagement…") } + } + .navigationTitle("Engagement") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Report") { + Task { await downloadReport() } + } + .disabled(active == nil) + } + ToolbarItem(placement: .primaryAction) { + Button("Delete", role: .destructive) { + pendingDelete = active + } + .disabled(active == nil || isDeleting) + } + } + .task { await loadSessions() } + .task(id: selectedID) { + guard !selectedID.isEmpty else { return } + await loadSnapshot() + if tab == .raw { + await loadReport() + } + } + .onChange(of: tab) { _, newValue in + if newValue == .raw { + Task { await loadReport() } + } + } + .refreshable { await loadSessions() } + .confirmationDialog( + "Delete this engagement?", + isPresented: Binding( + get: { pendingDelete != nil }, + set: { if !$0 { pendingDelete = nil } } + ), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let session = pendingDelete { + Task { await delete(session) } + } + pendingDelete = nil + } + Button("Cancel", role: .cancel) { + pendingDelete = nil + } + } message: { + Text("Clears findings and intel for \(pendingDelete?.title.isEmpty == false ? pendingDelete?.title ?? "" : pendingDelete?.id ?? ""). The chat session stays.") + } + } + + @ViewBuilder + private var overviewSections: some View { + Section("Methodology") { + if let snapshot, !snapshot.phases.isEmpty { + ForEach(snapshot.phases) { phase in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(phaseMark(phase.status)) + .foregroundStyle(phaseColor(phase.status)) + Text(phase.title.isEmpty ? phase.name : phase.title) + .font(.headline) + if phase.evidence > 0 { + Text("\(phase.evidence) recorded") + .font(.caption) + .foregroundStyle(.secondary) + } + } + if !phase.summary.isEmpty { + Text(phase.summary) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } else { + Text("No phases yet.") + .foregroundStyle(.secondary) + } + if let next = snapshot?.next, !next.isEmpty { + Text("→ \(next)") + .font(.callout) + .foregroundStyle(.secondary) + } + } + + Section("Coverage") { + let percent = snapshot?.coveragePercent ?? 0 + ProgressView(value: Double(percent), total: 100) { + Text("\(percent)%") + } + if let coverage = snapshot?.coverage, !coverage.isEmpty { + ForEach(coverage) { area in + HStack { + Image(systemName: area.covered ? "checkmark.square.fill" : "square") + .foregroundStyle(area.covered ? .green : .secondary) + Text(area.title.isEmpty ? area.name : area.title) + .foregroundStyle(area.covered ? .primary : .secondary) + } + } + } + } + + if let chains = snapshot?.chains, !chains.isEmpty { + Section("Chains") { + ForEach(chains) { chain in + VStack(alignment: .leading, spacing: 4) { + Text(chain.name) + .font(.headline) + .foregroundStyle(.red) + Text(chain.impact) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + Section("Findings (\(sortedFindings.count))") { + if sortedFindings.isEmpty { + Text("No findings recorded.") + .foregroundStyle(.secondary) + } else { + ForEach(sortedFindings) { finding in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(finding.severity) + .font(.caption) + .foregroundStyle(severityColor(finding.severity)) + Text(finding.title) + .font(.headline) + } + HStack(spacing: 8) { + if let cwe = finding.cwe, !cwe.isEmpty { + Text(cwe) + } + if let target = finding.target, !target.isEmpty { + Text(target) + .fontDesign(.monospaced) + } + if let status = finding.status, !status.isEmpty, status != "new", status != "confirmed" { + Text(status) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + if let intel = snapshot?.intel, !intel.isEmpty { + Section("Intel (\(intel.count))") { + ForEach(intel) { item in + HStack(alignment: .top) { + Text(item.type) + .font(.caption) + .foregroundStyle(.secondary) + Text(item.value) + .fontDesign(.monospaced) + .lineLimit(1) + if let detail = item.detail, !detail.isEmpty { + Text(detail) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .font(.callout) + } + } + } + } + + @ViewBuilder + private var rawSection: some View { + Section { + HStack { + Text("Raw report") + Spacer() + Button(copied ? "Copied" : "Copy") { + copyReport() + } + .disabled(rawReport == nil || rawReport?.isEmpty == true) + } + if let rawReport { + Text(rawReport) + .font(.callout) + .fontDesign(.monospaced) + .textSelection(.enabled) + } else { + Text("Loading report…") + .foregroundStyle(.secondary) + } + } + } + + private var sortedFindings: [EngagementFinding] { + let order = ["critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4] + return (snapshot?.findings ?? []).sorted { + (order[$0.severity] ?? 9) < (order[$1.severity] ?? 9) + } + } + + private func sessionLabel(_ session: EngagementSession) -> String { + let title = session.title.isEmpty ? session.id : session.title + return "\(title) · \(session.findings)✚ / \(session.intel)◆" + } + + private func phaseMark(_ status: String) -> String { + switch status { + case "complete": "●" + case "in_progress": "◐" + case "blocked": "▲" + default: "○" + } + } + + private func phaseColor(_ status: String) -> Color { + switch status { + case "complete": .green + case "in_progress": .accentColor + case "blocked": .orange + default: .secondary + } + } + + private func severityColor(_ severity: String) -> Color { + switch severity { + case "critical", "high": .red + case "medium": .orange + case "low": .secondary + default: .secondary + } + } + + private func loadSessions() async { + isLoading = sessions.isEmpty + errorMessage = nil + do { + let response = try await model.client.listEngagementSessions() + sessions = response.sessions + if selectedID.isEmpty || !sessions.contains(where: { $0.id == selectedID }) { + selectedID = sessions.first?.id ?? "" + } + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } + + private func loadSnapshot() async { + guard !selectedID.isEmpty else { + snapshot = nil + return + } + do { + snapshot = try await model.client.engagement(sessionID: selectedID) + errorMessage = nil + } catch { + snapshot = nil + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } + + private func loadReport() async { + guard let active else { + rawReport = nil + return + } + rawReport = nil + do { + rawReport = try await model.client.engagementReport( + sessionID: active.id, + title: active.title.isEmpty ? "Security Assessment" : active.title + ) + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } + + private func copyReport() { + guard let rawReport, !rawReport.isEmpty else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(rawReport, forType: .string) + copied = true + Task { + try? await Task.sleep(for: .seconds(1.5)) + copied = false + } + } + + private func downloadReport() async { + guard let active else { return } + do { + let markdown = try await model.client.engagementReport( + sessionID: active.id, + title: active.title.isEmpty ? "Security Assessment" : active.title + ) + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("report-\(active.id).md") + try Data(markdown.utf8).write(to: url) + NSWorkspace.shared.activateFileViewerSelecting([url]) + errorMessage = nil + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } + + private func delete(_ session: EngagementSession) async { + isDeleting = true + defer { isDeleting = false } + errorMessage = nil + do { + _ = try await model.client.deleteEngagement(sessionID: session.id) + if selectedID == session.id { selectedID = "" } + snapshot = nil + rawReport = nil + await loadSessions() + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } +} + +private enum EngagementTab: Hashable { + case overview + case raw +} diff --git a/macos/Antares/Antares/Pages/FilesView.swift b/macos/Antares/Antares/Pages/FilesView.swift new file mode 100644 index 00000000..4d3f8db3 --- /dev/null +++ b/macos/Antares/Antares/Pages/FilesView.swift @@ -0,0 +1,242 @@ +import AntaresKit +import AppKit +import SwiftUI + +struct FilesView: View { + @EnvironmentObject private var model: AppModel + + @State private var path = "." + @State private var listing: FileListResponse? + @State private var preview: FilePreview? + @State private var errorMessage: String? + @State private var isLoading = false + @State private var loadingPreview = false + + var body: some View { + Group { + if let errorMessage, listing == nil, !isLoading { + ContentUnavailableView( + "Files", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else { + VStack(alignment: .leading, spacing: 0) { + breadcrumb + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + .padding(.horizontal, 16) + .padding(.top, 8) + } + HSplitView { + fileList + .frame(minWidth: 280) + previewPane + .frame(minWidth: 320) + } + } + } + } + .overlay { + if isLoading { ProgressView("Loading files…") } + } + .navigationTitle("Files") + .toolbar { + ToolbarItem(placement: .automatic) { + Button("Home") { + path = "." + preview = nil + } + } + ToolbarItem(placement: .automatic) { + Button("Up") { + if let parent = listing?.parent, listing?.path != "." { + path = parent.isEmpty ? "." : parent + preview = nil + } + } + .disabled(listing == nil || listing?.path == "." || listing?.parent.isEmpty != false) + } + } + .task(id: path) { await load() } + .refreshable { await load() } + } + + private var breadcrumb: some View { + HStack(spacing: 4) { + Button("Workspace") { + path = "." + preview = nil + } + .buttonStyle(.plain) + ForEach(crumbs(listing?.path ?? path), id: \.path) { crumb in + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) + Button(crumb.name) { + path = crumb.path + preview = nil + } + .buttonStyle(.plain) + .font(.body.monospaced()) + } + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + + @ViewBuilder + private var fileList: some View { + let entries = listing?.entries ?? [] + if !isLoading && entries.isEmpty { + ContentUnavailableView("Empty Folder", systemImage: "folder") + } else { + List(entries, selection: Binding( + get: { preview?.entry.path }, + set: { _ in } + )) { entry in + Button { + Task { await open(entry) } + } label: { + HStack { + Image(systemName: entry.isDir ? "folder.fill" : "doc") + .foregroundStyle(entry.isDir ? Color.accentColor : Color.secondary) + Text(entry.name) + .lineLimit(1) + Spacer() + if !entry.isDir { + Text(formatBytes(entry.size)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + } + .buttonStyle(.plain) + } + } + } + + @ViewBuilder + private var previewPane: some View { + if let preview { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text(preview.entry.name) + .font(.headline.monospaced()) + .lineLimit(1) + Spacer() + Text(formatBytes(preview.entry.size)) + .font(.caption) + .foregroundStyle(.secondary) + Button("Close") { self.preview = nil } + } + .padding(12) + Divider() + if loadingPreview { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = preview.error { + Text(error) + .foregroundStyle(.red) + .padding() + } else if let image = preview.image { + Image(nsImage: image) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } else if preview.binary { + ContentUnavailableView( + "Binary File", + systemImage: "doc.zipper", + description: Text("This file is not shown as text.") + ) + } else { + ScrollView { + Text(preview.content) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + } + } + } + } else { + ContentUnavailableView( + "Select a File", + systemImage: "doc.text", + description: Text("Choose a file to preview its contents.") + ) + } + } + + private func load() async { + isLoading = listing == nil + errorMessage = nil + do { + listing = try await model.client.listFiles(path: path) + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } + + private func open(_ entry: FileEntry) async { + if entry.isDir { + path = entry.path + preview = nil + return + } + loadingPreview = true + preview = FilePreview(entry: entry) + defer { loadingPreview = false } + let ext = (entry.name as NSString).pathExtension.lowercased() + let imageExts: Set = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico"] + do { + if imageExts.contains(ext) { + let data = try await model.client.getData( + "/api/files/raw", + query: [URLQueryItem(name: "path", value: entry.path)] + ) + let image = NSImage(data: data) + preview = FilePreview(entry: entry, binary: image == nil, image: image) + return + } + let read = try await model.client.readFile(path: entry.path) + preview = FilePreview(entry: entry, content: read.content, binary: read.binary) + } catch { + preview = FilePreview( + entry: entry, + error: (error as? APIError)?.message ?? error.localizedDescription + ) + } + } + + private func crumbs(_ p: String) -> [(name: String, path: String)] { + if p.isEmpty || p == "." || p == "/" { return [] } + let parts = p.split(separator: "/").map(String.init).filter { $0 != "." && !$0.isEmpty } + var acc = "" + var out: [(name: String, path: String)] = [] + for part in parts { + acc = acc.isEmpty ? part : "\(acc)/\(part)" + out.append((part, acc)) + } + return out + } + + private func formatBytes(_ size: Int) -> String { + let value = Double(size) + if value >= 1_048_576 { return String(format: "%.1f MB", value / 1_048_576) } + if value >= 1024 { return String(format: "%.1f KB", value / 1024) } + return "\(size) B" + } +} + +private struct FilePreview { + var entry: FileEntry + var content: String = "" + var binary: Bool = false + var image: NSImage? + var error: String? +} diff --git a/macos/Antares/Antares/Pages/InterceptView.swift b/macos/Antares/Antares/Pages/InterceptView.swift new file mode 100644 index 00000000..461b89ff --- /dev/null +++ b/macos/Antares/Antares/Pages/InterceptView.swift @@ -0,0 +1,647 @@ +import AntaresKit +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +struct InterceptView: View { + @EnvironmentObject private var model: AppModel + + enum Tab: String, CaseIterable, Identifiable { + case traffic + case interceptors + case breakpoints + var id: String { rawValue } + var title: String { + switch self { + case .traffic: "Traffic" + case .interceptors: "Interceptors" + case .breakpoints: "Breakpoints" + } + } + } + + @State private var tab: Tab = .traffic + @State private var status: InterceptStatus? + @State private var exchanges: [InterceptExchange] = [] + @State private var rules: [InterceptRule] = [] + @State private var interceptors: [InterceptorInfo] = [] + @State private var sessions: [InterceptorSession] = [] + @State private var paused: [InterceptPaused] = [] + @State private var selectedID: InterceptExchange.ID? + @State private var port = "8899" + @State private var match = "" + @State private var mockStatus = "" + @State private var mockBody = "" + @State private var block = false + @State private var errorMessage: String? + @State private var isLoading = false + @State private var busy = false + @State private var envText: String? + + private var selected: InterceptExchange? { + exchanges.first { $0.id == selectedID } + } + + private var running: Bool { status?.running == true } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + .padding(.horizontal, 16) + .padding(.top, 8) + } + Picker("Tab", selection: $tab) { + ForEach(Tab.allCases) { item in + Text(item.title).tag(item) + } + } + .pickerStyle(.segmented) + .padding(16) + + Group { + switch tab { + case .traffic: + trafficTab + case .interceptors: + interceptorsTab + case .breakpoints: + breakpointsTab + } + } + } + .overlay { + if isLoading { ProgressView("Loading intercept…") } + } + .navigationTitle("Intercept") + .toolbar { toolbar } + .sheet(item: Binding( + get: { selected.map(ExchangeDetail.init) }, + set: { if $0 == nil { selectedID = nil } } + )) { detail in + exchangeSheet(detail.exchange) + } + .sheet(item: Binding( + get: { envText.map(EnvPayload.init) }, + set: { if $0 == nil { envText = nil } } + )) { payload in + envSheet(payload.text) + } + .task { await loadAll() } + .task(id: running) { + guard running else { return } + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(2)) + await loadExchanges(quiet: true) + await loadStatus(quiet: true) + } + } + .task(id: running) { + guard running else { + paused = [] + return + } + do { + try await model.client.streamInterceptBreakpoints { snapshot in + await MainActor.run { + paused = snapshot.paused + } + } + } catch is CancellationError { + return + } catch { + if !Task.isCancelled { + errorMessage = Self.message(error) + } + } + } + .refreshable { await loadAll() } + } + + @ToolbarContentBuilder + private var toolbar: some ToolbarContent { + if running { + ToolbarItem(placement: .automatic) { + Text(status?.addr ?? "Running") + .font(.caption) + .foregroundStyle(.green) + } + ToolbarItem(placement: .automatic) { + Button("Stop") { Task { await stop() } } + .disabled(busy) + } + } else { + ToolbarItem(placement: .automatic) { + TextField("Port", text: $port) + .frame(width: 70) + .font(.body.monospaced()) + } + ToolbarItem(placement: .automatic) { + Button("Start") { Task { await start() } } + .disabled(busy) + } + } + ToolbarItem(placement: .automatic) { + Button("CA cert") { Task { await saveCA() } } + .disabled(busy) + } + ToolbarItem(placement: .automatic) { + Button("Clear") { Task { await clear() } } + .disabled(busy || exchanges.isEmpty) + } + } + + private var trafficTab: some View { + HSplitView { + VStack(alignment: .leading, spacing: 0) { + Text("Traffic (\(exchanges.count))") + .font(.headline) + .padding(.horizontal, 16) + .padding(.vertical, 8) + if exchanges.isEmpty { + ContentUnavailableView( + "No Traffic", + systemImage: "dot.radiowaves.left.and.right", + description: Text("Start the proxy and send traffic through it.") + ) + } else { + Table(exchanges, selection: $selectedID) { + TableColumn("Method") { row in + Text(row.method).fontDesign(.monospaced) + } + .width(70) + TableColumn("Status") { row in + Text(row.status == 0 ? "—" : "\(row.status)") + .foregroundStyle(statusColor(row.status)) + } + .width(60) + TableColumn("URL") { row in + HStack { + Text(row.url) + .font(.caption.monospaced()) + .lineLimit(1) + if row.mocked { Text("mock").font(.caption2).foregroundStyle(.secondary) } + if row.blocked { Text("block").font(.caption2).foregroundStyle(.red) } + } + } + TableColumn("Time") { row in + Text("\(row.durationMS)ms") + .font(.caption) + .foregroundStyle(.secondary) + } + .width(70) + } + } + } + .frame(minWidth: 420) + + Form { + TextField("Match (URL substring)", text: $match) + HStack { + TextField("Mock status", text: $mockStatus) + .frame(width: 110) + Toggle("Block", isOn: $block) + Spacer() + Button("Add rule") { Task { await addRule() } } + .disabled(match.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || busy) + } + TextField("Mock body", text: $mockBody) + if rules.isEmpty { + Text("No rules. Matching requests can be mocked or blocked.") + .foregroundStyle(.secondary) + } else { + ForEach(rules) { rule in + HStack { + Text(rule.match) + .font(.caption.monospaced()) + .lineLimit(1) + Spacer() + Text(rule.block ? "block" : "mock \(rule.mockStatus)") + .font(.caption) + .foregroundStyle(rule.block ? .red : .secondary) + Button("Delete", role: .destructive) { + Task { await deleteRule(rule) } + } + .disabled(busy) + } + } + } + } + .formStyle(.grouped) + .frame(minWidth: 280, idealWidth: 320) + } + } + + private var interceptorsTab: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if !sessions.isEmpty { + Text("Active sessions") + .font(.headline) + ForEach(sessions) { session in + HStack { + Text(session.interceptor) + .font(.caption) + .foregroundStyle(.secondary) + Text(session.id) + .font(.body.monospaced()) + .lineLimit(1) + Spacer() + Button("Stop", role: .destructive) { + Task { await deactivate(session) } + } + .disabled(busy) + } + } + } + + ForEach(groupedInterceptors, id: \.category) { group in + Text(group.category.capitalized) + .font(.headline) + LazyVGrid(columns: [GridItem(.adaptive(minimum: 220), spacing: 12)], spacing: 12) { + ForEach(group.items) { item in + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(item.label).font(.headline) + Spacer() + Text(item.available ? "Ready" : "Needs setup") + .font(.caption) + .foregroundStyle(item.available ? .green : .secondary) + } + Text(item.id) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + if let reason = item.reason, !item.available { + Text(reason).font(.caption).foregroundStyle(.secondary) + } + Button("Activate") { + Task { await activate(item) } + } + .disabled(!item.available || busy) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.background, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.2))) + } + } + } + + if !running { + Text("Activating an interceptor starts the proxy automatically if it is stopped.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(16) + } + } + + private var breakpointsTab: some View { + Group { + if !running { + ContentUnavailableView( + "Proxy Off", + systemImage: "pause.circle", + description: Text("Start the intercept proxy to pause matching requests.") + ) + } else if paused.isEmpty { + ContentUnavailableView( + "No Breakpoints", + systemImage: "pause.circle", + description: Text("Matching requests will pause here until you resume or abort them.") + ) + } else { + List(paused) { item in + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Paused") + .font(.caption) + .foregroundStyle(.orange) + Text(item.method) + .font(.headline.monospaced()) + Text(item.url) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + if !item.body.isEmpty { + Text(item.body) + .font(.caption.monospaced()) + .textSelection(.enabled) + .lineLimit(8) + } + HStack { + Button("Resume") { Task { await resume(item, abort: false) } } + .disabled(busy) + Button("Abort", role: .destructive) { Task { await resume(item, abort: true) } } + .disabled(busy) + } + } + } + } + } + } + + private var groupedInterceptors: [(category: String, items: [InterceptorInfo])] { + var order: [String] = [] + var buckets: [String: [InterceptorInfo]] = [:] + for item in interceptors { + if buckets[item.category] == nil { + order.append(item.category) + } + buckets[item.category, default: []].append(item) + } + return order.map { (category: $0, items: buckets[$0] ?? []) } + } + + private func exchangeSheet(_ exchange: InterceptExchange) -> some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text(exchange.method).font(.headline.monospaced()) + Text(exchange.status == 0 ? "—" : "\(exchange.status)") + .foregroundStyle(statusColor(exchange.status)) + Text("\(exchange.durationMS)ms") + .font(.caption) + .foregroundStyle(.secondary) + } + Text(exchange.url) + .font(.caption.monospaced()) + .textSelection(.enabled) + section("Request", headers: exchange.reqHeaders, body: exchange.reqBody) + section("Response", headers: exchange.respHeaders, body: exchange.respBody) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + } + .navigationTitle("Exchange") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { selectedID = nil } + } + } + } + .frame(minWidth: 560, minHeight: 420) + } + + private func envSheet(_ text: String) -> some View { + NavigationStack { + ScrollView { + Text(text) + .font(.caption.monospaced()) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + } + .navigationTitle("Terminal environment") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { envText = nil } + } + ToolbarItem(placement: .primaryAction) { + Button("Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + } + } + } + .frame(minWidth: 480, minHeight: 280) + } + + @ViewBuilder + private func section(_ title: String, headers: [String: [String]], body: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + if !headers.isEmpty { + Text(headersText(headers)) + .font(.caption.monospaced()) + .textSelection(.enabled) + } + if body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text("Empty body").font(.caption).foregroundStyle(.secondary) + } else { + Text(prettyBody(body)) + .font(.caption.monospaced()) + .textSelection(.enabled) + } + } + } + + private func loadAll() async { + isLoading = status == nil && exchanges.isEmpty + errorMessage = nil + await loadStatus() + await loadExchanges() + await loadRules() + await loadInterceptors() + isLoading = false + } + + private func loadStatus(quiet: Bool = false) async { + do { + status = try await model.client.interceptStatus() + if !quiet { errorMessage = nil } + } catch { + if !quiet { errorMessage = Self.message(error) } + } + } + + private func loadExchanges(quiet: Bool = false) async { + do { + exchanges = try await model.client.interceptExchanges().exchanges + } catch { + if !quiet { errorMessage = Self.message(error) } + } + } + + private func loadRules() async { + do { + rules = try await model.client.interceptRules().rules + } catch { + errorMessage = Self.message(error) + } + } + + private func loadInterceptors() async { + do { + let response = try await model.client.interceptors() + interceptors = response.interceptors + sessions = response.sessions + } catch { + errorMessage = Self.message(error) + } + } + + private func start() async { + busy = true + defer { busy = false } + errorMessage = nil + do { + status = try await model.client.startIntercept(port: Int(port) ?? 8899) + await loadAll() + } catch { + errorMessage = Self.message(error) + } + } + + private func stop() async { + busy = true + defer { busy = false } + errorMessage = nil + do { + _ = try await model.client.stopIntercept() + await loadStatus() + } catch { + errorMessage = Self.message(error) + } + } + + private func clear() async { + busy = true + defer { busy = false } + errorMessage = nil + do { + _ = try await model.client.clearIntercept() + exchanges = [] + selectedID = nil + await loadStatus() + } catch { + errorMessage = Self.message(error) + } + } + + private func addRule() async { + let value = match.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return } + busy = true + defer { busy = false } + errorMessage = nil + do { + _ = try await model.client.addInterceptRule( + InterceptRuleRequest( + match: value, + block: block, + mockStatus: Int(mockStatus) ?? 0, + mockBody: mockBody + ) + ) + match = "" + mockStatus = "" + mockBody = "" + block = false + await loadRules() + await loadStatus() + } catch { + errorMessage = Self.message(error) + } + } + + private func deleteRule(_ rule: InterceptRule) async { + busy = true + defer { busy = false } + errorMessage = nil + do { + _ = try await model.client.deleteInterceptRule(id: rule.id) + await loadRules() + } catch { + errorMessage = Self.message(error) + } + } + + private func activate(_ item: InterceptorInfo) async { + busy = true + defer { busy = false } + errorMessage = nil + do { + let session = try await model.client.activateInterceptor(item.id) + envText = session.envText + await loadInterceptors() + await loadStatus() + } catch { + errorMessage = Self.message(error) + } + } + + private func deactivate(_ session: InterceptorSession) async { + busy = true + defer { busy = false } + errorMessage = nil + do { + _ = try await model.client.deactivateInterceptorSession(session.id) + await loadInterceptors() + } catch { + errorMessage = Self.message(error) + } + } + + private func resume(_ item: InterceptPaused, abort: Bool) async { + busy = true + defer { busy = false } + errorMessage = nil + do { + _ = try await model.client.resumeBreakpoint(id: item.id, abort: abort) + } catch { + errorMessage = Self.message(error) + } + } + + private func saveCA() async { + busy = true + defer { busy = false } + errorMessage = nil + do { + let data = try await model.client.interceptCA() + let panel = NSSavePanel() + panel.nameFieldStringValue = "antares-intercept-ca.pem" + panel.allowedContentTypes = [UTType(filenameExtension: "pem") ?? .plainText] + guard panel.runModal() == .OK, let url = panel.url else { return } + try data.write(to: url) + } catch { + errorMessage = Self.message(error) + } + } + + private func statusColor(_ status: Int) -> Color { + if status >= 500 { return .red } + if status >= 400 { return .orange } + if status >= 200 { return .green } + return .secondary + } + + private func headersText(_ headers: [String: [String]]) -> String { + headers.keys.sorted().map { key in + "\(key): \((headers[key] ?? []).joined(separator: ", "))" + }.joined(separator: "\n") + } + + private func prettyBody(_ body: String) -> String { + let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.first == "{" || trimmed.first == "[" else { return body } + guard let data = trimmed.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data), + let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted]), + let text = String(data: pretty, encoding: .utf8) + else { return body } + return text + } + + private static func message(_ error: Error) -> String { + (error as? APIError)?.message ?? error.localizedDescription + } +} + +private struct ExchangeDetail: Identifiable { + var id: Int { exchange.id } + var exchange: InterceptExchange +} + +private struct EnvPayload: Identifiable { + var id: String { text } + var text: String +} diff --git a/macos/Antares/Antares/Pages/LogsView.swift b/macos/Antares/Antares/Pages/LogsView.swift new file mode 100644 index 00000000..0bd58458 --- /dev/null +++ b/macos/Antares/Antares/Pages/LogsView.swift @@ -0,0 +1,166 @@ +import AntaresKit +import SwiftUI + +struct LogsView: View { + @EnvironmentObject private var model: AppModel + + private static let levels = ["ALL", "DEBUG", "INFO", "WARN", "ERROR"] + + @State private var rows: [LogRow] = [] + @State private var level = "ALL" + @State private var filter = "" + @State private var live = true + @State private var errorMessage: String? + @State private var isLoading = false + + private var visible: [LogRow] { + let q = filter.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return rows.filter { row in + let matchesLevel = level == "ALL" || row.entry.level.caseInsensitiveCompare(level) == .orderedSame + let matchesFilter = q.isEmpty || row.entry.message.lowercased().contains(q) + return matchesLevel && matchesFilter + } + } + + var body: some View { + Group { + if let errorMessage, rows.isEmpty, !isLoading { + ContentUnavailableView( + "Logs", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else if !isLoading && visible.isEmpty { + ContentUnavailableView( + "No Logs", + systemImage: "terminal", + description: Text(filter.isEmpty ? "Waiting for log lines." : "Nothing matched the filter.") + ) + } else { + VStack(alignment: .leading, spacing: 0) { + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + .padding(.horizontal, 16) + .padding(.top, 8) + } + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(visible) { row in + logLine(row.entry) + .id(row.id) + } + } + .padding(12) + } + .onChange(of: rows.count) { _, _ in + guard live, let last = visible.last else { return } + proxy.scrollTo(last.id, anchor: .bottom) + } + } + .font(.system(.caption, design: .monospaced)) + } + } + } + .overlay { + if isLoading { ProgressView("Loading logs…") } + } + .navigationTitle("Logs") + .toolbar { + ToolbarItem(placement: .automatic) { + Picker("Level", selection: $level) { + ForEach(Self.levels, id: \.self) { item in + Text(item).tag(item) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 420) + } + ToolbarItem(placement: .automatic) { + TextField("Filter", text: $filter) + .frame(width: 180) + } + ToolbarItem(placement: .automatic) { + Button(live ? "Pause" : "Resume") { + live.toggle() + } + } + ToolbarItem(placement: .automatic) { + Button("Clear") { rows = [] } + } + } + .task(id: "\(level)|\(live)") { + await loadSnapshot() + guard live else { return } + do { + try await model.client.streamLogs { entry in + let row = LogRow(entry: entry) + await MainActor.run { + rows.append(row) + if rows.count > 2000 { + rows.removeFirst(rows.count - 2000) + } + } + } + } catch is CancellationError { + return + } catch { + if !Task.isCancelled { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } + } + .refreshable { await loadSnapshot() } + } + + private func logLine(_ entry: LogEntry) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Circle() + .fill(levelColor(entry.level)) + .frame(width: 6, height: 6) + Text(timeLabel(entry.time)) + .foregroundStyle(.secondary) + Text(entry.level) + .foregroundStyle(levelColor(entry.level)) + .frame(width: 48, alignment: .leading) + Text(entry.message) + if let attrs = entry.attrs, !attrs.isEmpty { + Text(attrs.map { "\($0.key)=\($0.value.displayString)" }.sorted().joined(separator: " ")) + .foregroundStyle(.secondary) + } + } + .textSelection(.enabled) + } + + private func loadSnapshot() async { + isLoading = rows.isEmpty + errorMessage = nil + do { + let list = try await model.client.listLogs(limit: 500, level: level) + rows = list.entries.map { LogRow(entry: $0) } + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } + + private func levelColor(_ level: String) -> Color { + switch level.uppercased() { + case "ERROR": .red + case "WARN": .orange + case "INFO": .green + default: .secondary + } + } + + private func timeLabel(_ value: String) -> String { + let prefix = value.dropFirst(11).prefix(8) + return prefix.isEmpty ? value : String(prefix) + } +} + +private struct LogRow: Identifiable { + let id = UUID() + let entry: LogEntry +} diff --git a/macos/Antares/Antares/Pages/MCPView.swift b/macos/Antares/Antares/Pages/MCPView.swift new file mode 100644 index 00000000..d436e9b6 --- /dev/null +++ b/macos/Antares/Antares/Pages/MCPView.swift @@ -0,0 +1,464 @@ +import AntaresKit +import SwiftUI + +struct MCPView: View { + @EnvironmentObject private var model: AppModel + + enum Tab: String, CaseIterable, Identifiable { + case servers + case docs + var id: String { rawValue } + var title: String { self == .servers ? "Servers" : "Docs" } + } + + @State private var tab: Tab = .servers + @State private var enabled = true + @State private var servers: [MCPServerStatus] = [] + @State private var openName: String? + @State private var errorMessage: String? + @State private var isLoading = false + @State private var refreshing = false + @State private var showingAdd = false + @State private var showingHub = false + @State private var pendingRemove: MCPServerStatus? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Picker("Tab", selection: $tab) { + ForEach(Tab.allCases) { item in + Text(item.title).tag(item) + } + } + .pickerStyle(.segmented) + .padding(16) + + if tab == .docs { + docs + } else { + serversTab + } + } + .overlay { + if isLoading { ProgressView("Loading MCP…") } + } + .navigationTitle("MCP") + .toolbar { + ToolbarItem(placement: .automatic) { + Button(refreshing ? "Refreshing…" : "Refresh") { + Task { await refresh() } + } + .disabled(refreshing) + } + ToolbarItem(placement: .automatic) { + Button("Add") { showingAdd = true } + } + ToolbarItem(placement: .primaryAction) { + Button("Hub") { showingHub = true } + } + } + .sheet(isPresented: $showingAdd) { + AddMCPSheet { + Task { await load() } + } + .environmentObject(model) + } + .sheet(isPresented: $showingHub) { + HubMCPSheet { + Task { await load() } + } + .environmentObject(model) + } + .confirmationDialog( + "Remove \(pendingRemove?.name ?? "server")?", + isPresented: Binding( + get: { pendingRemove != nil }, + set: { if !$0 { pendingRemove = nil } } + ), + titleVisibility: .visible + ) { + Button("Remove", role: .destructive) { + if let pendingRemove { + Task { await remove(pendingRemove) } + } + } + Button("Cancel", role: .cancel) { pendingRemove = nil } + } + .task { await load() } + .refreshable { await load() } + } + + private var serversTab: some View { + Group { + if !isLoading && servers.isEmpty { + ContentUnavailableView( + "No MCP Servers", + systemImage: "cable.connector", + description: Text("Install a server from the hub or add one by command or URL.") + ) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if let errorMessage { + Text(errorMessage).foregroundStyle(.red) + } + if !enabled { + Text("MCP is disabled in config. Adding a server turns it on.") + .foregroundStyle(.orange) + } + LazyVGrid(columns: [GridItem(.adaptive(minimum: 240), spacing: 12)], spacing: 12) { + ForEach(servers) { server in + serverCard(server) + } + } + } + .padding(16) + } + } + } + } + + private func serverCard(_ server: MCPServerStatus) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top) { + Image(systemName: server.connected ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundStyle(server.connected ? .green : .red) + Text(server.name) + .font(.headline) + .lineLimit(1) + Spacer() + Button(role: .destructive) { + pendingRemove = server + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + } + HStack { + Text(server.connected ? "Connected" : server.started ? "Waiting" : "Failed") + .font(.caption) + .foregroundStyle(server.connected ? .green : server.started ? .orange : .red) + if server.connected { + Text("\(server.tools.count) tools") + .font(.caption) + .foregroundStyle(.secondary) + } + } + if let error = server.error, !error.isEmpty { + Text(error).font(.caption).foregroundStyle(.red) + } + if !server.tools.isEmpty { + Button(openName == server.name ? "Hide tools" : "Show tools") { + openName = openName == server.name ? nil : server.name + } + .buttonStyle(.plain) + .font(.caption) + if openName == server.name { + ForEach(server.tools) { tool in + VStack(alignment: .leading, spacing: 2) { + Text(tool.name).font(.caption.monospaced()) + if !tool.description.isEmpty { + Text(tool.description).font(.caption2).foregroundStyle(.secondary) + } + } + .padding(6) + .frame(maxWidth: .infinity, alignment: .leading) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2))) + } + } + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.background, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.2))) + } + + private var docs: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text("How to add an MCP server") + .font(.headline) + Text("Servers can be stdio commands or remote HTTP endpoints. Example config:") + .foregroundStyle(.secondary) + Text(Self.docsYAML) + .font(.caption.monospaced()) + .textSelection(.enabled) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + } + .padding(16) + } + } + + private func load() async { + isLoading = servers.isEmpty + errorMessage = nil + do { + let response = try await model.client.mcpStatus() + enabled = response.enabled + servers = response.servers + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } + + private func refresh() async { + refreshing = true + errorMessage = nil + do { + let response = try await model.client.refreshMCP() + enabled = response.enabled + servers = response.servers + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + refreshing = false + } + + private func remove(_ server: MCPServerStatus) async { + pendingRemove = nil + errorMessage = nil + do { + _ = try await model.client.deleteMCPServer(server.name) + await load() + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } + + private static let docsYAML = """ + mcp: + enabled: true + servers: + filesystem: + enabled: true + transport: stdio + command: npx + args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/you/data"] + remote: + enabled: true + transport: http + url: https://example.com/mcp + headers: + Authorization: "Bearer ..." + """ +} + +private struct AddMCPSheet: View { + @EnvironmentObject private var model: AppModel + @Environment(\.dismiss) private var dismiss + + var onAdded: () -> Void + + @State private var transport = "stdio" + @State private var name = "" + @State private var command = "" + @State private var args = "" + @State private var url = "" + @State private var envText = "" + @State private var errorMessage: String? + @State private var isSaving = false + + private var canSave: Bool { + !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && (transport == "stdio" + ? !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + : !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + var body: some View { + NavigationStack { + Form { + TextField("Name", text: $name) + Picker("Transport", selection: $transport) { + Text("stdio").tag("stdio") + Text("http").tag("http") + } + .pickerStyle(.segmented) + if transport == "stdio" { + TextField("Command", text: $command) + TextField("Args", text: $args) + TextField("Env (KEY=value per line)", text: $envText, axis: .vertical) + .lineLimit(3...8) + } else { + TextField("URL", text: $url) + } + if let errorMessage { + Text(errorMessage).foregroundStyle(.red) + } + } + .navigationTitle("Add MCP server") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(isSaving ? "Adding…" : "Add") { + Task { await save() } + } + .disabled(!canSave || isSaving) + } + } + } + .frame(minWidth: 440, minHeight: 360) + } + + private func save() async { + isSaving = true + errorMessage = nil + defer { isSaving = false } + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let request: AddMCPServerRequest + if transport == "stdio" { + switch MCPEnvParser.parse(envText) { + case .invalid: + errorMessage = "Each env line must be KEY=value with a valid variable name." + return + case .ok(let env): + request = AddMCPServerRequest( + name: trimmedName, + transport: "stdio", + command: command.trimmingCharacters(in: .whitespacesAndNewlines), + args: args.split(whereSeparator: \.isWhitespace).map(String.init).filter { !$0.isEmpty }, + env: env.isEmpty ? nil : env + ) + } + } else { + request = AddMCPServerRequest( + name: trimmedName, + transport: "http", + url: url.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + do { + let result = try await model.client.addMCPServer(request) + if result.ok { + onAdded() + dismiss() + } else { + errorMessage = result.error ?? "Add failed." + } + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } +} + +private struct HubMCPSheet: View { + @EnvironmentObject private var model: AppModel + @Environment(\.dismiss) private var dismiss + + var onInstalled: () -> Void + + @State private var query = "" + @State private var entries: [HubMCPServer] = [] + @State private var errorMessage: String? + @State private var note: String? + @State private var isLoading = false + @State private var busyID: String? + + var body: some View { + NavigationStack { + Group { + if let errorMessage, entries.isEmpty, !isLoading { + ContentUnavailableView( + "Hub", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else if !isLoading && entries.isEmpty { + ContentUnavailableView( + "No MCP Servers", + systemImage: "storefront", + description: Text("Try a different search.") + ) + } else { + List { + if let errorMessage { + Text(errorMessage).foregroundStyle(.red) + } + if let note { + Text(note).foregroundStyle(.orange) + } + ForEach(entries) { entry in + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(entry.name).font(.headline) + Text(entry.summary).font(.caption).foregroundStyle(.secondary) + if let command = entry.command, !command.isEmpty { + Text(command).font(.caption2.monospaced()).foregroundStyle(.secondary) + } + if let keys = entry.needsKeys, !keys.isEmpty { + Text("Needs \(keys.joined(separator: ", "))") + .font(.caption2) + .foregroundStyle(.orange) + } + } + Spacer() + if entry.installed { + Text("Installed").font(.caption).foregroundStyle(.secondary) + } else { + Button("Install") { + Task { await install(entry) } + } + .disabled(busyID != nil) + } + } + } + } + } + } + .overlay { + if isLoading { ProgressView("Searching hub…") } + } + .navigationTitle("MCP hub") + .searchable(text: $query, prompt: "Search MCP servers") + .onSubmit(of: .search) { + Task { await search() } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { dismiss() } + } + } + .task { await search() } + } + .frame(minWidth: 480, minHeight: 420) + } + + private func search() async { + isLoading = entries.isEmpty + errorMessage = nil + do { + let response = try await model.client.hubMCP(query: query) + entries = response.servers + errorMessage = response.error + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } + + private func install(_ entry: HubMCPServer) async { + busyID = entry.id + defer { busyID = nil } + errorMessage = nil + note = nil + do { + let result = try await model.client.installHubMCP(id: entry.id) + if result.ok { + if let keys = result.missingKeys, !keys.isEmpty { + note = "Installed. Still needs: \(keys.joined(separator: ", "))." + } + onInstalled() + await search() + } else { + errorMessage = result.error ?? "Install failed." + } + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } +} diff --git a/macos/Antares/Antares/Pages/MemoryView.swift b/macos/Antares/Antares/Pages/MemoryView.swift new file mode 100644 index 00000000..9c33d222 --- /dev/null +++ b/macos/Antares/Antares/Pages/MemoryView.swift @@ -0,0 +1,211 @@ +import AntaresKit +import SwiftUI + +struct MemoryView: View { + @EnvironmentObject private var model: AppModel + + @State private var memories: [MemoryItem] = [] + @State private var query = "" + @State private var isSearching = false + @State private var errorMessage: String? + @State private var isLoading = false + @State private var showingEditor = false + @State private var draftKey = "" + @State private var draftContent = "" + @State private var draftScope = "global" + @State private var isSaving = false + @State private var pendingDelete: MemoryItem? + + var body: some View { + Group { + if let errorMessage, memories.isEmpty, !isLoading { + ContentUnavailableView( + "Memory", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else if !isLoading && memories.isEmpty { + ContentUnavailableView( + isSearching ? "No Matches" : "No Memories", + systemImage: "brain", + description: Text(isSearching ? "Try a different search." : "Add a memory to persist facts across sessions.") + ) + } else { + List { + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + } + + ForEach(memories) { item in + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(item.scope) + .font(.caption) + .foregroundStyle(.secondary) + if !item.key.isEmpty { + Text(item.key) + .font(.subheadline) + .fontDesign(.monospaced) + .lineLimit(1) + } + Spacer() + Button("Delete", role: .destructive) { + pendingDelete = item + } + } + Text(item.content) + .font(.body) + if !item.updatedAt.isEmpty { + Text(item.updatedAt) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .swipeActions { + Button("Delete", role: .destructive) { + pendingDelete = item + } + } + } + } + } + } + .overlay { + if isLoading { ProgressView("Loading memory…") } + } + .navigationTitle("Memory") + .searchable(text: $query, prompt: "Search memory") + .onSubmit(of: .search) { + Task { await search() } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Add") { + draftKey = "" + draftContent = "" + draftScope = "global" + showingEditor = true + } + } + } + .task { await load() } + .refreshable { await load() } + .sheet(isPresented: $showingEditor) { + NavigationStack { + Form { + TextField("Key (optional)", text: $draftKey) + TextField("Scope", text: $draftScope) + TextEditor(text: $draftContent) + .frame(minHeight: 120) + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + } + } + .navigationTitle("Add Memory") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { showingEditor = false } + } + ToolbarItem(placement: .confirmationAction) { + Button(isSaving ? "Saving…" : "Save") { + Task { await save() } + } + .disabled(draftContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSaving) + } + } + } + .frame(minWidth: 420, minHeight: 320) + } + .confirmationDialog( + "Delete this memory?", + isPresented: Binding( + get: { pendingDelete != nil }, + set: { if !$0 { pendingDelete = nil } } + ), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let item = pendingDelete { + Task { await delete(item) } + } + pendingDelete = nil + } + Button("Cancel", role: .cancel) { + pendingDelete = nil + } + } + } + + private func load() async { + isLoading = memories.isEmpty + isSearching = false + errorMessage = nil + do { + let list: MemoryListResponse = try await model.client.get("/api/memory") + memories = list.memories + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } + + private func search() async { + let q = query.trimmingCharacters(in: .whitespacesAndNewlines) + if q.isEmpty { + await load() + return + } + isLoading = memories.isEmpty + errorMessage = nil + do { + let list: MemoryListResponse = try await model.client.get( + "/api/memory/search", + query: [ + URLQueryItem(name: "q", value: q), + URLQueryItem(name: "limit", value: "30"), + ] + ) + memories = list.memories + isSearching = true + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } + + private func save() async { + let content = draftContent.trimmingCharacters(in: .whitespacesAndNewlines) + guard !content.isEmpty else { return } + isSaving = true + errorMessage = nil + do { + let _: MemoryItem = try await model.client.post( + "/api/memory", + body: PutMemoryRequest( + id: "", + key: draftKey, + content: content, + scope: draftScope.isEmpty ? "global" : draftScope + ) + ) + showingEditor = false + await load() + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isSaving = false + } + + private func delete(_ item: MemoryItem) async { + do { + let _: DeletedFlagResponse = try await model.client.delete("/api/memory/\(item.id)") + memories.removeAll { $0.id == item.id } + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } +} diff --git a/macos/Antares/Antares/Pages/MessageViews.swift b/macos/Antares/Antares/Pages/MessageViews.swift new file mode 100644 index 00000000..808254fa --- /dev/null +++ b/macos/Antares/Antares/Pages/MessageViews.swift @@ -0,0 +1,106 @@ +import AntaresKit +import SwiftUI + +struct UserPromptView: View { + let text: String + + var body: some View { + HStack { + Spacer(minLength: 64) + Text(text) + .textSelection(.enabled) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 12)) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("You") + } +} + +struct AssistantTextView: View { + let text: String + var reasoning: String = "" + var error: String? + var isStreamingPlaceholder: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if !reasoning.isEmpty { + Text(reasoning) + .font(.callout) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + + if !text.isEmpty { + Text(text) + .textSelection(.enabled) + } else if isStreamingPlaceholder, error == nil { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Thinking…") + .foregroundStyle(.secondary) + } + } + + if let error, !error.isEmpty { + Text(error) + .foregroundStyle(.red) + .textSelection(.enabled) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel("Antares") + } +} + +struct ToolCallRow: View { + let call: ChatToolCall + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Image(systemName: iconName) + Text(call.name.isEmpty ? "tool" : call.name) + .fontWeight(.semibold) + if call.running { + ProgressView() + .controlSize(.small) + } + Spacer() + } + + if !call.args.isEmpty { + Text(call.args) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + if !call.progress.isEmpty { + Text(call.progress) + .font(.caption) + .textSelection(.enabled) + } + if let result = call.result, !result.isEmpty { + Text(result) + .font(.caption) + .foregroundStyle(call.isError ? .red : .secondary) + .textSelection(.enabled) + } + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 8)) + .accessibilityElement(children: .combine) + .accessibilityLabel("Tool \(call.name)") + } + + private var iconName: String { + if call.isError { return "xmark.octagon" } + if call.running { return "gearshape" } + return "wrench.and.screwdriver" + } +} diff --git a/macos/Antares/Antares/Pages/PluginsView.swift b/macos/Antares/Antares/Pages/PluginsView.swift new file mode 100644 index 00000000..e8b00c6d --- /dev/null +++ b/macos/Antares/Antares/Pages/PluginsView.swift @@ -0,0 +1,424 @@ +import AntaresKit +import SwiftUI + +struct PluginsView: View { + @EnvironmentObject private var model: AppModel + + enum Tab: String, CaseIterable, Identifiable { + case plugins + case docs + var id: String { rawValue } + var title: String { self == .plugins ? "Plugins" : "Docs" } + } + + @State private var tab: Tab = .plugins + @State private var enabled = true + @State private var plugins: [PluginInfo] = [] + @State private var dirs: [String] = [] + @State private var errorMessage: String? + @State private var isLoading = false + @State private var busyName: String? + @State private var showingAdd = false + @State private var showingHub = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Picker("Tab", selection: $tab) { + ForEach(Tab.allCases) { item in + Text(item.title).tag(item) + } + } + .pickerStyle(.segmented) + .padding(16) + + if tab == .docs { + docs + } else { + pluginsTab + } + } + .overlay { + if isLoading { ProgressView("Loading plugins…") } + } + .navigationTitle("Plugins") + .toolbar { + ToolbarItem(placement: .automatic) { + Button("Rescan") { Task { await rescan() } } + .disabled(busyName != nil) + } + ToolbarItem(placement: .automatic) { + Button("Hub") { showingHub = true } + } + ToolbarItem(placement: .primaryAction) { + Button("Add") { showingAdd = true } + } + } + .sheet(isPresented: $showingAdd) { + AddPluginSheet { + Task { await load() } + } + .environmentObject(model) + } + .sheet(isPresented: $showingHub) { + HubPluginsSheet { + Task { await load() } + } + .environmentObject(model) + } + .task { await load() } + .refreshable { await load() } + } + + private var pluginsTab: some View { + Group { + if !isLoading && plugins.isEmpty { + ContentUnavailableView( + "No Plugins", + systemImage: "puzzlepiece.extension", + description: Text("Drop a plugin into \(dirs.first ?? "~/.antares/plugins") or install one from the hub.") + ) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if let errorMessage { + Text(errorMessage).foregroundStyle(.red) + } + if !enabled { + Text("Plugins are switched off for this process.") + .foregroundStyle(.orange) + } + LazyVGrid(columns: [GridItem(.adaptive(minimum: 240), spacing: 12)], spacing: 12) { + ForEach(plugins) { plugin in + pluginCard(plugin) + } + } + } + .padding(16) + } + } + } + } + + private func pluginCard(_ plugin: PluginInfo) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top) { + Image(systemName: "puzzlepiece.extension.fill") + .foregroundStyle(plugin.error == nil ? Color.accentColor : .red) + Text(plugin.name).font(.headline).lineLimit(1) + Spacer() + if plugin.error == nil { + Toggle( + "Enabled", + isOn: Binding( + get: { plugin.enabled }, + set: { enabled in + Task { await toggle(plugin, enabled: enabled) } + } + ) + ) + .labelsHidden() + .disabled(busyName != nil) + } + } + if !plugin.description.isEmpty { + Text(plugin.description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + if let error = plugin.error, !error.isEmpty { + Text(error).font(.caption).foregroundStyle(.red) + } + HStack { + if !plugin.version.isEmpty { + Text(plugin.version).font(.caption2).foregroundStyle(.secondary) + } + if plugin.error != nil { + Text("Broken").font(.caption2).foregroundStyle(.red) + } + ForEach(plugin.hooks, id: \.self) { hook in + Text(hook) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + } + } + Text(plugin.command.isEmpty ? plugin.dir : "\(plugin.dir) · \(plugin.command)") + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.background, in: RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(plugin.error == nil ? Color.secondary.opacity(0.2) : Color.red.opacity(0.4)) + ) + } + + private var docs: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text("How to write a plugin") + .font(.headline) + Text("A plugin is a directory with plugin.yaml and an executable that reads one JSON event on stdin.") + .foregroundStyle(.secondary) + Text(Self.docsText) + .font(.caption.monospaced()) + .textSelection(.enabled) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + } + .padding(16) + } + } + + private func load() async { + isLoading = plugins.isEmpty + errorMessage = nil + do { + let response = try await model.client.plugins() + enabled = response.enabled + plugins = response.plugins + dirs = response.dirs + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } + + private func rescan() async { + busyName = "*" + defer { busyName = nil } + errorMessage = nil + do { + _ = try await model.client.reloadPlugins() + await load() + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } + + private func toggle(_ plugin: PluginInfo, enabled: Bool) async { + busyName = plugin.name + defer { busyName = nil } + errorMessage = nil + do { + _ = try await model.client.togglePlugin(name: plugin.name, enabled: enabled) + await load() + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } + + private static let docsText = """ + # ~/.antares/plugins/audit/plugin.yaml + name: audit + description: Log every terminal command + command: ./run.sh + hooks: [pre_tool_call] + + # ~/.antares/plugins/audit/run.sh + #!/bin/sh + cat >> ~/antares-audit.log + echo '{}' + """ +} + +private struct AddPluginSheet: View { + @EnvironmentObject private var model: AppModel + @Environment(\.dismiss) private var dismiss + + var onAdded: () -> Void + + private static let hooks = ["pre_tool_call", "post_tool_call", "session_start", "session_end", "turn_end"] + + @State private var name = "" + @State private var description = "" + @State private var command = "" + @State private var args = "" + @State private var selectedHooks: Set = [] + @State private var errorMessage: String? + @State private var isSaving = false + + private var canSave: Bool { + !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !selectedHooks.isEmpty + } + + var body: some View { + NavigationStack { + Form { + TextField("Name", text: $name) + TextField("Description", text: $description) + TextField("Command", text: $command) + Text("Relative to the plugin directory unless absolute or on PATH.") + .font(.caption) + .foregroundStyle(.secondary) + TextField("Args", text: $args) + Section("Hooks") { + ForEach(Self.hooks, id: \.self) { hook in + Toggle(hook, isOn: Binding( + get: { selectedHooks.contains(hook) }, + set: { on in + if on { selectedHooks.insert(hook) } else { selectedHooks.remove(hook) } + } + )) + } + } + if let errorMessage { + Text(errorMessage).foregroundStyle(.red) + } + } + .navigationTitle("Add plugin") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(isSaving ? "Adding…" : "Add") { + Task { await save() } + } + .disabled(!canSave || isSaving) + } + } + } + .frame(minWidth: 440, minHeight: 420) + } + + private func save() async { + isSaving = true + errorMessage = nil + defer { isSaving = false } + do { + let result = try await model.client.addPlugin( + AddPluginRequest( + name: name.trimmingCharacters(in: .whitespacesAndNewlines), + description: description.trimmingCharacters(in: .whitespacesAndNewlines), + command: command.trimmingCharacters(in: .whitespacesAndNewlines), + args: args.split(whereSeparator: \.isWhitespace).map(String.init).filter { !$0.isEmpty }, + hooks: Self.hooks.filter { selectedHooks.contains($0) } + ) + ) + if result.ok { + onAdded() + dismiss() + } else { + errorMessage = result.error ?? "Add failed." + } + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } +} + +private struct HubPluginsSheet: View { + @EnvironmentObject private var model: AppModel + @Environment(\.dismiss) private var dismiss + + var onInstalled: () -> Void + + @State private var query = "" + @State private var entries: [HubPlugin] = [] + @State private var errorMessage: String? + @State private var isLoading = false + @State private var busyID: String? + + var body: some View { + NavigationStack { + Group { + if let errorMessage, entries.isEmpty, !isLoading { + ContentUnavailableView( + "Hub", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else if !isLoading && entries.isEmpty { + ContentUnavailableView( + "No Plugins", + systemImage: "storefront", + description: Text("Try a different search.") + ) + } else { + List { + if let errorMessage { + Text(errorMessage).foregroundStyle(.red) + } + ForEach(entries) { entry in + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(entry.name).font(.headline) + Text(entry.summary).font(.caption).foregroundStyle(.secondary) + if let command = entry.command, !command.isEmpty { + Text(command).font(.caption2.monospaced()).foregroundStyle(.secondary) + } + if !entry.hooks.isEmpty { + Text(entry.hooks.joined(separator: ", ")) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + } + } + Spacer() + if entry.installed { + Text("Installed").font(.caption).foregroundStyle(.secondary) + } else { + Button("Install") { + Task { await install(entry) } + } + .disabled(busyID != nil) + } + } + } + } + } + } + .overlay { + if isLoading { ProgressView("Searching hub…") } + } + .navigationTitle("Plugin hub") + .searchable(text: $query, prompt: "Search plugins") + .onSubmit(of: .search) { + Task { await search() } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { dismiss() } + } + } + .task { await search() } + } + .frame(minWidth: 480, minHeight: 420) + } + + private func search() async { + isLoading = entries.isEmpty + errorMessage = nil + do { + let response = try await model.client.hubPlugins(query: query) + entries = response.plugins + errorMessage = response.error + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } + + private func install(_ entry: HubPlugin) async { + busyID = entry.id + defer { busyID = nil } + errorMessage = nil + do { + let result = try await model.client.installHubPlugin(id: entry.id) + if result.ok { + onInstalled() + await search() + } else { + errorMessage = result.error ?? "Install failed." + } + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } +} diff --git a/macos/Antares/Antares/Pages/ProvidersView.swift b/macos/Antares/Antares/Pages/ProvidersView.swift new file mode 100644 index 00000000..d9d28886 --- /dev/null +++ b/macos/Antares/Antares/Pages/ProvidersView.swift @@ -0,0 +1,168 @@ +import AntaresKit +import SwiftUI + +struct ProvidersView: View { + @EnvironmentObject private var model: AppModel + + @State private var options: ModelOptionsResponse? + @State private var models: [ProviderModel] = [] + @State private var providerErrors: [ProviderModelError] = [] + @State private var errorMessage: String? + @State private var isLoading = false + @State private var savingID: String? + + var body: some View { + Group { + if let errorMessage, options == nil, models.isEmpty, !isLoading { + ContentUnavailableView( + "Providers", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else { + List { + if let active = options?.active { + Section("Active") { + LabeledContent("Model", value: active.model.isEmpty ? "—" : active.model) + LabeledContent("Provider", value: active.provider.isEmpty ? "—" : active.provider) + } + } + + if !models.isEmpty { + Section("Models") { + ForEach(models, id: \.stableID) { item in + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(item.name.isEmpty ? item.id : item.name) + .font(.headline) + Text("\(item.providerLabel) · \(item.id)") + .font(.caption) + .foregroundStyle(.secondary) + HStack(spacing: 8) { + if item.vision { Text("Vision") } + if item.tools { Text("Tools") } + if item.reasoning { Text("Reasoning") } + if item.contextWindow > 0 { + Text("\(item.contextWindow) ctx") + } + } + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + Button(isActive(item) ? "Default" : "Set default") { + Task { await setDefault(item) } + } + .disabled(savingID != nil || isActive(item)) + } + } + } + } + + if let providers = options?.providers, !providers.isEmpty { + Section("Providers") { + ForEach(providers) { provider in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(provider.label) + .font(.headline) + Spacer() + if provider.active { + Text("Active") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Text(provider.baseURL.isEmpty ? provider.kind : provider.baseURL) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + Text(providerStatus(provider)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + + if let errorMessage, options != nil { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + } + + if !providerErrors.isEmpty { + Section("Provider errors") { + ForEach(providerErrors, id: \.provider) { item in + VStack(alignment: .leading, spacing: 2) { + Text(item.label) + .font(.headline) + Text(item.error) + .font(.caption) + .foregroundStyle(.red) + } + } + } + } + } + } + } + .overlay { + if isLoading { ProgressView("Loading models…") } + } + .navigationTitle("Providers") + .task { await load() } + .refreshable { await load() } + } + + private func isActive(_ item: ProviderModel) -> Bool { + guard let active = options?.active else { return false } + return active.model == item.id && active.provider == item.provider + } + + private func providerStatus(_ provider: ModelProvider) -> String { + if provider.hasKey { return "Connected" } + if provider.custom == true, let headers = provider.headers, !headers.isEmpty { + return "Connected" + } + if provider.local { return "Local" } + return "Needs key" + } + + private func load() async { + isLoading = options == nil && models.isEmpty + errorMessage = nil + do { + let loaded: ModelOptionsResponse = try await model.client.get("/api/model/options") + options = loaded + do { + let all: ModelListAllResponse = try await model.client.get("/api/model/list-all") + models = all.models + providerErrors = all.errors + options = ModelOptionsResponse(active: all.active, providers: loaded.providers) + } catch { + models = [] + providerErrors = [] + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } + + private func setDefault(_ item: ProviderModel) async { + savingID = item.stableID + defer { savingID = nil } + do { + let _: ModelSetResponse = try await model.client.post( + "/api/model/set", + body: ModelSetRequest(model: item.id, provider: item.provider) + ) + await load() + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } +} diff --git a/macos/Antares/Antares/Pages/ProxiesView.swift b/macos/Antares/Antares/Pages/ProxiesView.swift new file mode 100644 index 00000000..d84eec69 --- /dev/null +++ b/macos/Antares/Antares/Pages/ProxiesView.swift @@ -0,0 +1,252 @@ +import AntaresKit +import SwiftUI + +struct ProxiesView: View { + @EnvironmentObject private var model: AppModel + + @State private var entries: [ProxyEntry] = [] + @State private var errorMessage: String? + @State private var isLoading = false + @State private var showingAdd = false + @State private var pendingRemove: ProxyEntry? + @State private var busyID: String? + @State private var testResults: [String: ProxyTestResponse] = [:] + + var body: some View { + Group { + if let errorMessage, entries.isEmpty, !isLoading { + ContentUnavailableView( + "Proxies", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else if !isLoading && entries.isEmpty { + ContentUnavailableView( + "No Proxies", + systemImage: "globe", + description: Text("Paste a list of HTTP or SOCKS proxies to store them for tools.") + ) + } else { + VStack(alignment: .leading, spacing: 8) { + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + .padding(.horizontal, 16) + .padding(.top, 8) + } + Text("Stored proxies are picked by tools when they need one. Passwords stay masked.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 16) + Table(entries) { + TableColumn("Label") { entry in + HStack { + Text(entry.label) + if !entry.scheme.isEmpty { + Text(entry.scheme.uppercased()) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + .width(min: 120, ideal: 160) + TableColumn("Endpoint") { entry in + Text(entry.url) + .font(.caption.monospaced()) + .lineLimit(1) + } + TableColumn("Test") { entry in + testCell(entry) + } + .width(140) + TableColumn("") { entry in + Button("Remove", role: .destructive) { + pendingRemove = entry + } + .disabled(busyID != nil) + } + .width(80) + } + } + } + } + .overlay { + if isLoading { ProgressView("Loading proxies…") } + } + .navigationTitle("Proxies") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Add") { showingAdd = true } + } + } + .sheet(isPresented: $showingAdd) { + AddProxiesSheet { + Task { await load() } + } + .environmentObject(model) + } + .confirmationDialog( + "Remove \(pendingRemove?.label ?? "proxy")?", + isPresented: Binding( + get: { pendingRemove != nil }, + set: { if !$0 { pendingRemove = nil } } + ), + titleVisibility: .visible + ) { + Button("Remove", role: .destructive) { + if let pendingRemove { + Task { await remove(pendingRemove) } + } + } + Button("Cancel", role: .cancel) { pendingRemove = nil } + } message: { + Text("This deletes the stored proxy. Tools will no longer be able to pick it.") + } + .task { await load() } + .refreshable { await load() } + } + + @ViewBuilder + private func testCell(_ entry: ProxyEntry) -> some View { + if let result = testResults[entry.id] { + if result.error == "…" { + Text("Testing…").font(.caption).foregroundStyle(.secondary) + } else if result.ok { + Text(result.ip ?? "ok") + .font(.caption.monospaced()) + .foregroundStyle(.green) + } else { + Text(result.error ?? "Failed") + .font(.caption) + .foregroundStyle(.red) + .lineLimit(1) + .help(result.error ?? "Failed") + } + } else { + Button("Test") { + Task { await test(entry) } + } + .disabled(busyID != nil) + } + } + + private func load() async { + isLoading = entries.isEmpty + errorMessage = nil + do { + entries = try await model.client.listProxies().entries + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } + + private func test(_ entry: ProxyEntry) async { + busyID = entry.id + defer { busyID = nil } + testResults[entry.id] = ProxyTestResponse(ok: false, ip: nil, error: "…") + do { + testResults[entry.id] = try await model.client.testProxy(id: entry.id) + } catch { + testResults[entry.id] = ProxyTestResponse( + ok: false, + ip: nil, + error: (error as? APIError)?.message ?? error.localizedDescription + ) + } + } + + private func remove(_ entry: ProxyEntry) async { + pendingRemove = nil + busyID = entry.id + defer { busyID = nil } + errorMessage = nil + do { + _ = try await model.client.deleteProxy(entry.id) + testResults[entry.id] = nil + await load() + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } +} + +private struct AddProxiesSheet: View { + @EnvironmentObject private var model: AppModel + @Environment(\.dismiss) private var dismiss + + var onSaved: () -> Void + + @State private var text = "" + @State private var errorMessage: String? + @State private var result: ProxyBatchResponse? + @State private var isSaving = false + + private var lineCount: Int { + text.split(separator: "\n").filter { line in + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + return !trimmed.isEmpty && !trimmed.hasPrefix("#") + }.count + } + + var body: some View { + NavigationStack { + Form { + Text("Paste one proxy per line") + TextEditor(text: $text) + .font(.body.monospaced()) + .frame(minHeight: 160) + Text("host:port:user:pass, http://user:pass@host:port, socks5://host:port, or host:port") + .font(.caption) + .foregroundStyle(.secondary) + if let result { + Text("Added \(result.added)") + .foregroundStyle(.green) + if !result.failed.isEmpty { + Text("\(result.failed.count) failed") + .foregroundStyle(.red) + ForEach(result.failed.prefix(6), id: \.line) { failure in + Text("L\(failure.line): \(failure.text) — \(failure.error)") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + } + } + if let errorMessage { + Text(errorMessage).foregroundStyle(.red) + } + } + .navigationTitle("Add proxies") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(isSaving ? "Adding…" : (lineCount > 0 ? "Add \(lineCount)" : "Save")) { + Task { await save() } + } + .disabled(lineCount == 0 || isSaving) + } + } + } + .frame(minWidth: 480, minHeight: 380) + } + + private func save() async { + isSaving = true + errorMessage = nil + defer { isSaving = false } + do { + let response = try await model.client.batchAddProxies(text: text) + result = response + onSaved() + if response.failed.isEmpty { + text = "" + } else { + text = response.failed.map(\.text).joined(separator: "\n") + } + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } +} diff --git a/macos/Antares/Antares/Pages/RolesView.swift b/macos/Antares/Antares/Pages/RolesView.swift new file mode 100644 index 00000000..fe3068fb --- /dev/null +++ b/macos/Antares/Antares/Pages/RolesView.swift @@ -0,0 +1,195 @@ +import AntaresKit +import SwiftUI + +struct RolesView: View { + @EnvironmentObject private var model: AppModel + + @State private var response: RolesResponse? + @State private var sessionRole = "" + @State private var selectedRole = "" + @State private var errorMessage: String? + @State private var isLoading = false + + private var sessionID: String? { + UserDefaults.standard.string(forKey: ChatController.lastSessionKey).flatMap { + $0.isEmpty ? nil : $0 + } + } + + private var pickerRoles: [RoleInfo] { + (response?.roles ?? []).filter { !$0.isSubrole && $0.name != "assistant" } + } + + var body: some View { + Group { + if let errorMessage, response == nil, !isLoading { + ContentUnavailableView( + "Roles", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else if let response, response.roles.isEmpty, !isLoading { + ContentUnavailableView( + "No Roles", + systemImage: "person.3", + description: Text("Specialist roles will appear here.") + ) + } else { + List { + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + } + + Section("Session role") { + if let sessionID { + LabeledContent("Session", value: sessionID) + LabeledContent("Current", value: sessionRole.isEmpty ? "Orchestrator (default)" : sessionRole) + } else { + Text("No active chat session. The next message will use this role.") + .foregroundStyle(.secondary) + } + Picker("Next message", selection: $selectedRole) { + Text("Orchestrator (default)").tag("") + ForEach(pickerRoles) { role in + Text(role.title.isEmpty ? role.name : role.title).tag(role.name) + } + } + .onChange(of: selectedRole) { _, newValue in + persistRole(newValue) + } + } + + if let active = response?.active, !active.isEmpty { + Section("Working now") { + ForEach(active) { agent in + VStack(alignment: .leading, spacing: 4) { + Text(agent.role) + .font(.headline) + Text(agent.task) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + if let groups = groupedRoles, !groups.isEmpty { + ForEach(groups, id: \.category) { group in + Section(categoryLabel(group.category)) { + ForEach(group.roles) { role in + roleRow(role) + } + } + } + } + } + } + } + .overlay { + if isLoading { ProgressView("Loading roles…") } + } + .navigationTitle("Roles") + .task { await load() } + .refreshable { await load() } + } + + private var groupedRoles: [(category: String, roles: [RoleInfo])]? { + guard let roles = response?.roles else { return nil } + var groups: [(category: String, roles: [RoleInfo])] = [] + var index: [String: Int] = [:] + for role in roles { + if let i = index[role.category] { + groups[i].roles.append(role) + } else { + index[role.category] = groups.count + groups.append((role.category, [role])) + } + } + return groups + } + + @ViewBuilder + private func roleRow(_ role: RoleInfo) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(role.title.isEmpty ? role.name : role.title) + .font(.headline) + Spacer() + Text(role.source == "local" ? "Custom" : "Built-in") + .font(.caption) + .foregroundStyle(.secondary) + } + Text(role.name) + .font(.caption) + .fontDesign(.monospaced) + .foregroundStyle(.secondary) + if !role.summary.isEmpty { + Text(role.summary) + .font(.callout) + .foregroundStyle(.secondary) + } + HStack(spacing: 8) { + if role.isSubrole { + Text("Specialist of \(role.parent ?? "")") + } + if role.isDanger { + Text("Authorized testing") + .foregroundStyle(.orange) + } + if !role.toolset.isEmpty { + Text(role.toolset) + } + if !role.model.isEmpty { + Text(role.model) + } + } + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + private func categoryLabel(_ category: String) -> String { + switch category { + case "general": return "General" + case "engineering": return "Engineering" + case "research": return "Research" + case "writing": return "Writing" + case "security": return "Security — authorized testing" + default: return category + } + } + + private func persistRole(_ name: String) { + if name.isEmpty { + UserDefaults.standard.removeObject(forKey: ChatController.lastRoleKey) + } else { + UserDefaults.standard.set(name, forKey: ChatController.lastRoleKey) + } + } + + private func load() async { + isLoading = response == nil + errorMessage = nil + selectedRole = UserDefaults.standard.string(forKey: ChatController.lastRoleKey) ?? "" + do { + let loaded: RolesResponse = try await model.client.get("/api/roles") + response = loaded + if let sessionID { + let current: SessionRoleResponse = try await model.client.get("/api/sessions/\(sessionID)/role") + sessionRole = current.role + if !current.role.isEmpty { + selectedRole = current.role + persistRole(current.role) + } + } else { + sessionRole = "" + } + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } +} diff --git a/macos/Antares/Antares/Pages/SessionsView.swift b/macos/Antares/Antares/Pages/SessionsView.swift new file mode 100644 index 00000000..232135a3 --- /dev/null +++ b/macos/Antares/Antares/Pages/SessionsView.swift @@ -0,0 +1,183 @@ +import AntaresKit +import SwiftUI + +struct SessionsView: View { + @EnvironmentObject private var model: AppModel + var onOpenSession: () -> Void + + @State private var sessions: [SessionSummary] = [] + @State private var hits: [SessionSearchHit] = [] + @State private var query = "" + @State private var errorMessage: String? + @State private var isLoading = false + @State private var pendingDelete: SessionSummary? + + var body: some View { + Group { + if let errorMessage, sessions.isEmpty, hits.isEmpty, !isLoading { + ContentUnavailableView( + "Sessions", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else if !isLoading && sessions.isEmpty && hits.isEmpty && query.isEmpty { + ContentUnavailableView( + "No Sessions", + systemImage: "bubble.left.and.bubble.right", + description: Text("Conversations will appear here.") + ) + } else { + List { + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + } + + if !hits.isEmpty { + Section("Content matches") { + ForEach(hits) { hit in + Button { + open(hit.sessionID) + } label: { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(hit.role) + .font(.caption) + .foregroundStyle(.secondary) + Text(hit.sessionTitle.isEmpty ? hit.sessionID : hit.sessionTitle) + .font(.headline) + .lineLimit(1) + } + Text(hit.snippet) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .buttonStyle(.plain) + } + } + } + + Section("Sessions") { + ForEach(sessions) { session in + Button { + open(session.id) + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(session.title.isEmpty ? session.id : session.title) + .font(.headline) + .lineLimit(1) + HStack(spacing: 8) { + if let modelName = session.model, !modelName.isEmpty { + Text(modelName) + } + Text("\(session.messageCount) messages") + if !session.updatedAt.isEmpty { + Text(session.updatedAt) + } + } + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .buttonStyle(.plain) + .swipeActions { + Button("Delete", role: .destructive) { + pendingDelete = session + } + } + .contextMenu { + Button("Open") { open(session.id) } + Button("Delete", role: .destructive) { + pendingDelete = session + } + } + } + } + } + } + } + .overlay { + if isLoading { ProgressView("Loading sessions…") } + } + .navigationTitle("Sessions") + .searchable(text: $query, prompt: "Search sessions") + .onSubmit(of: .search) { + Task { await load() } + } + .onChange(of: query) { _, newValue in + if newValue.trimmingCharacters(in: .whitespacesAndNewlines).count < 2 { + hits = [] + } + } + .task { await load() } + .refreshable { await load() } + .confirmationDialog( + "Delete this session?", + isPresented: Binding( + get: { pendingDelete != nil }, + set: { if !$0 { pendingDelete = nil } } + ), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let session = pendingDelete { + Task { await delete(session) } + } + pendingDelete = nil + } + Button("Cancel", role: .cancel) { + pendingDelete = nil + } + } + } + + private func load() async { + isLoading = sessions.isEmpty && hits.isEmpty + errorMessage = nil + let q = query.trimmingCharacters(in: .whitespacesAndNewlines) + do { + let list: SessionListResponse = try await model.client.get( + "/api/sessions", + query: [URLQueryItem(name: "limit", value: "100")] + ) + sessions = list.sessions + if q.count >= 2 { + let search: SessionSearchResponse = try await model.client.get( + "/api/sessions/search", + query: [ + URLQueryItem(name: "q", value: q), + URLQueryItem(name: "limit", value: "30"), + ] + ) + hits = search.hits + } else { + hits = [] + } + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } + + private func open(_ id: String) { + UserDefaults.standard.set(id, forKey: ChatController.lastSessionKey) + onOpenSession() + } + + private func delete(_ session: SessionSummary) async { + do { + let _: DeletedFlagResponse = try await model.client.delete("/api/sessions/\(session.id)") + if UserDefaults.standard.string(forKey: ChatController.lastSessionKey) == session.id { + UserDefaults.standard.removeObject(forKey: ChatController.lastSessionKey) + } + await load() + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } +} diff --git a/macos/Antares/Antares/Pages/SkillsView.swift b/macos/Antares/Antares/Pages/SkillsView.swift new file mode 100644 index 00000000..5e4faa7c --- /dev/null +++ b/macos/Antares/Antares/Pages/SkillsView.swift @@ -0,0 +1,236 @@ +import AntaresKit +import SwiftUI + +struct SkillsView: View { + @EnvironmentObject private var model: AppModel + + @State private var skills: [SkillInfo] = [] + @State private var errorMessage: String? + @State private var isLoading = false + @State private var busyName: String? + @State private var showingHub = false + + var body: some View { + Group { + if let errorMessage, skills.isEmpty, !isLoading { + ContentUnavailableView( + "Skills", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else if !isLoading && skills.isEmpty { + ContentUnavailableView( + "No Skills", + systemImage: "sparkles", + description: Text("Install a skill from the hub or wait until the agent learns one.") + ) + } else { + List { + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + } + + Section("Skills") { + ForEach(skills) { skill in + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(skill.name) + .font(.headline) + .fontDesign(.monospaced) + Text(skill.description) + .font(.caption) + .foregroundStyle(.secondary) + HStack(spacing: 8) { + Text(skill.source) + if skill.usageCount > 0 { + Text("Used \(skill.usageCount)×") + } + } + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + Toggle( + "Enabled", + isOn: Binding( + get: { skill.enabled }, + set: { enabled in + Task { await toggle(name: skill.name, enabled: enabled) } + } + ) + ) + .labelsHidden() + .disabled(busyName != nil) + } + .opacity(skill.enabled ? 1 : 0.6) + } + } + } + } + } + .overlay { + if isLoading { ProgressView("Loading skills…") } + } + .navigationTitle("Skills") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Hub") { showingHub = true } + } + } + .sheet(isPresented: $showingHub) { + HubSkillsSheet { + Task { await load() } + } + .environmentObject(model) + } + .task { await load() } + .refreshable { await load() } + } + + private func load() async { + isLoading = skills.isEmpty + errorMessage = nil + do { + let response: SkillsResponse = try await model.client.get("/api/skills") + skills = response.skills + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } + + private func toggle(name: String, enabled: Bool) async { + busyName = name + defer { busyName = nil } + do { + let _: OKResponse = try await model.client.post( + "/api/skills/toggle", + body: ToggleToolRequest(name: name, enabled: enabled) + ) + await load() + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } +} + +private struct HubSkillsSheet: View { + @EnvironmentObject private var model: AppModel + @Environment(\.dismiss) private var dismiss + + var onInstalled: () -> Void + + @State private var query = "" + @State private var entries: [HubSkill] = [] + @State private var errorMessage: String? + @State private var isLoading = false + @State private var busyID: String? + + var body: some View { + NavigationStack { + Group { + if let errorMessage, entries.isEmpty, !isLoading { + ContentUnavailableView( + "Hub", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else if !isLoading && entries.isEmpty { + ContentUnavailableView( + "No Hub Skills", + systemImage: "storefront", + description: Text("Try a different search.") + ) + } else { + List { + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + } + ForEach(entries) { entry in + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(entry.name) + .font(.headline) + Text(entry.summary) + .font(.caption) + .foregroundStyle(.secondary) + Text(entry.source) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + if entry.installed { + Text("Installed") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Button("Install") { + Task { await install(entry) } + } + .disabled(busyID != nil) + } + } + } + } + } + } + .overlay { + if isLoading { ProgressView("Searching hub…") } + } + .navigationTitle("Skill hub") + .searchable(text: $query, prompt: "Search skills") + .onSubmit(of: .search) { + Task { await search() } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { dismiss() } + } + } + .task { await search() } + } + .frame(minWidth: 480, minHeight: 420) + } + + private func search() async { + isLoading = entries.isEmpty + errorMessage = nil + do { + let response: HubSkillsResponse = try await model.client.get( + "/api/hub/skills", + query: [URLQueryItem(name: "q", value: query)] + ) + entries = response.skills + errorMessage = response.error + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } + + private func install(_ entry: HubSkill) async { + busyID = entry.id + defer { busyID = nil } + errorMessage = nil + do { + let result: HubInstallResponse = try await model.client.post( + "/api/hub/skills/install", + body: HubInstallRequest(id: entry.id) + ) + if result.ok { + onInstalled() + await search() + } else { + errorMessage = result.error ?? "Install failed." + } + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } +} diff --git a/macos/Antares/Antares/Pages/SocialMediaView.swift b/macos/Antares/Antares/Pages/SocialMediaView.swift new file mode 100644 index 00000000..7cfc52fb --- /dev/null +++ b/macos/Antares/Antares/Pages/SocialMediaView.swift @@ -0,0 +1,426 @@ +import AntaresKit +import AppKit +import SwiftUI + +struct SocialMediaView: View { + @EnvironmentObject private var model: AppModel + + @State private var status: SocialStatus? + @State private var errorMessage: String? + @State private var isLoading = false + @State private var busy = "" + @State private var recoveryKey = "" + @State private var showIMAP = false + @State private var showingAdd = false + @State private var pendingRemove: SocialAccount? + + var body: some View { + Group { + if let errorMessage, status == nil, !isLoading { + ContentUnavailableView( + "Social Media", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else if let status { + List { + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + } + + if !recoveryKey.isEmpty { + Section("Recovery key") { + Text("Save this recovery key. It will never be shown again.") + .foregroundStyle(.secondary) + Text(recoveryKey) + .font(.body.monospaced()) + .textSelection(.enabled) + HStack { + Button("Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(recoveryKey, forType: .string) + } + Button("Dismiss") { recoveryKey = "" } + } + Text("Restart the backend after generating a key.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if !status.encryptionReady { + Section("Encryption") { + Text("Generate a local master key before storing social credentials.") + .foregroundStyle(.secondary) + Button(busy == "encryption" ? "Generating…" : "Generate key") { + Task { await setupEncryption() } + } + .disabled(!busy.isEmpty) + } + } + + Section("Gmail / IMAP") { + if showIMAP { + IMAPEditor( + defaults: status, + busy: $busy, + errorMessage: $errorMessage + ) { + showIMAP = false + Task { await load() } + } onCancel: { + showIMAP = false + } + .environmentObject(model) + } else if status.imapConfigured { + LabeledContent("Host", value: "\(status.imapHost):\(status.imapPort)") + LabeledContent("Email", value: status.imapUsername) + Button("Edit configuration") { showIMAP = true } + } else { + Text("IMAP is not configured.") + .foregroundStyle(.secondary) + Button("Configure Gmail") { showIMAP = true } + .disabled(!status.encryptionReady) + } + } + + Section("Browser") { + HStack { + Text("Persistent login browser") + Spacer() + Text(status.browser.state) + .font(.caption) + .foregroundStyle(status.browser.state == "running" ? .green : .secondary) + } + Text("Stable fingerprint and login sessions.") + .font(.caption) + .foregroundStyle(.secondary) + if status.browser.state != "running" { + Button(busy == "browser-start" ? "Starting…" : "Start") { + Task { await act("browser-start") { _ = try await model.client.startSocialBrowser() } } + } + .disabled(!status.encryptionReady || !busy.isEmpty) + } else { + Button(busy == "browser-stop" ? "Stopping…" : "Stop") { + Task { await act("browser-stop") { _ = try await model.client.stopSocialBrowser() } } + } + .disabled(!busy.isEmpty) + } + if !status.browser.error.isEmpty { + Text(status.browser.error) + .foregroundStyle(.red) + } + } + + Section("Autopilot") { + Toggle( + "Enable social autopilot", + isOn: Binding( + get: { status.autopilotEnabled }, + set: { enabled in + Task { + await act("autopilot") { + _ = try await model.client.setSocialAutopilot(enabled: enabled) + } + } + } + ) + ) + .disabled(!busy.isEmpty) + Text("Let the agent manage connected accounts on a schedule.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Accounts") { + if status.accounts.isEmpty { + Text("No connected profiles yet.") + .foregroundStyle(.secondary) + } else { + ForEach(status.accounts) { account in + accountRow(account) + } + } + } + } + } else { + Color.clear + } + } + .overlay { + if isLoading { ProgressView("Loading social media…") } + } + .navigationTitle("Social media") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Add account") { showingAdd = true } + .disabled(!(status?.encryptionReady ?? false)) + } + } + .sheet(isPresented: $showingAdd) { + AddSocialAccountSheet { + Task { await load() } + } + .environmentObject(model) + } + .confirmationDialog( + "Remove this account?", + isPresented: Binding( + get: { pendingRemove != nil }, + set: { if !$0 { pendingRemove = nil } } + ), + titleVisibility: .visible + ) { + Button("Remove", role: .destructive) { + if let pendingRemove { + Task { await remove(pendingRemove) } + } + } + Button("Cancel", role: .cancel) { pendingRemove = nil } + } message: { + Text("Credentials stored for \(pendingRemove?.label ?? "this account") will be deleted.") + } + .task { await load() } + .refreshable { await load() } + } + + private func accountRow(_ account: SocialAccount) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(account.label) + .font(.headline) + Text("@\(account.username)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Text(account.status) + .font(.caption) + .foregroundStyle(account.status == "connected" ? .green : .secondary) + } + HStack(spacing: 8) { + Text(account.platform) + .font(.caption2) + .textCase(.uppercase) + .foregroundStyle(.secondary) + if account.hasPassword { + Text("Password") + .font(.caption2) + .foregroundStyle(.secondary) + } + if account.hasRecovery { + Text("Recovery") + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + if let url = URL(string: account.profileURL), !account.profileURL.isEmpty { + Button("Open profile") { NSWorkspace.shared.open(url) } + } + Button("Remove", role: .destructive) { + pendingRemove = account + } + .disabled(!busy.isEmpty) + } + } + } + + private func load() async { + isLoading = status == nil + errorMessage = nil + do { + status = try await model.client.socialStatus() + } catch { + errorMessage = describe(error) + } + isLoading = false + } + + private func setupEncryption() async { + await act("encryption") { + let response = try await model.client.setupSocialEncryption() + recoveryKey = response.recoveryKey + } + } + + private func remove(_ account: SocialAccount) async { + pendingRemove = nil + await act("remove") { + _ = try await model.client.deleteSocialAccount(account.id) + } + } + + private func act(_ name: String, _ work: () async throws -> Void) async { + busy = name + errorMessage = nil + defer { busy = "" } + do { + try await work() + status = try await model.client.socialStatus() + } catch { + errorMessage = describe(error) + } + } +} + +private struct IMAPEditor: View { + @EnvironmentObject private var model: AppModel + let defaults: SocialStatus + @Binding var busy: String + @Binding var errorMessage: String? + var onDone: () -> Void + var onCancel: () -> Void + + @State private var host = "" + @State private var port = "993" + @State private var username = "" + @State private var password = "" + @State private var result = "" + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + TextField("Host", text: $host) + TextField("Port", text: $port) + TextField("Username", text: $username) + SecureField("App password", text: $password) + if !result.isEmpty { + Text(result) + .font(.caption) + .foregroundStyle(.secondary) + } + HStack { + Button(busy == "imap-test" ? "Testing…" : "Test") { + Task { await test() } + } + .disabled(username.isEmpty || password.isEmpty || !busy.isEmpty) + Button(busy == "imap-save" ? "Saving…" : "Save") { + Task { await save() } + } + .disabled(username.isEmpty || !busy.isEmpty) + Button("Cancel", role: .cancel, action: onCancel) + } + } + .onAppear { + host = defaults.imapHost.isEmpty ? "imap.gmail.com" : defaults.imapHost + port = defaults.imapPort == 0 ? "993" : String(defaults.imapPort) + username = defaults.imapUsername + } + } + + private func request() -> SocialIMAPRequest { + SocialIMAPRequest( + host: host.trimmingCharacters(in: .whitespacesAndNewlines), + port: Int(port) ?? 993, + username: username.trimmingCharacters(in: .whitespacesAndNewlines), + password: password + ) + } + + private func test() async { + busy = "imap-test" + result = "" + defer { busy = "" } + do { + let response = try await model.client.testSocialIMAP(request()) + if response.ok { + result = "Connected. Inbox has \(response.inboxCount ?? 0) messages." + } else { + result = response.error ?? "IMAP test failed." + } + } catch { + result = describe(error) + } + } + + private func save() async { + busy = "imap-save" + errorMessage = nil + defer { busy = "" } + do { + _ = try await model.client.saveSocialIMAP(request()) + onDone() + } catch { + result = describe(error) + errorMessage = describe(error) + } + } +} + +private struct AddSocialAccountSheet: View { + @EnvironmentObject private var model: AppModel + var onSaved: () -> Void + + @Environment(\.dismiss) private var dismiss + @State private var platform = "" + @State private var username = "" + @State private var password = "" + @State private var recovery = "" + @State private var profileURL = "" + @State private var errorMessage: String? + @State private var isSaving = false + + private var canSave: Bool { + !platform.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + NavigationStack { + Form { + TextField("Platform", text: $platform) + TextField("Username", text: $username) + SecureField("Password", text: $password) + TextField("Recovery codes", text: $recovery) + TextField("Profile URL", text: $profileURL) + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + } + } + .navigationTitle("Add account") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + .disabled(isSaving) + } + ToolbarItem(placement: .confirmationAction) { + Button(isSaving ? "Saving…" : "Save") { + Task { await save() } + } + .disabled(!canSave || isSaving) + } + } + } + .frame(minWidth: 420, minHeight: 320) + } + + private func save() async { + isSaving = true + errorMessage = nil + defer { isSaving = false } + do { + _ = try await model.client.addSocialAccount( + SocialAccountRequest( + platform: platform.trimmingCharacters(in: .whitespacesAndNewlines), + username: username.trimmingCharacters(in: .whitespacesAndNewlines), + password: password, + recoveryCodes: recovery, + profileURL: profileURL.trimmingCharacters(in: .whitespacesAndNewlines), + status: "connected" + ) + ) + onSaved() + dismiss() + } catch { + errorMessage = describe(error) + } + } +} + +func describe(_ error: Error) -> String { + (error as? APIError)?.message ?? error.localizedDescription +} diff --git a/macos/Antares/Antares/Pages/SoulView.swift b/macos/Antares/Antares/Pages/SoulView.swift new file mode 100644 index 00000000..7e3a8c05 --- /dev/null +++ b/macos/Antares/Antares/Pages/SoulView.swift @@ -0,0 +1,113 @@ +import AntaresKit +import SwiftUI + +struct SoulView: View { + @EnvironmentObject private var model: AppModel + + @State private var text = "" + @State private var saved = "" + @State private var unset = false + @State private var errorMessage: String? + @State private var statusMessage: String? + @State private var isLoading = false + @State private var isSaving = false + @State private var didLoad = false + + private var isDirty: Bool { text != saved } + + var body: some View { + Group { + if let errorMessage, !didLoad, !isLoading { + ContentUnavailableView( + "Soul", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else { + Form { + if let errorMessage, didLoad { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + } + + if unset { + Section { + Text("Soul is still the unset default. Saving here, or chatting, will define the agent’s identity.") + .foregroundStyle(.secondary) + } + } + + Section("SOUL.md") { + TextEditor(text: $text) + .font(.body.monospaced()) + .frame(minHeight: 280) + .disabled(isLoading || isSaving) + } + + if let statusMessage { + Section { + Text(statusMessage) + .foregroundStyle(.secondary) + } + } + } + } + } + .overlay { + if isLoading { ProgressView("Loading soul…") } + } + .navigationTitle("Soul") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Reset") { + Task { await save("") } + } + .disabled(isSaving || isLoading) + } + ToolbarItem(placement: .primaryAction) { + Button(isSaving ? "Saving…" : "Save") { + Task { await save(text) } + } + .disabled(!isDirty || isSaving || isLoading) + } + } + .task { await load() } + } + + private func load() async { + isLoading = !didLoad + errorMessage = nil + do { + let response: SoulResponse = try await model.client.get("/api/soul") + text = response.soul + saved = response.soul + unset = response.unset + didLoad = true + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } + + private func save(_ content: String) async { + isSaving = true + errorMessage = nil + statusMessage = nil + do { + let response: SoulSaveResponse = try await model.client.post( + "/api/soul", + body: SoulRequest(soul: content) + ) + text = content + saved = content + unset = response.unset + statusMessage = content.isEmpty ? "Reset to the unset default." : "Saved." + didLoad = true + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isSaving = false + } +} diff --git a/macos/Antares/Antares/Pages/SystemView.swift b/macos/Antares/Antares/Pages/SystemView.swift new file mode 100644 index 00000000..53c35c76 --- /dev/null +++ b/macos/Antares/Antares/Pages/SystemView.swift @@ -0,0 +1,189 @@ +import AntaresKit +import SwiftUI + +struct SystemView: View { + @EnvironmentObject private var model: AppModel + + @State private var stats: SystemStats? + @State private var status: RuntimeStatus? + @State private var errorMessage: String? + @State private var isLoading = false + + var body: some View { + Group { + if let errorMessage, stats == nil, !isLoading { + ContentUnavailableView( + "System", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else if let stats { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let errorMessage { + Text(errorMessage).foregroundStyle(.red) + } + if let status { + statusStrip(status) + } + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), spacing: 12) { + stat("Uptime", formatUptime(stats.uptimeSeconds), hint: "v\(stats.version)") + stat( + "Tokens", + formatCount(stats.store.tokensIn + stats.store.tokensOut), + hint: String(format: "$%.4f", stats.store.cost) + ) + stat("Sessions", formatCount(stats.store.sessions), hint: "\(formatCount(stats.store.messages)) messages") + stat("Database", formatBytes(stats.database.sizeBytes), hint: stats.database.driver) + } + card("Runtime") { + row("Go", "\(stats.goVersion) · \(stats.os)/\(stats.arch)") + row("Goroutines", "\(stats.goroutines)") + row("Heap", formatBytes(stats.memoryAlloc)) + row("Sys memory", formatBytes(stats.memorySys)) + row("CPU", "\(stats.cpuCount) cores") + row("Active shells", "\(stats.activeShells)") + } + card("Active config") { + row("Model", stats.model.isEmpty ? "—" : stats.model) + row("Provider", stats.provider.isEmpty ? "—" : stats.provider) + row("RAG", stats.ragProvider.isEmpty ? "inactive" : stats.ragProvider) + row("Memories", formatCount(stats.store.memories)) + row("Workspace", stats.workspace, wrap: true) + row("Config path", stats.configPath, wrap: true) + } + } + .padding(16) + } + } else { + Color.clear + } + } + .overlay { + if isLoading { ProgressView("Loading system…") } + } + .navigationTitle("System") + .task { + await load() + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(5)) + await load(quiet: true) + } + } + .refreshable { await load() } + } + + private func statusStrip(_ status: RuntimeStatus) -> some View { + HStack(spacing: 16) { + Label(status.ok ? "Backend ok" : "Backend down", systemImage: status.ok ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundStyle(status.ok ? .green : .red) + Text(status.providerReady ? "Provider ready" : "Needs setup") + .foregroundStyle(status.providerReady ? Color.secondary : Color.orange) + Text("\(status.activeSessions) active sessions") + .foregroundStyle(.secondary) + Spacer() + } + .font(.caption) + .padding(12) + .background(.background, in: RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.secondary.opacity(0.2)) + ) + } + + private func stat(_ label: String, _ value: String, hint: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(label.uppercased()) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.title2.monospacedDigit().weight(.semibold)) + Text(hint) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(.background, in: RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.secondary.opacity(0.2)) + ) + } + + private func card(_ title: String, @ViewBuilder content: () -> some View) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title).font(.headline) + content() + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.background, in: RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.secondary.opacity(0.2)) + ) + } + + private func row(_ label: String, _ value: String, wrap: Bool = false) -> some View { + Group { + if wrap { + VStack(alignment: .leading, spacing: 2) { + Text(label).foregroundStyle(.secondary) + Text(value) + .font(.body.monospaced()) + .textSelection(.enabled) + } + .font(.caption) + .padding(.vertical, 4) + } else { + HStack { + Text(label).foregroundStyle(.secondary) + Spacer() + Text(value) + .fontWeight(.medium) + .lineLimit(1) + } + .font(.caption) + .padding(.vertical, 4) + } + } + } + + private func load(quiet: Bool = false) async { + if !quiet { isLoading = stats == nil } + errorMessage = nil + do { + async let statsLoad = model.client.systemStats() + async let statusLoad = model.client.runtimeStatus() + stats = try await statsLoad + status = try await statusLoad + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } + + private func formatUptime(_ seconds: Int) -> String { + let d = seconds / 86_400 + let h = (seconds % 86_400) / 3600 + let m = (seconds % 3600) / 60 + if d > 0 { return "\(d)d \(h)h" } + if h > 0 { return "\(h)h \(m)m" } + return "\(m)m" + } + + private func formatCount(_ value: Int) -> String { + value.formatted(.number.notation(.compactName)) + } + + private func formatBytes(_ size: Int) -> String { + let value = Double(size) + if value >= 1_073_741_824 { return String(format: "%.1f GB", value / 1_073_741_824) } + if value >= 1_048_576 { return String(format: "%.1f MB", value / 1_048_576) } + if value >= 1024 { return String(format: "%.1f KB", value / 1024) } + return "\(size) B" + } +} diff --git a/macos/Antares/Antares/Pages/ToolsView.swift b/macos/Antares/Antares/Pages/ToolsView.swift new file mode 100644 index 00000000..b71e1ca6 --- /dev/null +++ b/macos/Antares/Antares/Pages/ToolsView.swift @@ -0,0 +1,120 @@ +import AntaresKit +import SwiftUI + +struct ToolsView: View { + @EnvironmentObject private var model: AppModel + + @State private var response: ToolsResponse? + @State private var errorMessage: String? + @State private var isLoading = false + @State private var busyName: String? + + var body: some View { + Group { + if let errorMessage, response == nil, !isLoading { + ContentUnavailableView( + "Tools", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else if let response, response.tools.isEmpty, !isLoading { + ContentUnavailableView( + "No Tools", + systemImage: "wrench.and.screwdriver", + description: Text("No tools are registered on this backend.") + ) + } else { + List { + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + } + + if let response { + Section("Toolset") { + Text(response.toolset.isEmpty ? "—" : response.toolset) + if !response.toolsets.isEmpty { + Text(response.toolsets.joined(separator: ", ")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Section("Tools") { + ForEach(response.tools) { tool in + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(tool.name) + .font(.headline) + .fontDesign(.monospaced) + Text(tool.description) + .font(.caption) + .foregroundStyle(.secondary) + HStack(spacing: 6) { + if tool.requiresApproval { + Text("Mutating") + .font(.caption2) + .foregroundStyle(.orange) + } + ForEach(tool.toolsets, id: \.self) { set in + Text(set) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + Spacer() + Toggle( + "Enabled", + isOn: Binding( + get: { tool.enabled }, + set: { enabled in + Task { await toggle(name: tool.name, enabled: enabled) } + } + ) + ) + .labelsHidden() + .disabled(busyName != nil) + } + } + } + } + } + } + } + .overlay { + if isLoading { ProgressView("Loading tools…") } + } + .navigationTitle("Tools") + .task { await load() } + .refreshable { await load() } + } + + private func load() async { + isLoading = response == nil + errorMessage = nil + do { + let loaded: ToolsResponse = try await model.client.get("/api/tools") + response = loaded + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } + + private func toggle(name: String, enabled: Bool) async { + busyName = name + defer { busyName = nil } + do { + let _: OKResponse = try await model.client.post( + "/api/tools/toggle", + body: ToggleToolRequest(name: name, enabled: enabled) + ) + await load() + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } +} diff --git a/macos/Antares/Antares/Pages/VPSView.swift b/macos/Antares/Antares/Pages/VPSView.swift new file mode 100644 index 00000000..abf424f7 --- /dev/null +++ b/macos/Antares/Antares/Pages/VPSView.swift @@ -0,0 +1,421 @@ +import AntaresKit +import SwiftUI + +struct VPSView: View { + @EnvironmentObject private var model: AppModel + + @State private var hosts: [VPSHost] = [] + @State private var metrics: [String: VPSMetrics] = [:] + @State private var errorMessage: String? + @State private var isLoading = false + @State private var showingAdd = false + @State private var editing: VPSHost? + @State private var pendingRemove: VPSHost? + @State private var processHost: VPSHost? + + var body: some View { + Group { + if let errorMessage, hosts.isEmpty, !isLoading { + ContentUnavailableView( + "VPS", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else if !isLoading && hosts.isEmpty { + ContentUnavailableView( + "No Hosts", + systemImage: "server.rack", + description: Text("Add an SSH host to watch CPU, memory, and disk.") + ) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let errorMessage { + Text(errorMessage).foregroundStyle(.red) + } + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 320), spacing: 12)], + spacing: 12 + ) { + ForEach(hosts) { host in + VPSHostCard( + host: host, + metrics: metrics[host.id], + onRefresh: { Task { await loadMetrics(host.id) } }, + onEdit: { editing = host }, + onRemove: { pendingRemove = host }, + onProcesses: { processHost = host } + ) + } + } + } + .padding(16) + } + } + } + .overlay { + if isLoading { ProgressView("Loading VPS…") } + } + .navigationTitle("VPS") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Add") { showingAdd = true } + } + } + .sheet(isPresented: $showingAdd) { + VPSEditorSheet(host: nil) { + Task { await load() } + } + .environmentObject(model) + } + .sheet(item: $editing) { host in + VPSEditorSheet(host: host) { + Task { await load() } + } + .environmentObject(model) + } + .sheet(item: $processHost) { host in + VPSProcessSheet(host: host) + .environmentObject(model) + } + .confirmationDialog( + "Remove \(pendingRemove?.label ?? "host")?", + isPresented: Binding( + get: { pendingRemove != nil }, + set: { if !$0 { pendingRemove = nil } } + ), + titleVisibility: .visible + ) { + Button("Remove", role: .destructive) { + if let pendingRemove { + Task { await remove(pendingRemove) } + } + } + Button("Cancel", role: .cancel) { pendingRemove = nil } + } message: { + Text("This deletes the stored SSH credentials for this host.") + } + .task { await load() } + .refreshable { await load() } + } + + private func load() async { + isLoading = hosts.isEmpty + errorMessage = nil + do { + hosts = try await model.client.listVPS().hosts + for host in hosts where metrics[host.id] == nil { + await loadMetrics(host.id) + } + } catch { + errorMessage = Self.message(error) + } + isLoading = false + } + + private func loadMetrics(_ id: String) async { + do { + metrics[id] = try await model.client.vpsMetrics(id: id) + } catch { + metrics[id] = VPSMetrics(reachable: false, error: Self.message(error)) + } + } + + private func remove(_ host: VPSHost) async { + pendingRemove = nil + do { + _ = try await model.client.deleteVPS(host.id) + metrics[host.id] = nil + await load() + } catch { + errorMessage = Self.message(error) + } + } + + private static func message(_ error: Error) -> String { + (error as? APIError)?.message ?? error.localizedDescription + } + +} + +private struct VPSHostCard: View { + let host: VPSHost + let metrics: VPSMetrics? + var onRefresh: () -> Void + var onEdit: () -> Void + var onRemove: () -> Void + var onProcesses: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(host.label.isEmpty ? host.host : host.label) + .font(.headline) + if let metrics { + Text(metrics.reachable ? "Online" : "Offline") + .font(.caption) + .foregroundStyle(metrics.reachable ? .green : .red) + } + } + Text("\(host.username)@\(host.host):\(host.port)") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + Button(action: onRefresh) { Image(systemName: "arrow.clockwise") } + .help("Refresh") + Button(action: onEdit) { Image(systemName: "pencil") } + .help("Edit") + Button(role: .destructive, action: onRemove) { Image(systemName: "trash") } + .help("Remove") + } + + if let metrics, !metrics.reachable { + Text(metrics.error ?? "Unreachable") + .font(.caption) + .foregroundStyle(.red) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 6)) + } else if let metrics { + let identity = [ + metrics.os.isEmpty ? nil : metrics.os, + metrics.kernel.isEmpty ? nil : "kernel \(metrics.kernel)", + metrics.uptime.isEmpty ? nil : "up \(metrics.uptime)", + ] + .compactMap { $0 } + .joined(separator: " · ") + if !identity.isEmpty { + Text(identity) + .font(.caption) + .foregroundStyle(.secondary) + } + gauge("CPU", percent: metrics.cpuPercent, detail: "\(fmt(metrics.load1)) / \(fmt(metrics.load5)) / \(fmt(metrics.load15)) · \(metrics.cpuCores) cores") + gauge("Memory", percent: metrics.memPercent, detail: "\(fmtMB(metrics.memUsedMB)) / \(fmtMB(metrics.memTotalMB))") + gauge("Disk", percent: metrics.diskPercent, detail: "\(fmt(metrics.diskUsedGB))G / \(fmt(metrics.diskTotalGB))G") + if metrics.swapTotalMB > 0 { + gauge("Swap", percent: metrics.swapPercent, detail: "\(fmtMB(metrics.swapUsedMB)) / \(fmtMB(metrics.swapTotalMB))") + } + HStack { + Text("\(metrics.processes) processes") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button("Show processes", action: onProcesses) + } + } else { + ProgressView().frame(maxWidth: .infinity, minHeight: 80) + } + } + .padding(14) + .background(.background, in: RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.secondary.opacity(0.2)) + ) + } + + private func gauge(_ label: String, percent: Double, detail: String) -> some View { + let clamped = min(max(percent, 0), 100) + return VStack(alignment: .leading, spacing: 4) { + HStack { + Text(label).font(.caption).foregroundStyle(.secondary) + Spacer() + Text("\(Int(clamped.rounded()))% · \(detail)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + ProgressView(value: clamped, total: 100) + .tint(clamped >= 90 ? .red : clamped >= 70 ? .orange : .accentColor) + } + } + + private func fmt(_ value: Double) -> String { + value.formatted(.number.precision(.fractionLength(0...1))) + } + + private func fmtMB(_ mb: Int) -> String { + if mb >= 1024 { return String(format: "%.1fG", Double(mb) / 1024) } + return "\(mb)M" + } +} + +private struct VPSEditorSheet: View { + @EnvironmentObject private var model: AppModel + @Environment(\.dismiss) private var dismiss + + let host: VPSHost? + var onSaved: () -> Void + + @State private var label = "" + @State private var hostname = "" + @State private var port = "22" + @State private var username = "root" + @State private var authMethod = "password" + @State private var password = "" + @State private var privateKey = "" + @State private var passphrase = "" + @State private var testResult: VPSTestResponse? + @State private var errorMessage: String? + @State private var testing = false + @State private var saving = false + + private var isEdit: Bool { host != nil } + + var body: some View { + NavigationStack { + Form { + TextField("Label", text: $label) + HStack { + TextField("Host", text: $hostname) + TextField("Port", text: $port) + .frame(width: 70) + } + TextField("Username", text: $username) + Picker("Auth", selection: $authMethod) { + Text("Password").tag("password") + Text("Private key").tag("key") + } + if authMethod == "password" { + SecureField(isEdit && host?.hasPassword == true ? "Password (stored)" : "Password", text: $password) + } else { + TextEditor(text: $privateKey) + .font(.body.monospaced()) + .frame(minHeight: 100) + SecureField("Passphrase", text: $passphrase) + } + if let testResult { + Text(testResult.ok ? "Connected as \(testResult.asUser ?? "")" : (testResult.error ?? "Test failed")) + .foregroundStyle(testResult.ok ? .green : .red) + } + if let errorMessage { + Text(errorMessage).foregroundStyle(.red) + } + } + .navigationTitle(isEdit ? "Edit host" : "Add host") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { dismiss() } + } + ToolbarItem(placement: .automatic) { + Button(testing ? "Testing…" : "Test") { Task { await test() } } + .disabled(hostname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || testing || saving) + } + ToolbarItem(placement: .confirmationAction) { + Button(saving ? "Saving…" : "Save") { Task { await save() } } + .disabled(hostname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || testing || saving) + } + } + } + .frame(minWidth: 460, minHeight: 420) + .onAppear { + label = host?.label ?? "" + hostname = host?.host ?? "" + port = host.map { String($0.port) } ?? "22" + username = host?.username ?? "root" + authMethod = host?.authMethod.isEmpty == false ? (host?.authMethod ?? "password") : "password" + } + } + + private func bodyRequest() -> VPSSaveRequest { + VPSSaveRequest( + id: host?.id, + label: label.trimmingCharacters(in: .whitespacesAndNewlines), + host: hostname.trimmingCharacters(in: .whitespacesAndNewlines), + port: Int(port) ?? 22, + username: username.trimmingCharacters(in: .whitespacesAndNewlines), + authMethod: authMethod, + password: authMethod == "password" ? password : "", + privateKey: authMethod == "key" ? privateKey : "", + passphrase: authMethod == "key" ? passphrase : "" + ) + } + + private func test() async { + testing = true + errorMessage = nil + defer { testing = false } + do { + testResult = try await model.client.testVPS(bodyRequest()) + } catch { + testResult = VPSTestResponse(ok: false, asUser: nil, error: (error as? APIError)?.message ?? error.localizedDescription) + } + } + + private func save() async { + saving = true + errorMessage = nil + defer { saving = false } + do { + _ = try await model.client.saveVPS(bodyRequest()) + onSaved() + dismiss() + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + } +} + +private struct VPSProcessSheet: View { + @EnvironmentObject private var model: AppModel + @Environment(\.dismiss) private var dismiss + + let host: VPSHost + + @State private var processes: [VPSProcess] = [] + @State private var errorMessage: String? + @State private var query = "" + @State private var isLoading = true + + private var shown: [VPSProcess] { + let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !q.isEmpty else { return processes } + return processes.filter { + $0.command.lowercased().contains(q) || $0.user.lowercased().contains(q) || String($0.pid).contains(q) + } + } + + var body: some View { + NavigationStack { + Group { + if isLoading { + ProgressView("Loading processes…") + } else if let errorMessage, processes.isEmpty { + ContentUnavailableView("Processes", systemImage: "exclamationmark.triangle", description: Text(errorMessage)) + } else if shown.isEmpty { + ContentUnavailableView("No Processes", systemImage: "list.bullet", description: Text("Nothing matched the filter.")) + } else { + Table(shown) { + TableColumn("PID") { Text("\($0.pid)").font(.caption.monospaced()) }.width(70) + TableColumn("User") { Text($0.user) }.width(80) + TableColumn("CPU") { Text($0.cpu.formatted(.number.precision(.fractionLength(1)))) }.width(60) + TableColumn("Mem") { Text($0.mem.formatted(.number.precision(.fractionLength(1)))) }.width(60) + TableColumn("Command") { Text($0.command).font(.caption.monospaced()).lineLimit(1) } + } + } + } + .navigationTitle("Processes — \(host.label)") + .searchable(text: $query, prompt: "Filter processes") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { dismiss() } + } + } + } + .frame(minWidth: 640, minHeight: 420) + .task { + do { + let list = try await model.client.vpsProcesses(id: host.id) + processes = list.processes + errorMessage = list.error + } catch { + errorMessage = (error as? APIError)?.message ?? error.localizedDescription + } + isLoading = false + } + } +} diff --git a/macos/Antares/Antares/SetupView.swift b/macos/Antares/Antares/SetupView.swift new file mode 100644 index 00000000..a529598e --- /dev/null +++ b/macos/Antares/Antares/SetupView.swift @@ -0,0 +1,416 @@ +import SwiftUI +import AntaresKit + +struct SetupView: View { + @EnvironmentObject private var model: AppModel + + @State private var status: SetupStatus? + @State private var providerID = "" + @State private var providerName = "" + @State private var baseURL = "" + @State private var apiKey = "" + @State private var headersText = "" + @State private var selectedModel = "" + @State private var modelFilter = "" + @State private var testedModels: [String] = [] + @State private var workspace = "" + @State private var dbDriver = "sqlite" + @State private var dbDSN = "" + @State private var ragEnabled = false + @State private var embedProvider = "voyage" + @State private var embedModel = "voyage-4" + @State private var embedAPIKey = "" + @State private var telegramToken = "" + @State private var dashboardPassword = "" + @State private var passwordForGate = "" + @State private var confirmPasswordForGate = "" + @State private var errorMessage: String? + @State private var noticeMessage: String? + @State private var isLoading = true + @State private var isTesting = false + @State private var isSaving = false + @State private var showsSetPassword = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if isLoading { + ProgressView("Loading setup...") + .frame(maxWidth: .infinity, minHeight: 260) + } else if showsSetPassword { + setPasswordForm + } else { + setupForm + } + } + .padding(28) + } + .frame(minWidth: 560, minHeight: 620) + .task { + await loadStatus() + } + } + + private var setupForm: some View { + Form { + Section { + Picker("Provider", selection: $providerID) { + ForEach(status?.providers ?? []) { provider in + Text(provider.label).tag(provider.id) + } + } + .onChange(of: providerID) { _, newValue in + selectedModel = "" + applyProviderDefaults(for: newValue) + } + + if selectedProvider?.custom == true { + TextField("Provider Name", text: $providerName) + } + + TextField("Base URL", text: $baseURL) + .textContentType(.URL) + + if selectedProvider?.local != true { + SecureField(selectedProvider?.keyLabel ?? "API Key", text: $apiKey) + } + + if showsHeaderEditor { + Text("Custom Headers") + .font(.headline) + TextEditor(text: $headersText) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 86) + Text("Enter one header per line as `Header-Name: value`.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + if let hint = selectedProvider?.hint, !hint.isEmpty { + Text(hint) + .font(.footnote) + .foregroundStyle(.secondary) + } + + Button(isTesting ? "Testing..." : "Test Provider") { + testProvider() + } + .disabled(providerID.isEmpty || isTesting) + } header: { + Text("Provider") + .font(.title2) + .fontWeight(.semibold) + } + + Section { + if !modelChoices.isEmpty { + Picker("Suggested Model", selection: $selectedModel) { + ForEach(modelChoices, id: \.self) { model in + Text(model).tag(model) + } + } + } + + TextField("Model", text: $selectedModel) + .font(.system(.body, design: .monospaced)) + + if !modelChoices.isEmpty { + TextField("Filter Models", text: $modelFilter) + } + } header: { + Text("Model") + } + + Section { + TextField("Workspace Path", text: $workspace) + .font(.system(.body, design: .monospaced)) + + Picker("Database", selection: $dbDriver) { + Text("SQLite").tag("sqlite") + Text("Postgres").tag("postgres") + } + if dbDriver == "postgres" { + TextField("Postgres DSN", text: $dbDSN) + .font(.system(.body, design: .monospaced)) + } + } header: { + Text("Workspace") + } + + Section { + Toggle("Enable RAG", isOn: $ragEnabled) + if ragEnabled { + TextField("Embedding Provider", text: $embedProvider) + TextField("Embedding Model", text: $embedModel) + .font(.system(.body, design: .monospaced)) + SecureField("Embedding API Key", text: $embedAPIKey) + } + + SecureField("Telegram Bot Token", text: $telegramToken) + SecureField("Dashboard Password", text: $dashboardPassword) + } header: { + Text("Optional Extras") + } + + if let noticeMessage { + Text(noticeMessage) + .font(.footnote) + .foregroundStyle(.secondary) + } + + if let errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(.red) + } + + Button(isSaving ? "Saving..." : "Complete Setup") { + completeSetup() + } + .disabled(!canComplete || isSaving) + } + } + + private var setPasswordForm: some View { + Form { + Section { + SecureField("New Password", text: $passwordForGate) + .textContentType(.newPassword) + SecureField("Confirm Password", text: $confirmPasswordForGate) + .textContentType(.newPassword) + + if let errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(.red) + } + } header: { + Text("Set a Dashboard Password") + .font(.title2) + .fontWeight(.semibold) + } footer: { + Text("The server requires a dashboard password before saving sensitive setup values.") + } + + Button(isSaving ? "Saving..." : "Set Password") { + setPasswordForGate() + } + .disabled(passwordForGate.isEmpty || passwordForGate != confirmPasswordForGate || isSaving) + } + } + + private var selectedProvider: SetupProvider? { + status?.providers.first { $0.id == providerID } + } + + private var modelChoices: [String] { + let all = testedModels.isEmpty ? (selectedProvider?.models ?? []) : testedModels + let query = modelFilter.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !query.isEmpty else { return all } + return all.filter { $0.lowercased().contains(query) } + } + + private var canComplete: Bool { + !providerID.isEmpty && + (!needsBaseURL || !effectiveBaseURL.isEmpty) && + !selectedModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !workspace.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + (dbDriver != "postgres" || !dbDSN.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + private var needsBaseURL: Bool { + selectedProvider?.custom == true || selectedProvider?.needsBaseURL == true + } + + private var showsHeaderEditor: Bool { + selectedProvider?.custom == true + } + + private func loadStatus() async { + isLoading = true + errorMessage = nil + + do { + let loaded = try await model.setupStatus() + status = loaded + providerID = loaded.provider.isEmpty ? (loaded.providers.first?.id ?? "") : loaded.provider + selectedModel = loaded.model + workspace = loaded.workspace + dbDriver = loaded.database.isEmpty ? "sqlite" : loaded.database + showsSetPassword = false + applyProviderDefaults(for: providerID) + } catch let error as APIError where error.isDashboardPasswordRequired { + showsSetPassword = true + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + + private func applyProviderDefaults(for id: String) { + guard let provider = status?.providers.first(where: { $0.id == id }) else { return } + baseURL = provider.baseURL ?? "" + testedModels = [] + if provider.local { + apiKey = "" + } + if provider.custom != true { + headersText = "" + } + if selectedModel.isEmpty { + selectedModel = provider.models?.first ?? "" + } + } + + private func testProvider() { + guard !providerID.isEmpty, !isTesting else { return } + isTesting = true + errorMessage = nil + noticeMessage = nil + + let headers: [String: String]? + do { + headers = try parsedHeaders() + } catch { + errorMessage = error.localizedDescription + isTesting = false + return + } + + Task { + do { + let response = try await model.testSetup( + SetupTestRequest( + provider: providerID, + baseURL: effectiveBaseURL, + apiKey: selectedProvider?.local == true ? "" : apiKey, + headers: headers + ) + ) + if response.ok { + testedModels = response.models ?? [] + noticeMessage = response.note ?? "Provider test succeeded." + if selectedModel.isEmpty { + selectedModel = response.suggested?.first ?? response.models?.first ?? "" + } + } else { + errorMessage = response.error ?? "Provider test failed." + } + } catch let error as APIError where error.isDashboardPasswordRequired { + showsSetPassword = true + } catch { + errorMessage = error.localizedDescription + } + isTesting = false + } + } + + private func completeSetup() { + guard canComplete, !isSaving else { return } + isSaving = true + errorMessage = nil + noticeMessage = nil + + let headers: [String: String]? + do { + headers = try parsedHeaders() + } catch { + errorMessage = error.localizedDescription + isSaving = false + return + } + + Task { + do { + try await model.completeSetup( + CompleteSetupRequest( + provider: providerID, + name: providerName, + baseURL: effectiveBaseURL, + apiKey: selectedProvider?.local == true ? "" : apiKey, + headers: headers, + model: selectedModel.trimmingCharacters(in: .whitespacesAndNewlines), + workspace: workspace.trimmingCharacters(in: .whitespacesAndNewlines), + database: SetupDatabaseRequest(driver: dbDriver, dsn: dbDriver == "postgres" ? dbDSN : ""), + rag: SetupRAGRequest( + enabled: ragEnabled, + embedProvider: embedProvider, + embedModel: embedModel, + embedAPIKey: embedAPIKey + ), + telegramToken: telegramToken, + dashboardPassword: dashboardPassword + ) + ) + } catch let error as APIError where error.isDashboardPasswordRequired { + showsSetPassword = true + } catch { + errorMessage = error.localizedDescription + } + isSaving = false + } + } + + private func setPasswordForGate() { + guard !passwordForGate.isEmpty, passwordForGate == confirmPasswordForGate, !isSaving else { return } + isSaving = true + errorMessage = nil + + Task { + do { + try await model.setDashboardPassword(passwordForGate) + try await model.login(password: passwordForGate) + passwordForGate = "" + confirmPasswordForGate = "" + await loadStatus() + } catch { + errorMessage = error.localizedDescription + } + isSaving = false + } + } + + private var effectiveBaseURL: String { + let trimmed = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? (selectedProvider?.baseURL ?? "") : trimmed + } + + private func parsedHeaders() throws -> [String: String]? { + guard showsHeaderEditor else { return nil } + + let lines = headersText + .split(whereSeparator: \.isNewline) + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + guard !lines.isEmpty else { return nil } + + var headers: [String: String] = [:] + for line in lines { + guard let separator = line.firstIndex(of: ":") else { + throw HeaderParseError.invalidLine(line) + } + + let key = line[.. + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + AntaresApp + CFBundleIdentifier + dev.enow.antares + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Antares + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + + diff --git a/macos/Antares/Package.swift b/macos/Antares/Package.swift new file mode 100644 index 00000000..5a6d9390 --- /dev/null +++ b/macos/Antares/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "Antares", + platforms: [.macOS(.v14)], + products: [ + .executable(name: "Antares", targets: ["Antares"]), + ], + dependencies: [ + .package(path: "../AntaresKit"), + ], + targets: [ + .executableTarget( + name: "Antares", + dependencies: ["AntaresKit"], + path: "Antares" + ), + .testTarget( + name: "AntaresTests", + dependencies: ["Antares"], + path: "Tests/AntaresTests" + ), + ] +) diff --git a/macos/Antares/README.md b/macos/Antares/README.md new file mode 100644 index 00000000..54282728 --- /dev/null +++ b/macos/Antares/README.md @@ -0,0 +1,19 @@ +# Antares macOS + +Native SwiftUI dashboard. Build the app bundle (Swift executable + bundled `antares` backend): + +```sh +make macos +``` + +The result is `macos/build/Build/Products/Release/Antares.app`. + +Package-level checks: + +```sh +swift test --package-path macos/AntaresKit +swift test --package-path macos/Antares +swift build --package-path macos/Antares +``` + +Override the loopback API with `ANTARES_URL` (shared by the HTTP client and the runtime supervisor). diff --git a/macos/Antares/Tests/AntaresTests/AppModelTests.swift b/macos/Antares/Tests/AntaresTests/AppModelTests.swift new file mode 100644 index 00000000..a9e51c41 --- /dev/null +++ b/macos/Antares/Tests/AntaresTests/AppModelTests.swift @@ -0,0 +1,204 @@ +import AntaresKit +import XCTest +@testable import Antares + +@MainActor +final class AppModelTests: XCTestCase { + override func tearDown() { + StubURLProtocol.reset() + super.tearDown() + } + + func testStopDelegatesToRuntimeSupervisor() async { + let runtime = SpyRuntimeSupervisor() + let model = AppModel(runtime: runtime) + + await model.stop() + + XCTAssertTrue(runtime.didStop) + } + + func testClientUsesTheInjectedBaseURL() { + let url = URL(string: "http://127.0.0.1:9999")! + let model = AppModel(baseURL: url, runtime: SpyRuntimeSupervisor()) + + XCTAssertEqual(model.client.baseURL, url) + } + + func testStartCallsRuntimeOnceThenReadsSetupAndAuth() async { + StubURLProtocol.readyAuthenticated() + let runtime = SpyRuntimeSupervisor() + let model = AppModel(runtime: runtime, session: StubURLProtocol.session()) + + await model.start() + + XCTAssertEqual(runtime.startCount, 1) + guard case .ready = model.phase else { + return XCTFail("expected ready, got \(model.phase)") + } + } + + func testLoginDoesNotStartRuntimeAgain() async throws { + StubURLProtocol.readyAuthenticated() + let runtime = SpyRuntimeSupervisor() + let model = AppModel(runtime: runtime, session: StubURLProtocol.session()) + + await model.start() + XCTAssertEqual(runtime.startCount, 1) + + try await model.login(password: "secret") + + XCTAssertEqual(runtime.startCount, 1) + guard case .ready = model.phase else { + return XCTFail("expected ready, got \(model.phase)") + } + } + + func testCompleteSetupDoesNotStartRuntimeAgain() async throws { + StubURLProtocol.readyAuthenticated() + let runtime = SpyRuntimeSupervisor() + let model = AppModel(runtime: runtime, session: StubURLProtocol.session()) + + await model.start() + XCTAssertEqual(runtime.startCount, 1) + + try await model.completeSetup(Self.completeSetupRequest) + + XCTAssertEqual(runtime.startCount, 1) + guard case .ready = model.phase else { + return XCTFail("expected ready, got \(model.phase)") + } + } + + func testUnauthorizedRefreshGoesToLogin() async { + StubURLProtocol.statusCodeByPath = [ + "/api/setup/status": 401, + ] + let runtime = SpyRuntimeSupervisor() + let model = AppModel(runtime: runtime, session: StubURLProtocol.session()) + + await model.start() + + XCTAssertEqual(runtime.startCount, 1) + guard case .login = model.phase else { + return XCTFail("expected login, got \(model.phase)") + } + } + + func testTerminateSpawnedChildIsCallableFromAppModel() { + let runtime = SpyRuntimeSupervisor() + let model = AppModel(runtime: runtime) + + model.terminateSpawnedChild() + + XCTAssertTrue(runtime.didTerminate) + } + + private static let completeSetupRequest = CompleteSetupRequest( + provider: "openrouter", + baseURL: "https://openrouter.ai/api/v1", + apiKey: "sk-test", + model: "anthropic/claude-sonnet-4.5", + workspace: "/tmp/antares", + database: SetupDatabaseRequest(), + rag: SetupRAGRequest() + ) +} + +private final class SpyRuntimeSupervisor: RuntimeSupervising { + private(set) var startCount = 0 + private(set) var didStop = false + private(set) var didTerminate = false + + func start(binary: URL?) async throws { + startCount += 1 + } + + func stop() async { + didStop = true + } + + func terminateSpawnedChild() { + didTerminate = true + } +} + +private final class StubURLProtocol: URLProtocol { + private static let state = StubState() + + private final class StubState: @unchecked Sendable { + let lock = NSLock() + var statusCodeByPath: [String: Int] = [:] + } + + static var statusCodeByPath: [String: Int] { + get { + state.lock.lock() + defer { state.lock.unlock() } + return state.statusCodeByPath + } + set { + state.lock.lock() + state.statusCodeByPath = newValue + state.lock.unlock() + } + } + + static func reset() { + statusCodeByPath = [:] + } + + static func readyAuthenticated() { + statusCodeByPath = [:] + } + + static func session() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [StubURLProtocol.self] + return URLSession(configuration: config) + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let path = request.url?.path ?? "" + let status = Self.statusCodeByPath[path] ?? 200 + + let body: Data + if status >= 400 { + body = Data(#"{"error":"unauthorized"}"#.utf8) + } else if path.hasSuffix("/api/setup/status") { + body = Data(Self.setupStatusJSON.utf8) + } else if path.hasSuffix("/api/auth/status") { + body = Data(#"{"password_required":true,"authenticated":true}"#.utf8) + } else { + body = Data("{}".utf8) + } + + let response = HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: body) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} + + private static let setupStatusJSON = """ + { + "needs_setup": false, + "model": "anthropic/claude-sonnet-4.5", + "provider": "openrouter", + "workspace": "/tmp/antares", + "home": "/tmp", + "config_path": "/tmp/.antares/config.json", + "database": "sqlite", + "providers": [] + } + """ +} diff --git a/macos/Antares/Tests/AntaresTests/SidebarItemTests.swift b/macos/Antares/Tests/AntaresTests/SidebarItemTests.swift new file mode 100644 index 00000000..b751a3d2 --- /dev/null +++ b/macos/Antares/Tests/AntaresTests/SidebarItemTests.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import Antares + +final class SidebarItemTests: XCTestCase { + private let expectedIDs = [ + "chat", + "sessions", + "providers", + "tools", + "memory", + "roles", + "soul", + "skills", + "cron", + "channels", + "autopilot", + "engagement", + "board", + "intercept", + "mcp", + "plugins", + "proxies", + "vps", + "analytics", + "files", + "logs", + "config", + "system", + "content-creator", + "social-media", + ] + + func testAllContainsRequiredIdsInRouteManifestOrder() { + XCTAssertEqual(SidebarItem.all.map(\.id), expectedIDs) + } + + func testEverySidebarItemHasARouteAndEveryRouteIsListed() { + let allIDs = Set(SidebarItem.all.map(\.id)) + let routeIDs = Set(SidebarRoute.allCases.map(\.rawValue)) + XCTAssertEqual(routeIDs, allIDs) + XCTAssertEqual(routeIDs, Set(expectedIDs)) + for item in SidebarItem.all { + XCTAssertEqual(item.route.rawValue, item.id) + } + } + + func testAppShellImplementsEverySidebarItemAndHasNoStub() throws { + let source = try String(contentsOf: appShellURL, encoding: .utf8) + XCTAssertFalse(source.contains("PageStub"), "AppShell still references PageStub") + XCTAssertFalse(source.contains("default:"), "AppShell switch should be exhaustive without default") + + var implemented = Set() + for item in SidebarItem.all { + let needle = "case .\(item.route):" + XCTAssertTrue(source.contains(needle), "AppShell missing \(needle) for \(item.id)") + implemented.insert(item.id) + } + XCTAssertEqual(implemented, Set(SidebarItem.all.map(\.id))) + XCTAssertEqual(implemented, Set(expectedIDs)) + } + + private var appShellURL: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Antares/AppShell.swift") + } +} diff --git a/macos/AntaresKit/Package.swift b/macos/AntaresKit/Package.swift new file mode 100644 index 00000000..b6ad13f2 --- /dev/null +++ b/macos/AntaresKit/Package.swift @@ -0,0 +1,12 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "AntaresKit", + platforms: [.macOS(.v14)], + products: [.library(name: "AntaresKit", targets: ["AntaresKit"])], + targets: [ + .target(name: "AntaresKit"), + .testTarget(name: "AntaresKitTests", dependencies: ["AntaresKit"]), + ] +) diff --git a/macos/AntaresKit/Sources/AntaresKit/APIClient.swift b/macos/AntaresKit/Sources/AntaresKit/APIClient.swift new file mode 100644 index 00000000..a22594ae --- /dev/null +++ b/macos/AntaresKit/Sources/AntaresKit/APIClient.swift @@ -0,0 +1,97 @@ +import Foundation + +public struct APIError: Error, LocalizedError, Sendable { + public var status: Int + public var message: String + public var isDashboardPasswordRequired: Bool { status == 428 } + public var isUnauthorized: Bool { status == 401 } + public var errorDescription: String? { message } + + public init(status: Int, body: Data) { + self.status = status + if let obj = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let m = obj["message"] as? String { + self.message = m + } else if let obj = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let e = obj["error"] as? String { + self.message = e + } else { + self.message = HTTPURLResponse.localizedString(forStatusCode: status) + } + } +} + +public final class APIClient: @unchecked Sendable { + public let baseURL: URL + public var bearerToken: String? + public let session: URLSession + + public init(baseURL: URL, session: URLSession = .shared) { + self.baseURL = baseURL + self.session = session + } + + public func url(_ path: String) -> URL { + URL(string: path, relativeTo: baseURL)! + } + + public func get(_ path: String) async throws -> T { + try await send(url: url(path), method: "GET", body: nil) + } + + public func get(_ path: String, query: [URLQueryItem]) async throws -> T { + try await send(url: url(path, query: query), method: "GET", body: nil) + } + + public func getData(_ path: String, query: [URLQueryItem] = []) async throws -> Data { + try await raw(url: url(path, query: query), method: "GET", body: nil) + } + + public func post(_ path: String, body: B) async throws -> T { + try await send(url: url(path), method: "POST", body: JSONEncoder().encode(body)) + } + + public func put(_ path: String, body: B) async throws -> T { + try await send(url: url(path), method: "PUT", body: JSONEncoder().encode(body)) + } + + public func delete(_ path: String) async throws -> T { + try await send(url: url(path), method: "DELETE", body: nil) + } + + public func delete(_ path: String, query: [URLQueryItem]) async throws -> T { + try await send(url: url(path, query: query), method: "DELETE", body: nil) + } + + private func url(_ path: String, query: [URLQueryItem]) -> URL { + var components = URLComponents(url: url(path), resolvingAgainstBaseURL: true)! + if !query.isEmpty { + components.queryItems = query + } + return components.url! + } + + private func send(url: URL, method: String, body: Data?) async throws -> T { + let data = try await raw(url: url, method: method, body: body) + if T.self == EmptyResponse.self { return EmptyResponse() as! T } + return try JSONDecoder().decode(T.self, from: data) + } + + private func raw(url: URL, method: String, body: Data?) async throws -> Data { + var req = URLRequest(url: url) + req.httpMethod = method + req.httpBody = body + if body != nil { req.setValue("application/json", forHTTPHeaderField: "Content-Type") } + if let bearerToken, !bearerToken.isEmpty { + req.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") + } + let (data, resp) = try await session.data(for: req) + let status = (resp as? HTTPURLResponse)?.statusCode ?? 0 + guard (200..<300).contains(status) else { throw APIError(status: status, body: data) } + return data + } +} + +public struct EmptyResponse: Decodable, Sendable { + public init() {} +} diff --git a/macos/AntaresKit/Sources/AntaresKit/AntaresKit.swift b/macos/AntaresKit/Sources/AntaresKit/AntaresKit.swift new file mode 100644 index 00000000..9081b89a --- /dev/null +++ b/macos/AntaresKit/Sources/AntaresKit/AntaresKit.swift @@ -0,0 +1,12 @@ +import Foundation + +public enum AntaresKit { + public static let defaultBaseURL = URL(string: "http://127.0.0.1:8787")! + + /// Shared by `APIClient` and `RuntimeSupervisor`. `ANTARES_URL` overrides the loopback default. + public static func resolveBaseURL( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> URL { + environment["ANTARES_URL"].flatMap(URL.init(string:)) ?? defaultBaseURL + } +} diff --git a/macos/AntaresKit/Sources/AntaresKit/AuthSetupModels.swift b/macos/AntaresKit/Sources/AntaresKit/AuthSetupModels.swift new file mode 100644 index 00000000..7a023906 --- /dev/null +++ b/macos/AntaresKit/Sources/AntaresKit/AuthSetupModels.swift @@ -0,0 +1,208 @@ +import Foundation + +public struct AuthStatus: Decodable, Equatable, Sendable { + public var passwordRequired: Bool + public var authenticated: Bool + + enum CodingKeys: String, CodingKey { + case passwordRequired = "password_required" + case authenticated + } +} + +public struct LoginRequest: Encodable, Equatable, Sendable { + public var password: String + + public init(password: String) { + self.password = password + } +} + +public struct SetPasswordRequest: Encodable, Equatable, Sendable { + public var password: String + public var current: String? + + public init(password: String, current: String? = nil) { + self.password = password + self.current = current + } +} + +public struct SetupStatus: Decodable, Equatable, Sendable { + public var needsSetup: Bool + public var model: String + public var provider: String + public var workspace: String + public var home: String + public var configPath: String + public var providers: [SetupProvider] + public var database: String + + enum CodingKeys: String, CodingKey { + case needsSetup = "needs_setup" + case model + case provider + case workspace + case home + case configPath = "config_path" + case providers + case database + } +} + +public struct SetupProvider: Decodable, Identifiable, Equatable, Sendable { + public var id: String + public var label: String + public var kind: String + public var hint: String + public var keyHint: String? + public var keyURL: String? + public var baseURL: String? + public var local: Bool + public var models: [String]? + public var hasKey: Bool + public var keyLabel: String? + public var note: String? + public var needsRegion: Bool? + public var needsAPIVersion: Bool? + public var needsBaseURL: Bool? + public var custom: Bool? + + enum CodingKeys: String, CodingKey { + case id + case label + case kind + case hint + case keyHint = "key_hint" + case keyURL = "key_url" + case baseURL = "base_url" + case local + case models + case hasKey = "has_key" + case keyLabel = "key_label" + case note + case needsRegion = "needs_region" + case needsAPIVersion = "needs_api_version" + case needsBaseURL = "needs_base_url" + case custom + } +} + +public struct SetupTestRequest: Encodable, Equatable, Sendable { + public var provider: String + public var baseURL: String + public var apiKey: String + public var headers: [String: String]? + + public init(provider: String, baseURL: String, apiKey: String, headers: [String: String]? = nil) { + self.provider = provider + self.baseURL = baseURL + self.apiKey = apiKey + self.headers = headers + } + + enum CodingKeys: String, CodingKey { + case provider + case baseURL = "base_url" + case apiKey = "api_key" + case headers + } +} + +public struct SetupTestResponse: Decodable, Equatable, Sendable { + public var ok: Bool + public var error: String? + public var note: String? + public var models: [String]? + public var suggested: [String]? +} + +public struct CompleteSetupRequest: Encodable, Equatable, Sendable { + public var provider: String + public var name: String + public var baseURL: String + public var apiKey: String + public var headers: [String: String]? + public var model: String + public var workspace: String + public var database: SetupDatabaseRequest + public var rag: SetupRAGRequest + public var telegramToken: String + public var dashboardPassword: String + + public init( + provider: String, + name: String = "", + baseURL: String, + apiKey: String, + headers: [String: String]? = nil, + model: String, + workspace: String, + database: SetupDatabaseRequest, + rag: SetupRAGRequest, + telegramToken: String = "", + dashboardPassword: String = "" + ) { + self.provider = provider + self.name = name + self.baseURL = baseURL + self.apiKey = apiKey + self.headers = headers + self.model = model + self.workspace = workspace + self.database = database + self.rag = rag + self.telegramToken = telegramToken + self.dashboardPassword = dashboardPassword + } + + enum CodingKeys: String, CodingKey { + case provider + case name + case baseURL = "base_url" + case apiKey = "api_key" + case headers + case model + case workspace + case database + case rag + case telegramToken = "telegram_token" + case dashboardPassword = "dashboard_password" + } +} + +public struct SetupDatabaseRequest: Encodable, Equatable, Sendable { + public var driver: String + public var dsn: String + + public init(driver: String = "sqlite", dsn: String = "") { + self.driver = driver + self.dsn = dsn + } +} + +public struct SetupRAGRequest: Encodable, Equatable, Sendable { + public var enabled: Bool + public var embedProvider: String + public var embedModel: String + public var embedAPIKey: String + + public init( + enabled: Bool = false, + embedProvider: String = "voyage", + embedModel: String = "voyage-4", + embedAPIKey: String = "" + ) { + self.enabled = enabled + self.embedProvider = embedProvider + self.embedModel = embedModel + self.embedAPIKey = embedAPIKey + } + + enum CodingKeys: String, CodingKey { + case enabled + case embedProvider = "embed_provider" + case embedModel = "embed_model" + case embedAPIKey = "embed_api_key" + } +} diff --git a/macos/AntaresKit/Sources/AntaresKit/ChatAPI.swift b/macos/AntaresKit/Sources/AntaresKit/ChatAPI.swift new file mode 100644 index 00000000..eb2cecd3 --- /dev/null +++ b/macos/AntaresKit/Sources/AntaresKit/ChatAPI.swift @@ -0,0 +1,335 @@ +import Foundation + +public struct ChatSendRequest: Encodable, Sendable { + public var sessionID: String? + public var message: String + public var images: [String] + public var role: String? + public var model: String? + public var toolset: String? + public var reasoningEffort: String? + + public init( + sessionID: String? = nil, + message: String, + images: [String] = [], + role: String? = nil, + model: String? = nil, + toolset: String? = nil, + reasoningEffort: String? = nil + ) { + self.sessionID = sessionID + self.message = message + self.images = images + self.role = role + self.model = model + self.toolset = toolset + self.reasoningEffort = reasoningEffort + } + + enum CodingKeys: String, CodingKey { + case sessionID = "session_id" + case message + case images + case role + case model + case toolset + case reasoningEffort = "reasoning_effort" + } +} + +public struct ChatInterruptRequest: Encodable, Sendable { + public var sessionID: String + + public init(sessionID: String) { + self.sessionID = sessionID + } + + enum CodingKeys: String, CodingKey { + case sessionID = "session_id" + } +} + +public struct ChatEditRequest: Encodable, Sendable { + public var messageID: String + public var revert: Bool + + public init(messageID: String, revert: Bool) { + self.messageID = messageID + self.revert = revert + } + + enum CodingKeys: String, CodingKey { + case messageID = "message_id" + case revert + } +} + +public struct ChatUploadRequest: Encodable, Sendable { + public var sessionID: String? + public var name: String + public var data: String + + public init(sessionID: String? = nil, name: String, data: String) { + self.sessionID = sessionID + self.name = name + self.data = data + } + + enum CodingKeys: String, CodingKey { + case sessionID = "session_id" + case name + case data + } +} + +public struct ChatUploadResponse: Decodable, Sendable { + public var ok: Bool + public var path: String + public var name: String + public var size: Int +} + +public struct ChatInterruptResponse: Decodable, Sendable { + public var interrupted: Bool +} + +public struct ChatEditResponse: Decodable, Sendable { + public var ok: Bool + public var reverted: [String] + public var skipped: [String] +} + +public struct SessionDetail: Decodable, Sendable { + public var session: SessionSummary + public var messages: [SessionMessage] +} + +public struct SessionSummary: Decodable, Identifiable, Sendable { + public var id: String + public var title: String + public var platform: String + public var channelID: String + public var userID: String + public var model: String? + public var provider: String? + public var workspace: String + public var messageCount: Int + public var tokensIn: Int + public var tokensOut: Int + public var cost: Double + public var archived: Bool + public var pinned: Bool + public var createdAt: String + public var updatedAt: String + public var meta: [String: FlexibleJSON]? + + enum CodingKeys: String, CodingKey { + case id + case title + case platform + case channelID = "channel_id" + case userID = "user_id" + case model + case provider + case workspace + case messageCount = "message_count" + case tokensIn = "tokens_in" + case tokensOut = "tokens_out" + case cost + case archived + case pinned + case createdAt = "created_at" + case updatedAt = "updated_at" + case meta + } +} + +public struct SessionMessage: Decodable, Identifiable, Sendable { + public var id: String + public var role: String + public var content: String + public var reasoning: String? + public var toolCalls: String? + public var toolCallID: String? + public var toolName: String? + public var attachments: String? + public var createdAt: String + public var tokensIn: Int? + public var tokensOut: Int? + public var hidden: Bool? + public var meta: [String: FlexibleJSON]? + + enum CodingKeys: String, CodingKey { + case id + case role + case content + case reasoning + case toolCalls = "tool_calls" + case toolCallID = "tool_call_id" + case toolName = "tool_name" + case attachments + case createdAt = "created_at" + case tokensIn = "tokens_in" + case tokensOut = "tokens_out" + case hidden + case meta + } +} + +public enum FlexibleJSON: Decodable, Sendable, Equatable, Hashable { + case string(String) + case bool(Bool) + case number(Double) + case object([String: FlexibleJSON]) + case array([FlexibleJSON]) + case null + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([FlexibleJSON].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: FlexibleJSON].self)) + } + } + + public var boolValue: Bool? { + if case .bool(let value) = self { return value } + return nil + } + + public var stringValue: String? { + if case .string(let value) = self { return value } + return nil + } + + public var numberValue: Double? { + if case .number(let value) = self { return value } + return nil + } + + public var displayString: String { + switch self { + case .string(let value): return value + case .bool(let value): return value ? "true" : "false" + case .number(let value): + if value.rounded() == value { return String(Int(value)) } + return String(value) + case .null: return "null" + case .array, .object: return "" + } + } +} + +extension APIClient { + public func getSession(_ id: String) async throws -> SessionDetail { + try await get("/api/sessions/\(id)") + } + + public func interruptChat(sessionID: String) async throws -> ChatInterruptResponse { + try await post("/api/chat/interrupt", body: ChatInterruptRequest(sessionID: sessionID)) + } + + public func editSession( + sessionID: String, + messageID: String, + revert: Bool = false + ) async throws -> ChatEditResponse { + try await post( + "/api/sessions/\(sessionID)/edit", + body: ChatEditRequest(messageID: messageID, revert: revert) + ) + } + + public func upload(_ request: ChatUploadRequest) async throws -> ChatUploadResponse { + try await post("/api/upload", body: request) + } + + public func streamChat( + _ request: ChatSendRequest, + onEvent: @escaping @Sendable ([String: String]) async -> Void, + onParseError: (@Sendable (String, Error) async -> Void)? = nil + ) async throws { + var urlRequest = try makeJSONStreamRequest(path: "/api/chat", method: "POST", body: request) + urlRequest.setValue("text/event-stream", forHTTPHeaderField: "Accept") + try await stream(urlRequest, onEvent: onEvent, onParseError: onParseError) + } + + public func attachChat( + sessionID: String, + cursor: Int = 0, + onEvent: @escaping @Sendable ([String: String]) async -> Void, + onParseError: (@Sendable (String, Error) async -> Void)? = nil + ) async throws { + var components = URLComponents(url: url("/api/chat/attach"), resolvingAgainstBaseURL: false)! + components.queryItems = [ + URLQueryItem(name: "session_id", value: sessionID), + URLQueryItem(name: "cursor", value: String(cursor)), + ] + var request = URLRequest(url: components.url!) + request.httpMethod = "GET" + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + if let bearerToken, !bearerToken.isEmpty { + request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") + } + try await stream(request, onEvent: onEvent, onParseError: onParseError) + } + + private func makeJSONStreamRequest( + path: String, + method: String, + body: B + ) throws -> URLRequest { + var request = URLRequest(url: url(path)) + request.httpMethod = method + request.httpBody = try JSONEncoder().encode(body) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let bearerToken, !bearerToken.isEmpty { + request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") + } + return request + } + + private func stream( + _ request: URLRequest, + onEvent: @escaping @Sendable ([String: String]) async -> Void, + onParseError: (@Sendable (String, Error) async -> Void)? + ) async throws { + let (bytes, response) = try await session.bytes(for: request) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard (200..<300).contains(status) else { + throw APIError(status: status, body: Data()) + } + + try await SSEStream.consume(bytes, onEvent: onEvent, onParseError: onParseError) + } + + public func streamGetJSON( + _ path: String, + as type: T.Type = T.self, + onEvent: @escaping @Sendable (T) async -> Void, + onParseError: (@Sendable (String, Error) async -> Void)? = nil + ) async throws { + var request = URLRequest(url: url(path)) + request.httpMethod = "GET" + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + if let bearerToken, !bearerToken.isEmpty { + request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") + } + let (bytes, response) = try await session.bytes(for: request) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard (200..<300).contains(status) else { + throw APIError(status: status, body: Data()) + } + try await SSEStream.consumeJSON(bytes, as: type, onEvent: onEvent, onParseError: onParseError) + } +} diff --git a/macos/AntaresKit/Sources/AntaresKit/ChatEvents.swift b/macos/AntaresKit/Sources/AntaresKit/ChatEvents.swift new file mode 100644 index 00000000..fc75437d --- /dev/null +++ b/macos/AntaresKit/Sources/AntaresKit/ChatEvents.swift @@ -0,0 +1,281 @@ +import Foundation + +public struct ChatToolCall: Equatable, Identifiable, Sendable { + public var id: String + public var name: String + public var args: String + public var progress: String + public var result: String? + public var isError: Bool + public var running: Bool + + public init( + id: String = "", + name: String = "", + args: String = "", + progress: String = "", + result: String? = nil, + isError: Bool = false, + running: Bool = false + ) { + self.id = id + self.name = name + self.args = args + self.progress = progress + self.result = result + self.isError = isError + self.running = running + } +} + +public struct ChatUsage: Equatable, Sendable { + public var inputTokens: Int? + public var outputTokens: Int? + public var contextTokens: Int? + public var contextWindow: Int? + public var cost: Double? + + public init( + inputTokens: Int? = nil, + outputTokens: Int? = nil, + contextTokens: Int? = nil, + contextWindow: Int? = nil, + cost: Double? = nil + ) { + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.contextTokens = contextTokens + self.contextWindow = contextWindow + self.cost = cost + } +} + +public struct ChatTranscript: Equatable, Sendable { + public var sessionID: String? + public var title: String? + public var assistantText: String + public var reasoning: String + public var toolCalls: [ChatToolCall] + public var notice: String? + public var askID: String? + public var usage: ChatUsage? + public var turn: Int? + public var done: Bool + public var error: String? + + public init( + sessionID: String? = nil, + title: String? = nil, + assistantText: String = "", + reasoning: String = "", + toolCalls: [ChatToolCall] = [], + notice: String? = nil, + askID: String? = nil, + usage: ChatUsage? = nil, + turn: Int? = nil, + done: Bool = false, + error: String? = nil + ) { + self.sessionID = sessionID + self.title = title + self.assistantText = assistantText + self.reasoning = reasoning + self.toolCalls = toolCalls + self.notice = notice + self.askID = askID + self.usage = usage + self.turn = turn + self.done = done + self.error = error + } + + public mutating func apply(_ event: [String: String]) { + guard let type = event["type"] else { return } + + switch type { + case "session": + if let id = event["id"] { sessionID = id } + if let title = event["title"] { self.title = title } + + case "turn": + if let turnStr = event["turn"], let n = Int(turnStr) { + turn = n + } + + case "text": + assistantText += event["delta"] ?? "" + + case "reasoning": + reasoning += event["delta"] ?? "" + + case "tool_call": + toolCalls.append(ChatToolCall( + id: event["id"] ?? "", + name: event["name"] ?? "", + args: event["arguments"] ?? "", + running: true + )) + + case "tool_progress": + let id = event["id"] ?? "" + updateTool(id: id) { tool in + if let chunk = event["chunk"] { + tool.progress += chunk + } else { + tool.progress = event["message"] ?? tool.progress + } + } + + case "tool_result": + let id = event["id"] ?? "" + updateTool(id: id) { tool in + tool.result = event["content"] ?? "" + tool.isError = event["is_error"] == "true" + tool.running = false + } + + case "usage": + var u = usage ?? ChatUsage() + if let v = event["input_tokens"], let n = Int(v) { u.inputTokens = n } + if let v = event["output_tokens"], let n = Int(v) { u.outputTokens = n } + if let v = event["context_tokens"], let n = Int(v) { u.contextTokens = n } + if let v = event["context_window"], let n = Int(v) { u.contextWindow = n } + if let v = event["cost"], let n = Double(v) { u.cost = n } + usage = u + + case "notice": + let raw = event["message"] ?? event["content"] ?? "" + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + notice = trimmed.isEmpty ? nil : trimmed + + case "ask": + askID = event["id"] ?? "" + + case "reset": + assistantText = "" + reasoning = "" + toolCalls = [] + error = nil + + case "error": + error = event["error"] + + case "done": + done = true + + case "approval": + break + + default: + break + } + } + + public var hasDisplayableContent: Bool { + !assistantText.isEmpty || !reasoning.isEmpty || !toolCalls.isEmpty || error != nil + } + + private mutating func updateTool(id: String, update: (inout ChatToolCall) -> Void) { + guard let idx = toolCalls.firstIndex(where: { $0.id == id }) else { return } + update(&toolCalls[idx]) + } +} + +public struct ChatHistoryMessage: Equatable, Identifiable, Sendable { + public var id: String + public var role: String + public var content: String + public var reasoning: String + public var toolCalls: [ChatToolCall] + public var error: String? + + public init( + id: String, + role: String, + content: String = "", + reasoning: String = "", + toolCalls: [ChatToolCall] = [], + error: String? = nil + ) { + self.id = id + self.role = role + self.content = content + self.reasoning = reasoning + self.toolCalls = toolCalls + self.error = error + } +} + +public func hydrateChat(_ detail: SessionDetail) -> [ChatHistoryMessage] { + var rows: [ChatHistoryMessage] = [] + var toolIndex: [String: Int] = [:] + + for message in detail.messages { + if message.hidden == true { continue } + + if message.role == "tool" { + if let callID = message.toolCallID, let idx = toolIndex[callID], rows.indices.contains(idx) { + rows[idx].toolCalls = rows[idx].toolCalls.map { call in + guard call.id == callID else { return call } + var next = call + next.result = message.content + next.running = false + next.isError = message.meta?["is_error"]?.boolValue == true + return next + } + } + continue + } + + var row = ChatHistoryMessage( + id: message.id, + role: message.role, + content: message.content, + reasoning: message.reasoning ?? "" + ) + + if message.role == "assistant", message.meta?["is_error"]?.boolValue == true { + row.error = parseHydratedError(message.content) + row.content = "" + } + + if let raw = message.toolCalls, let parsed = parseHydratedToolCalls(raw) { + row.toolCalls = parsed + let index = rows.count + for call in parsed { + toolIndex[call.id] = index + } + } + + rows.append(row) + } + + return rows +} + +private func parseHydratedError(_ content: String) -> String { + guard let data = content.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = obj["error"] as? String + else { + return content + } + return error +} + +private struct EncodedHydratedToolCall: Decodable { + var id: String + var name: String + var arguments: String +} + +private func parseHydratedToolCalls(_ raw: String) -> [ChatToolCall]? { + guard let data = raw.data(using: .utf8), + let parsed = try? JSONDecoder().decode([EncodedHydratedToolCall].self, from: data) + else { + return nil + } + return parsed.map { + ChatToolCall(id: $0.id, name: $0.name, args: $0.arguments, running: true) + } +} diff --git a/macos/AntaresKit/Sources/AntaresKit/ConfigSchema.swift b/macos/AntaresKit/Sources/AntaresKit/ConfigSchema.swift new file mode 100644 index 00000000..0e7f63d0 --- /dev/null +++ b/macos/AntaresKit/Sources/AntaresKit/ConfigSchema.swift @@ -0,0 +1,319 @@ +import Foundation + +public enum ConfigFieldKind: String, Sendable, Equatable, Hashable { + case string + case number + case boolean + case stringArray = "string[]" + case object +} + +public struct ConfigField: Decodable, Sendable, Identifiable, Hashable { + public var path: String + public var label: String + public var group: String + public var type: ConfigFieldKind + public var tier: String + public var defaultValue: FlexibleJSON? + public var secret: Bool + public var enumValues: [String]? + public var help: String? + public var reload: String? + + public var id: String { path } + + enum CodingKeys: String, CodingKey { + case path + case label + case group + case type + case tier + case defaultValue = "default" + case secret + case enumValues = "enum" + case help + case reload + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + path = try container.decodeIfPresent(String.self, forKey: .path) ?? "" + label = try container.decodeIfPresent(String.self, forKey: .label) ?? "" + group = try container.decodeIfPresent(String.self, forKey: .group) ?? "" + let rawType = try container.decodeIfPresent(String.self, forKey: .type) ?? "" + type = ConfigFieldKind(rawValue: rawType) ?? .string + tier = try container.decodeIfPresent(String.self, forKey: .tier) ?? "" + defaultValue = try container.decodeIfPresent(FlexibleJSON.self, forKey: .defaultValue) + secret = try container.decodeIfPresent(Bool.self, forKey: .secret) ?? false + enumValues = try container.decodeIfPresent([String].self, forKey: .enumValues) + help = try container.decodeIfPresent(String.self, forKey: .help) + reload = try container.decodeIfPresent(String.self, forKey: .reload) + } +} + +public struct ConfigResponse: Decodable, Sendable { + public var values: [String: FlexibleJSON] + public var schema: [ConfigField] + public var restartFields: [String] + public var profile: String + public var path: String + + public var formFields: [ConfigField] { + schema.filter { $0.type != .object } + } + + enum CodingKeys: String, CodingKey { + case values + case schema + case restartFields = "restart_fields" + case profile + case path + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + values = try container.decodeIfPresent([String: FlexibleJSON].self, forKey: .values) ?? [:] + schema = try container.decodeIfPresent([ConfigField].self, forKey: .schema) ?? [] + restartFields = try container.decodeIfPresent([String].self, forKey: .restartFields) ?? [] + profile = try container.decodeIfPresent(String.self, forKey: .profile) ?? "" + path = try container.decodeIfPresent(String.self, forKey: .path) ?? "" + } + + public func value(at path: String) -> FlexibleJSON? { + Self.value(at: path, in: values) + } + + public static func value(at path: String, in values: [String: FlexibleJSON]) -> FlexibleJSON? { + let parts = path.split(separator: ".").map(String.init) + guard !parts.isEmpty else { return nil } + var current: FlexibleJSON = .object(values) + for part in parts { + guard case .object(let object) = current, let next = object[part] else { + return nil + } + current = next + } + return current + } +} + +public struct ConfigSchemaResponse: Decodable, Sendable { + public var schema: [ConfigField] + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + schema = try container.decodeIfPresent([ConfigField].self, forKey: .schema) ?? [] + } + + enum CodingKeys: String, CodingKey { + case schema + } +} + +public enum ConfigJSON: Codable, Sendable, Equatable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case strings([String]) + case null + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([String].self) { + self = .strings(value) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unsupported config JSON value" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .int(let value): + try container.encode(value) + case .double(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + case .strings(let value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } + + public var boolValue: Bool { + if case .bool(let value) = self { return value } + return false + } + + public var stringValue: String { + switch self { + case .string(let value): + return value + case .int(let value): + return String(value) + case .double(let value): + if value.rounded() == value { return String(Int(value)) } + return String(value) + case .bool(let value): + return value ? "true" : "false" + case .strings(let value): + return value.joined(separator: ", ") + case .null: + return "" + } + } + + public var stringArray: [String] { + if case .strings(let value) = self { return value } + if stringValue.isEmpty { return [] } + return stringValue.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } + } + + public var isMaskedSecret: Bool { + if case .string(let value) = self { + return value.contains("••••") + } + return false + } +} + +public struct ConfigUpdateRequest: Encodable, Sendable { + public var updates: [String: ConfigJSON] + + public init(updates: [String: ConfigJSON]) { + self.updates = updates + } +} + +public struct ConfigUpdateResponse: Decodable, Sendable { + public var saved: Int? + public var values: [String: FlexibleJSON]? + public var restartFields: [String] + + enum CodingKeys: String, CodingKey { + case saved + case values + case restartFields = "restart_fields" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + saved = try container.decodeIfPresent(Int.self, forKey: .saved) + values = try container.decodeIfPresent([String: FlexibleJSON].self, forKey: .values) + restartFields = try container.decodeIfPresent([String].self, forKey: .restartFields) ?? [] + } +} + +public struct ConfigRawResponse: Decodable, Sendable { + public var yaml: String + public var path: String + + enum CodingKeys: String, CodingKey { + case yaml + case path + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + yaml = try container.decodeIfPresent(String.self, forKey: .yaml) ?? "" + path = try container.decodeIfPresent(String.self, forKey: .path) ?? "" + } +} + +public struct ConfigRawRequest: Encodable, Sendable { + public var yaml: String + + public init(yaml: String) { + self.yaml = yaml + } +} + +public struct ConfigRawSaveResponse: Decodable, Sendable { + public var saved: Bool? + public var restartFields: [String] + + enum CodingKeys: String, CodingKey { + case saved + case restartFields = "restart_fields" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + saved = try container.decodeIfPresent(Bool.self, forKey: .saved) + restartFields = try container.decodeIfPresent([String].self, forKey: .restartFields) ?? [] + } +} + +extension FlexibleJSON { + public var configJSON: ConfigJSON { + switch self { + case .string(let value): + return .string(value) + case .bool(let value): + return .bool(value) + case .number(let value): + if value.rounded() == value, let intValue = Int(exactly: value) { + return .int(intValue) + } + return .double(value) + case .array(let items): + let strings = items.compactMap(\.stringValue) + if strings.count == items.count { + return .strings(strings) + } + return .string(displayString) + case .object, .null: + return .string(displayString) + } + } + + public var stringArray: [String] { + if case .array(let items) = self { + return items.compactMap(\.stringValue) + } + if let value = stringValue, !value.isEmpty { + return [value] + } + return [] + } +} + +extension APIClient { + public func getConfig() async throws -> ConfigResponse { + try await get("/api/config") + } + + public func getConfigSchema() async throws -> ConfigSchemaResponse { + try await get("/api/config/schema") + } + + public func updateConfig(_ updates: [String: ConfigJSON]) async throws -> ConfigUpdateResponse { + try await post("/api/config", body: ConfigUpdateRequest(updates: updates)) + } + + public func getRawConfig() async throws -> ConfigRawResponse { + try await get("/api/config/raw") + } + + public func saveRawConfig(_ yaml: String) async throws -> ConfigRawSaveResponse { + try await post("/api/config/raw", body: ConfigRawRequest(yaml: yaml)) + } +} diff --git a/macos/AntaresKit/Sources/AntaresKit/ControlModels.swift b/macos/AntaresKit/Sources/AntaresKit/ControlModels.swift new file mode 100644 index 00000000..a338de2f --- /dev/null +++ b/macos/AntaresKit/Sources/AntaresKit/ControlModels.swift @@ -0,0 +1,524 @@ +import Foundation + +public struct RolesResponse: Decodable, Sendable { + public var roles: [RoleInfo] + public var scope: [String]? + public var active: [ActiveAgentInfo]? + public var performance: [RolePerformance]? + public var toolsets: [String]? + public var categories: [String]? + + enum CodingKeys: String, CodingKey { + case roles, scope, active, performance, toolsets, categories + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + roles = try container.decodeIfPresent([RoleInfo].self, forKey: .roles) ?? [] + scope = try container.decodeIfPresent([String].self, forKey: .scope) + active = try container.decodeIfPresent([ActiveAgentInfo].self, forKey: .active) + performance = try container.decodeIfPresent([RolePerformance].self, forKey: .performance) + toolsets = try container.decodeIfPresent([String].self, forKey: .toolsets) + categories = try container.decodeIfPresent([String].self, forKey: .categories) + } +} + +public struct RoleInfo: Decodable, Identifiable, Sendable, Hashable { + public var name: String + public var title: String + public var summary: String + public var category: String + public var toolset: String + public var model: String + public var effort: String? + public var maxTurns: Int? + public var tags: [String]? + public var danger: Bool? + public var subrole: Bool? + public var parent: String? + public var source: String + + public var id: String { name } + public var isDanger: Bool { danger ?? false } + public var isSubrole: Bool { subrole ?? false } + + enum CodingKeys: String, CodingKey { + case name + case title + case summary + case category + case toolset + case model + case effort + case maxTurns = "max_turns" + case tags + case danger + case subrole + case parent + case source + } +} + +public struct ActiveAgentInfo: Decodable, Identifiable, Sendable { + public var id: String + public var role: String + public var task: String + public var parent: String? + public var startedAt: String + + enum CodingKeys: String, CodingKey { + case id + case role + case task + case parent + case startedAt = "started_at" + } +} + +public struct RolePerformance: Decodable, Identifiable, Sendable { + public var role: String + public var missions: Int + public var successes: Int + public var failures: Int? + public var kept: Int + public var totalTurns: Int? + public var score: Double + public var updatedAt: String? + + public var id: String { role } + + enum CodingKeys: String, CodingKey { + case role + case missions + case successes + case failures + case kept + case totalTurns = "total_turns" + case score + case updatedAt = "updated_at" + } +} + +public struct SessionRoleResponse: Decodable, Sendable { + public var role: String +} + +public struct SoulResponse: Decodable, Sendable { + public var soul: String + public var unset: Bool +} + +public struct SoulRequest: Encodable, Sendable { + public var soul: String + + public init(soul: String) { + self.soul = soul + } +} + +public struct SoulSaveResponse: Decodable, Sendable { + public var ok: Bool + public var unset: Bool +} + +public struct SkillsResponse: Decodable, Sendable { + public var skills: [SkillInfo] + public var library: Int? + public var searching: Bool? + + enum CodingKeys: String, CodingKey { + case skills, library, searching + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + skills = try container.decodeIfPresent([SkillInfo].self, forKey: .skills) ?? [] + library = try container.decodeIfPresent(Int.self, forKey: .library) + searching = try container.decodeIfPresent(Bool.self, forKey: .searching) + } +} + +public struct SkillInfo: Decodable, Identifiable, Sendable { + public var name: String + public var description: String + public var path: String + public var enabled: Bool + public var source: String + public var tags: [String]? + public var triggers: [String]? + public var updatedAt: String + public var usageCount: Int + + public var id: String { name } + + enum CodingKeys: String, CodingKey { + case name + case description + case path + case enabled + case source + case tags + case triggers + case updatedAt = "updated_at" + case usageCount = "usage_count" + } +} + +public struct HubSkillsResponse: Decodable, Sendable { + public var skills: [HubSkill] + public var error: String? + + enum CodingKeys: String, CodingKey { + case skills, error + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + skills = try container.decodeIfPresent([HubSkill].self, forKey: .skills) ?? [] + error = try container.decodeIfPresent(String.self, forKey: .error) + } +} + +public struct HubSkill: Decodable, Identifiable, Sendable { + public var id: String + public var kind: String + public var name: String + public var summary: String + public var source: String + public var tags: [String]? + public var homepage: String? + public var author: String? + public var installed: Bool +} + +public struct HubInstallRequest: Encodable, Sendable { + public var id: String + + public init(id: String) { + self.id = id + } +} + +public struct HubInstallResponse: Decodable, Sendable { + public var ok: Bool + public var error: String? + public var name: String? + public var path: String? + public var summary: String? + public var missingKeys: [String]? + + enum CodingKeys: String, CodingKey { + case ok + case error + case name + case path + case summary + case missingKeys = "missing_keys" + } +} + +public struct CronJobsResponse: Decodable, Sendable { + public var jobs: [CronJob] + + enum CodingKeys: String, CodingKey { + case jobs + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + jobs = try container.decodeIfPresent([CronJob].self, forKey: .jobs) ?? [] + } +} + +public struct CronJob: Decodable, Identifiable, Sendable { + public var id: String + public var name: String + public var schedule: String + public var prompt: String + public var enabled: Bool + public var target: String + public var timezone: String? + public var lastRun: String? + public var nextRun: String? + public var lastState: String + public var createdAt: String? + public var updatedAt: String? + public var meta: [String: FlexibleJSON]? + + enum CodingKeys: String, CodingKey { + case id + case name + case schedule + case prompt + case enabled + case target + case timezone + case lastRun = "last_run" + case nextRun = "next_run" + case lastState = "last_state" + case createdAt = "created_at" + case updatedAt = "updated_at" + case meta + } +} + +public struct CreateCronRequest: Encodable, Sendable { + public var name: String + public var schedule: String + public var prompt: String + + public init(name: String, schedule: String, prompt: String) { + self.name = name + self.schedule = schedule + self.prompt = prompt + } +} + +public struct ToggleEnabledRequest: Encodable, Sendable { + public var enabled: Bool + + public init(enabled: Bool) { + self.enabled = enabled + } +} + +public struct StartedFlagResponse: Decodable, Sendable { + public var started: Bool +} + +public struct ChannelsResponse: Decodable, Sendable { + public var channels: [ChannelInfo] + public var pairings: [PairingInfo] + + enum CodingKeys: String, CodingKey { + case channels, pairings + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + channels = try container.decodeIfPresent([ChannelInfo].self, forKey: .channels) ?? [] + pairings = try container.decodeIfPresent([PairingInfo].self, forKey: .pairings) ?? [] + } +} + +public struct ChannelInfo: Decodable, Identifiable, Sendable { + public var id: String + public var label: String + public var enabled: Bool + public var connected: Bool + public var configured: Bool + public var hasToken: Bool? + public var botName: String? + public var replyStyle: String? + public var detail: String + public var docs: String? + public var fields: [ChannelField] + + enum CodingKeys: String, CodingKey { + case id + case label + case enabled + case connected + case configured + case hasToken = "has_token" + case botName = "bot_name" + case replyStyle = "reply_style" + case detail + case docs + case fields + } +} + +public struct ChannelField: Decodable, Identifiable, Sendable { + public var key: String + public var label: String + public var secret: Bool + public var placeholder: String? + public var set: Bool + + public var id: String { key } + + public init(key: String, label: String, secret: Bool, placeholder: String? = nil, set: Bool) { + self.key = key + self.label = label + self.secret = secret + self.placeholder = placeholder + self.set = set + } +} + +public enum ChannelConfigForm { + public static let optionalKeys: Set = ["listen_addr", "path", "user_id"] + + public static func collectedFields(_ values: [String: String]) -> [String: String] { + var fields: [String: String] = [:] + for (key, value) in values { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + fields[key] = trimmed + } + } + return fields + } + + public static func hasMissingRequired(_ fields: [ChannelField], values: [String: String]) -> Bool { + fields.contains { field in + guard !optionalKeys.contains(field.key) else { return false } + if field.set { return false } + let trimmed = values[field.key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty + } + } +} + +public struct PairingInfo: Decodable, Identifiable, Sendable { + public var id: String + public var platform: String + public var externalID: String + public var displayName: String + public var status: String + public var code: String? + public var createdAt: String + public var updatedAt: String? + + enum CodingKeys: String, CodingKey { + case id + case platform + case externalID = "external_id" + case displayName = "display_name" + case status + case code + case createdAt = "created_at" + case updatedAt = "updated_at" + } +} + +public struct ChannelTokenRequest: Encodable, Sendable { + public var token: String + + public init(token: String) { + self.token = token + } +} + +public struct ChannelConfigRequest: Encodable, Sendable { + public var fields: [String: String] + public var enabled: Bool? + + public init(fields: [String: String], enabled: Bool? = nil) { + self.fields = fields + self.enabled = enabled + } + + enum CodingKeys: String, CodingKey { + case fields + case enabled + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(fields, forKey: .fields) + try container.encodeIfPresent(enabled, forKey: .enabled) + } +} + +public struct ChannelMutationResponse: Decodable, Sendable { + public var ok: Bool + public var error: String? + public var restartRequired: Bool? + + enum CodingKeys: String, CodingKey { + case ok + case error + case restartRequired = "restart_required" + } +} + +public struct PairingIDRequest: Encodable, Sendable { + public var id: String + + public init(id: String) { + self.id = id + } +} + +extension APIClient { + public func listRoles() async throws -> RolesResponse { + try await get("/api/roles") + } + + public func sessionRole(_ sessionID: String) async throws -> SessionRoleResponse { + try await get("/api/sessions/\(sessionID)/role") + } + + public func getSoul() async throws -> SoulResponse { + try await get("/api/soul") + } + + public func saveSoul(_ soul: String) async throws -> SoulSaveResponse { + try await post("/api/soul", body: SoulRequest(soul: soul)) + } + + public func listSkills(query: String = "") async throws -> SkillsResponse { + if query.isEmpty { + return try await get("/api/skills") + } + return try await get("/api/skills", query: [URLQueryItem(name: "q", value: query)]) + } + + public func toggleSkill(name: String, enabled: Bool) async throws -> OKResponse { + try await post("/api/skills/toggle", body: ToggleToolRequest(name: name, enabled: enabled)) + } + + public func hubSkills(query: String = "") async throws -> HubSkillsResponse { + try await get("/api/hub/skills", query: [URLQueryItem(name: "q", value: query)]) + } + + public func installHubSkill(id: String) async throws -> HubInstallResponse { + try await post("/api/hub/skills/install", body: HubInstallRequest(id: id)) + } + + public func listCronJobs() async throws -> CronJobsResponse { + try await get("/api/cron/jobs") + } + + public func createCronJob(_ request: CreateCronRequest) async throws -> CronJob { + try await post("/api/cron/jobs", body: request) + } + + public func toggleCronJob(id: String, enabled: Bool) async throws -> CronJob { + try await post("/api/cron/jobs/\(id)/toggle", body: ToggleEnabledRequest(enabled: enabled)) + } + + public func runCronJob(id: String) async throws -> StartedFlagResponse { + try await post("/api/cron/jobs/\(id)/run", body: EmptyBody()) + } + + public func listChannels() async throws -> ChannelsResponse { + try await get("/api/channels") + } + + public func setChannelToken(id: String, token: String) async throws -> ChannelMutationResponse { + try await post("/api/channels/\(id)/token", body: ChannelTokenRequest(token: token)) + } + + public func setChannelConfig( + id: String, + fields: [String: String], + enabled: Bool? = nil + ) async throws -> ChannelMutationResponse { + try await post("/api/channels/\(id)/config", body: ChannelConfigRequest(fields: fields, enabled: enabled)) + } + + public func approvePairing(id: String) async throws -> PairingInfo { + try await post("/api/pairing/approve", body: PairingIDRequest(id: id)) + } + + public func revokePairing(id: String) async throws -> PairingInfo { + try await post("/api/pairing/revoke", body: PairingIDRequest(id: id)) + } +} + +public struct EmptyBody: Encodable, Sendable { + public init() {} +} diff --git a/macos/AntaresKit/Sources/AntaresKit/CreatorSocialModels.swift b/macos/AntaresKit/Sources/AntaresKit/CreatorSocialModels.swift new file mode 100644 index 00000000..d4688061 --- /dev/null +++ b/macos/AntaresKit/Sources/AntaresKit/CreatorSocialModels.swift @@ -0,0 +1,977 @@ +import Foundation + +public struct CreatorProjectsResponse: Decodable, Sendable { + public var projects: [CreatorProject] + + enum CodingKeys: String, CodingKey { + case projects + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + projects = try container.decodeIfPresent([CreatorProject].self, forKey: .projects) ?? [] + } +} + +public struct CreatorProject: Codable, Identifiable, Sendable { + public var id: String + public var title: String + public var brief: String + public var platform: String + public var accountID: String + public var style: String + public var size: String + public var targetSeconds: Int + public var caption: String + public var revision: Int + public var publishMode: String + public var status: String + public var error: String + public var createdAt: String + public var updatedAt: String + public var runSessionID: String + public var runStage: String + public var runPublishMode: String + public var runStatus: String + public var lastRunError: String + public var runStartedAt: String + public var runFinishedAt: String + public var references: [CreatorReference] + public var shots: [CreatorShot] + public var research: [CreatorTrend] + public var ideas: [CreatorIdea] + public var finalPath: String + public var finalHash: String + public var publication: CreatorPublication + + public var isRunning: Bool { runStatus == "running" } + + enum CodingKeys: String, CodingKey { + case id + case title + case brief + case platform + case accountID = "account_id" + case style + case size + case targetSeconds = "target_seconds" + case caption + case revision + case publishMode = "publish_mode" + case status + case error + case createdAt = "created_at" + case updatedAt = "updated_at" + case runSessionID = "run_session_id" + case runStage = "run_stage" + case runPublishMode = "run_publish_mode" + case runStatus = "run_status" + case lastRunError = "last_run_error" + case runStartedAt = "run_started_at" + case runFinishedAt = "run_finished_at" + case references + case shots + case research + case ideas + case finalPath = "final_path" + case finalHash = "final_hash" + case publication + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(String.self, forKey: .id) ?? "" + title = try container.decodeIfPresent(String.self, forKey: .title) ?? "" + brief = try container.decodeIfPresent(String.self, forKey: .brief) ?? "" + platform = try container.decodeIfPresent(String.self, forKey: .platform) ?? "" + accountID = try container.decodeIfPresent(String.self, forKey: .accountID) ?? "" + style = try container.decodeIfPresent(String.self, forKey: .style) ?? "" + size = try container.decodeIfPresent(String.self, forKey: .size) ?? "" + targetSeconds = try container.decodeIfPresent(Int.self, forKey: .targetSeconds) ?? 0 + caption = try container.decodeIfPresent(String.self, forKey: .caption) ?? "" + revision = try container.decodeIfPresent(Int.self, forKey: .revision) ?? 0 + publishMode = try container.decodeIfPresent(String.self, forKey: .publishMode) ?? "draft" + status = try container.decodeIfPresent(String.self, forKey: .status) ?? "" + error = try container.decodeIfPresent(String.self, forKey: .error) ?? "" + createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? "" + updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt) ?? "" + runSessionID = try container.decodeIfPresent(String.self, forKey: .runSessionID) ?? "" + runStage = try container.decodeIfPresent(String.self, forKey: .runStage) ?? "" + runPublishMode = try container.decodeIfPresent(String.self, forKey: .runPublishMode) ?? "" + runStatus = try container.decodeIfPresent(String.self, forKey: .runStatus) ?? "" + lastRunError = try container.decodeIfPresent(String.self, forKey: .lastRunError) ?? "" + runStartedAt = try container.decodeIfPresent(String.self, forKey: .runStartedAt) ?? "" + runFinishedAt = try container.decodeIfPresent(String.self, forKey: .runFinishedAt) ?? "" + references = try container.decodeIfPresent([CreatorReference].self, forKey: .references) ?? [] + shots = try container.decodeIfPresent([CreatorShot].self, forKey: .shots) ?? [] + research = try container.decodeIfPresent([CreatorTrend].self, forKey: .research) ?? [] + ideas = try container.decodeIfPresent([CreatorIdea].self, forKey: .ideas) ?? [] + finalPath = try container.decodeIfPresent(String.self, forKey: .finalPath) ?? "" + finalHash = try container.decodeIfPresent(String.self, forKey: .finalHash) ?? "" + publication = try container.decodeIfPresent(CreatorPublication.self, forKey: .publication) ?? CreatorPublication() + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(title, forKey: .title) + try container.encode(brief, forKey: .brief) + try container.encode(platform, forKey: .platform) + try container.encode(accountID, forKey: .accountID) + try container.encode(style, forKey: .style) + try container.encode(size, forKey: .size) + try container.encode(targetSeconds, forKey: .targetSeconds) + try container.encode(caption, forKey: .caption) + try container.encode(revision, forKey: .revision) + try container.encode(publishMode, forKey: .publishMode) + try container.encode(status, forKey: .status) + try container.encode(error, forKey: .error) + try container.encode(createdAt, forKey: .createdAt) + try container.encode(updatedAt, forKey: .updatedAt) + try container.encode(runSessionID, forKey: .runSessionID) + try container.encode(runStage, forKey: .runStage) + try container.encode(runPublishMode, forKey: .runPublishMode) + try container.encode(runStatus, forKey: .runStatus) + try container.encode(lastRunError, forKey: .lastRunError) + try container.encode(runStartedAt, forKey: .runStartedAt) + try container.encode(runFinishedAt, forKey: .runFinishedAt) + try container.encode(references, forKey: .references) + try container.encode(shots, forKey: .shots) + try container.encode(research, forKey: .research) + try container.encode(ideas, forKey: .ideas) + try container.encode(finalPath, forKey: .finalPath) + try container.encode(finalHash, forKey: .finalHash) + try container.encode(publication, forKey: .publication) + } +} + +public struct CreatorReference: Codable, Identifiable, Sendable, Hashable { + public var id: String + public var kind: String + public var name: String + public var prompt: String + public var path: String + public var status: String + public var error: String + + public init( + id: String, + kind: String = "character", + name: String = "", + prompt: String = "", + path: String = "", + status: String = "draft", + error: String = "" + ) { + self.id = id + self.kind = kind + self.name = name + self.prompt = prompt + self.path = path + self.status = status + self.error = error + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(String.self, forKey: .id) ?? "" + kind = try container.decodeIfPresent(String.self, forKey: .kind) ?? "character" + name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" + prompt = try container.decodeIfPresent(String.self, forKey: .prompt) ?? "" + path = try container.decodeIfPresent(String.self, forKey: .path) ?? "" + status = try container.decodeIfPresent(String.self, forKey: .status) ?? "" + error = try container.decodeIfPresent(String.self, forKey: .error) ?? "" + } +} + +public struct CreatorShot: Codable, Identifiable, Sendable, Hashable { + public var id: String + public var prompt: String + public var referenceIDs: [String] + public var durationSeconds: Int + public var continuity: String + public var notes: String + public var keyframePath: String + public var videoJobID: String + public var videoPath: String + public var lastFramePath: String + public var status: String + public var error: String + public var progress: Int + + enum CodingKeys: String, CodingKey { + case id + case prompt + case referenceIDs = "reference_ids" + case durationSeconds = "duration_seconds" + case continuity + case notes + case keyframePath = "keyframe_path" + case videoJobID = "video_job_id" + case videoPath = "video_path" + case lastFramePath = "last_frame_path" + case status + case error + case progress + } + + public init( + id: String, + prompt: String = "", + referenceIDs: [String] = [], + durationSeconds: Int = 8, + continuity: String = "cut", + notes: String = "", + keyframePath: String = "", + videoJobID: String = "", + videoPath: String = "", + lastFramePath: String = "", + status: String = "draft", + error: String = "", + progress: Int = 0 + ) { + self.id = id + self.prompt = prompt + self.referenceIDs = referenceIDs + self.durationSeconds = durationSeconds + self.continuity = continuity + self.notes = notes + self.keyframePath = keyframePath + self.videoJobID = videoJobID + self.videoPath = videoPath + self.lastFramePath = lastFramePath + self.status = status + self.error = error + self.progress = progress + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(String.self, forKey: .id) ?? "" + prompt = try container.decodeIfPresent(String.self, forKey: .prompt) ?? "" + referenceIDs = try container.decodeIfPresent([String].self, forKey: .referenceIDs) ?? [] + durationSeconds = try container.decodeIfPresent(Int.self, forKey: .durationSeconds) ?? 0 + continuity = try container.decodeIfPresent(String.self, forKey: .continuity) ?? "cut" + notes = try container.decodeIfPresent(String.self, forKey: .notes) ?? "" + keyframePath = try container.decodeIfPresent(String.self, forKey: .keyframePath) ?? "" + videoJobID = try container.decodeIfPresent(String.self, forKey: .videoJobID) ?? "" + videoPath = try container.decodeIfPresent(String.self, forKey: .videoPath) ?? "" + lastFramePath = try container.decodeIfPresent(String.self, forKey: .lastFramePath) ?? "" + status = try container.decodeIfPresent(String.self, forKey: .status) ?? "" + error = try container.decodeIfPresent(String.self, forKey: .error) ?? "" + progress = try container.decodeIfPresent(Int.self, forKey: .progress) ?? 0 + } +} + +public struct CreatorTrend: Codable, Identifiable, Sendable { + public var url: String + public var title: String + public var platform: String + public var observedAt: String + public var notes: String + public var metrics: [String: Double]? + + public var id: String { "\(url)|\(observedAt)|\(title)" } + + enum CodingKeys: String, CodingKey { + case url + case title + case platform + case observedAt = "observed_at" + case notes + case metrics + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + url = try container.decodeIfPresent(String.self, forKey: .url) ?? "" + title = try container.decodeIfPresent(String.self, forKey: .title) ?? "" + platform = try container.decodeIfPresent(String.self, forKey: .platform) ?? "" + observedAt = try container.decodeIfPresent(String.self, forKey: .observedAt) ?? "" + notes = try container.decodeIfPresent(String.self, forKey: .notes) ?? "" + metrics = try container.decodeIfPresent([String: Double].self, forKey: .metrics) + } +} + +public struct CreatorIdea: Codable, Identifiable, Sendable, Hashable { + public var id: String + public var title: String + public var hook: String + public var rationale: String + public var selected: Bool + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(String.self, forKey: .id) ?? "" + title = try container.decodeIfPresent(String.self, forKey: .title) ?? "" + hook = try container.decodeIfPresent(String.self, forKey: .hook) ?? "" + rationale = try container.decodeIfPresent(String.self, forKey: .rationale) ?? "" + selected = try container.decodeIfPresent(Bool.self, forKey: .selected) ?? false + } +} + +public struct CreatorPublication: Codable, Sendable, Hashable { + public var status: String + public var postURL: String + public var error: String + public var accountID: String + public var artifactHash: String + public var proof: String + public var preparedAt: String + public var publishedAt: String + + enum CodingKeys: String, CodingKey { + case status + case postURL = "post_url" + case error + case accountID = "account_id" + case artifactHash = "artifact_hash" + case proof + case preparedAt = "prepared_at" + case publishedAt = "published_at" + } + + public init( + status: String = "not_started", + postURL: String = "", + error: String = "", + accountID: String = "", + artifactHash: String = "", + proof: String = "", + preparedAt: String = "", + publishedAt: String = "" + ) { + self.status = status + self.postURL = postURL + self.error = error + self.accountID = accountID + self.artifactHash = artifactHash + self.proof = proof + self.preparedAt = preparedAt + self.publishedAt = publishedAt + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + status = try container.decodeIfPresent(String.self, forKey: .status) ?? "not_started" + postURL = try container.decodeIfPresent(String.self, forKey: .postURL) ?? "" + error = try container.decodeIfPresent(String.self, forKey: .error) ?? "" + accountID = try container.decodeIfPresent(String.self, forKey: .accountID) ?? "" + artifactHash = try container.decodeIfPresent(String.self, forKey: .artifactHash) ?? "" + proof = try container.decodeIfPresent(String.self, forKey: .proof) ?? "" + preparedAt = try container.decodeIfPresent(String.self, forKey: .preparedAt) ?? "" + publishedAt = try container.decodeIfPresent(String.self, forKey: .publishedAt) ?? "" + } +} + +public struct CreatorProjectDraft: Encodable, Sendable { + public var title: String + public var brief: String + public var platform: String + public var size: String + public var targetSeconds: Int + public var style: String + public var publishMode: String + + enum CodingKeys: String, CodingKey { + case title + case brief + case platform + case size + case targetSeconds = "target_seconds" + case style + case publishMode = "publish_mode" + } + + public init( + title: String, + brief: String, + platform: String, + size: String, + targetSeconds: Int, + style: String, + publishMode: String + ) { + self.title = title + self.brief = brief + self.platform = platform + self.size = size + self.targetSeconds = targetSeconds + self.style = style + self.publishMode = publishMode + } +} + +public struct CreatorActionRequest: Encodable, Sendable { + public var action: String + public var targetID: String? + public var confirmed: Bool? + + enum CodingKeys: String, CodingKey { + case action + case targetID = "target_id" + case confirmed + } + + public init(action: String, targetID: String? = nil, confirmed: Bool? = nil) { + self.action = action + self.targetID = targetID + self.confirmed = confirmed + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(action, forKey: .action) + try container.encodeIfPresent(targetID, forKey: .targetID) + try container.encodeIfPresent(confirmed, forKey: .confirmed) + } +} + +public struct CreatorRunRequest: Encodable, Sendable { + public var stage: String + public var publishMode: String + + enum CodingKeys: String, CodingKey { + case stage + case publishMode = "publish_mode" + } + + public init(stage: String, publishMode: String) { + self.stage = stage + self.publishMode = publishMode + } +} + +public struct CreatorRunResponse: Decodable, Sendable { + public var sessionID: String + + enum CodingKeys: String, CodingKey { + case sessionID = "session_id" + } +} + +public struct CreatorReconcileRequest: Encodable, Sendable { + public var proof: String + public var notPublished: Bool + + enum CodingKeys: String, CodingKey { + case proof + case notPublished = "not_published" + } + + public init(proof: String, notPublished: Bool) { + self.proof = proof + self.notPublished = notPublished + } +} + +public struct CreatorSettings: Decodable, Sendable { + public var image: CreatorProviderSettings + public var video: CreatorProviderSettings + public var ffmpeg: Bool + public var ffprobe: Bool + + enum CodingKeys: String, CodingKey { + case image, video, ffmpeg, ffprobe + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + image = try container.decodeIfPresent(CreatorProviderSettings.self, forKey: .image) ?? CreatorProviderSettings() + video = try container.decodeIfPresent(CreatorProviderSettings.self, forKey: .video) ?? CreatorProviderSettings() + ffmpeg = try container.decodeIfPresent(Bool.self, forKey: .ffmpeg) ?? false + ffprobe = try container.decodeIfPresent(Bool.self, forKey: .ffprobe) ?? false + } +} + +public struct CreatorProviderSettings: Decodable, Sendable { + public var enabled: Bool + public var provider: String + public var model: String + public var baseURL: String + public var size: String + public var seconds: Int? + public var hasKey: Bool + + enum CodingKeys: String, CodingKey { + case enabled + case provider + case model + case baseURL = "base_url" + case size + case seconds + case hasKey = "has_key" + } + + public init( + enabled: Bool = false, + provider: String = "", + model: String = "", + baseURL: String = "", + size: String = "", + seconds: Int? = nil, + hasKey: Bool = false + ) { + self.enabled = enabled + self.provider = provider + self.model = model + self.baseURL = baseURL + self.size = size + self.seconds = seconds + self.hasKey = hasKey + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? false + provider = try container.decodeIfPresent(String.self, forKey: .provider) ?? "" + model = try container.decodeIfPresent(String.self, forKey: .model) ?? "" + baseURL = try container.decodeIfPresent(String.self, forKey: .baseURL) ?? "" + size = try container.decodeIfPresent(String.self, forKey: .size) ?? "" + seconds = try container.decodeIfPresent(Int.self, forKey: .seconds) + hasKey = try container.decodeIfPresent(Bool.self, forKey: .hasKey) ?? false + } + + public var patch: CreatorProviderPatch { + CreatorProviderPatch( + enabled: enabled, + provider: provider, + model: model, + baseURL: baseURL, + size: size, + seconds: seconds + ) + } +} + +public struct CreatorSettingsPatch: Encodable, Sendable { + public var image: CreatorProviderPatch? + public var video: CreatorProviderPatch? + + enum CodingKeys: String, CodingKey { + case image, video + } + + public init(image: CreatorProviderPatch? = nil, video: CreatorProviderPatch? = nil) { + self.image = image + self.video = video + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(image, forKey: .image) + try container.encodeIfPresent(video, forKey: .video) + } +} + +public struct CreatorProviderPatch: Encodable, Sendable { + public var enabled: Bool? + public var provider: String? + public var model: String? + public var baseURL: String? + public var size: String? + public var seconds: Int? + public var apiKey: String? + + enum CodingKeys: String, CodingKey { + case enabled + case provider + case model + case baseURL = "base_url" + case size + case seconds + case apiKey = "api_key" + } + + public init( + enabled: Bool? = nil, + provider: String? = nil, + model: String? = nil, + baseURL: String? = nil, + size: String? = nil, + seconds: Int? = nil, + apiKey: String? = nil + ) { + self.enabled = enabled + self.provider = provider + self.model = model + self.baseURL = baseURL + self.size = size + self.seconds = seconds + self.apiKey = apiKey + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(enabled, forKey: .enabled) + try container.encodeIfPresent(provider, forKey: .provider) + try container.encodeIfPresent(model, forKey: .model) + try container.encodeIfPresent(baseURL, forKey: .baseURL) + try container.encodeIfPresent(size, forKey: .size) + try container.encodeIfPresent(seconds, forKey: .seconds) + try container.encodeIfPresent(apiKey, forKey: .apiKey) + } +} + +public struct CreatorCronJobRequest: Encodable, Sendable { + public var name: String + public var schedule: String + public var role: String + public var contentProjectID: String + public var contentStage: String + public var publishMode: String + + enum CodingKeys: String, CodingKey { + case name + case schedule + case role + case contentProjectID = "content_project_id" + case contentStage = "content_stage" + case publishMode = "publish_mode" + } + + public init( + name: String, + schedule: String, + role: String, + contentProjectID: String, + contentStage: String, + publishMode: String + ) { + self.name = name + self.schedule = schedule + self.role = role + self.contentProjectID = contentProjectID + self.contentStage = contentStage + self.publishMode = publishMode + } +} + +public struct SocialStatus: Decodable, Sendable { + public var enabled: Bool + public var encryptionReady: Bool + public var imapConfigured: Bool + public var imapHost: String + public var imapPort: Int + public var imapUsername: String + public var browser: SocialBrowserStatus + public var autopilotEnabled: Bool + public var accounts: [SocialAccount] + + enum CodingKeys: String, CodingKey { + case enabled + case encryptionReady = "encryption_ready" + case imapConfigured = "imap_configured" + case imapHost = "imap_host" + case imapPort = "imap_port" + case imapUsername = "imap_username" + case browser + case autopilotEnabled = "autopilot_enabled" + case accounts + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? false + encryptionReady = try container.decodeIfPresent(Bool.self, forKey: .encryptionReady) ?? false + imapConfigured = try container.decodeIfPresent(Bool.self, forKey: .imapConfigured) ?? false + imapHost = try container.decodeIfPresent(String.self, forKey: .imapHost) ?? "" + imapPort = try container.decodeIfPresent(Int.self, forKey: .imapPort) ?? 0 + imapUsername = try container.decodeIfPresent(String.self, forKey: .imapUsername) ?? "" + browser = try container.decodeIfPresent(SocialBrowserStatus.self, forKey: .browser) ?? SocialBrowserStatus() + autopilotEnabled = try container.decodeIfPresent(Bool.self, forKey: .autopilotEnabled) ?? false + accounts = try container.decodeIfPresent([SocialAccount].self, forKey: .accounts) ?? [] + } +} + +public struct SocialBrowserStatus: Decodable, Sendable { + public var enabled: Bool + public var state: String + public var error: String + + enum CodingKeys: String, CodingKey { + case enabled, state, error + } + + public init(enabled: Bool = false, state: String = "disabled", error: String = "") { + self.enabled = enabled + self.state = state + self.error = error + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? false + state = try container.decodeIfPresent(String.self, forKey: .state) ?? "disabled" + error = try container.decodeIfPresent(String.self, forKey: .error) ?? "" + } +} + +public struct SocialAccount: Decodable, Identifiable, Sendable, Hashable { + public var id: String + public var platform: String + public var displayName: String + public var username: String + public var profileURL: String + public var status: String + public var ragNamespace: String + public var skillName: String + public var lastCheckedAt: String? + public var createdAt: String + public var updatedAt: String + public var hasPassword: Bool + public var hasRecovery: Bool + + public var label: String { + displayName.isEmpty ? username : displayName + } + + enum CodingKeys: String, CodingKey { + case id + case platform + case displayName = "display_name" + case username + case profileURL = "profile_url" + case status + case ragNamespace = "rag_namespace" + case skillName = "skill_name" + case lastCheckedAt = "last_checked_at" + case createdAt = "created_at" + case updatedAt = "updated_at" + case hasPassword = "has_password" + case hasRecovery = "has_recovery" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(String.self, forKey: .id) ?? "" + platform = try container.decodeIfPresent(String.self, forKey: .platform) ?? "" + displayName = try container.decodeIfPresent(String.self, forKey: .displayName) ?? "" + username = try container.decodeIfPresent(String.self, forKey: .username) ?? "" + profileURL = try container.decodeIfPresent(String.self, forKey: .profileURL) ?? "" + status = try container.decodeIfPresent(String.self, forKey: .status) ?? "" + ragNamespace = try container.decodeIfPresent(String.self, forKey: .ragNamespace) ?? "" + skillName = try container.decodeIfPresent(String.self, forKey: .skillName) ?? "" + lastCheckedAt = try container.decodeIfPresent(String.self, forKey: .lastCheckedAt) + createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? "" + updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt) ?? "" + hasPassword = try container.decodeIfPresent(Bool.self, forKey: .hasPassword) ?? false + hasRecovery = try container.decodeIfPresent(Bool.self, forKey: .hasRecovery) ?? false + } +} + +public struct SocialIMAPRequest: Encodable, Sendable { + public var host: String + public var port: Int + public var username: String + public var password: String + + public init(host: String, port: Int, username: String, password: String) { + self.host = host + self.port = port + self.username = username + self.password = password + } +} + +public struct SocialAccountRequest: Encodable, Sendable { + public var platform: String + public var username: String + public var password: String + public var recoveryCodes: String + public var profileURL: String + public var status: String + + enum CodingKeys: String, CodingKey { + case platform + case username + case password + case recoveryCodes = "recovery_codes" + case profileURL = "profile_url" + case status + } + + public init( + platform: String, + username: String, + password: String, + recoveryCodes: String, + profileURL: String, + status: String + ) { + self.platform = platform + self.username = username + self.password = password + self.recoveryCodes = recoveryCodes + self.profileURL = profileURL + self.status = status + } +} + +public struct SocialAutopilotRequest: Encodable, Sendable { + public var enabled: Bool + + public init(enabled: Bool) { + self.enabled = enabled + } +} + +public struct SocialEncryptionSetupResponse: Decodable, Sendable { + public var ok: Bool + public var recoveryKey: String + public var message: String? + + enum CodingKeys: String, CodingKey { + case ok + case recoveryKey = "recovery_key" + case message + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + ok = try container.decodeIfPresent(Bool.self, forKey: .ok) ?? false + recoveryKey = try container.decodeIfPresent(String.self, forKey: .recoveryKey) ?? "" + message = try container.decodeIfPresent(String.self, forKey: .message) + } +} + +public struct SocialIMAPTestResponse: Decodable, Sendable { + public var ok: Bool + public var inboxCount: Int? + public var error: String? + + enum CodingKeys: String, CodingKey { + case ok + case inboxCount = "inbox_count" + case error + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + ok = try container.decodeIfPresent(Bool.self, forKey: .ok) ?? false + inboxCount = try container.decodeIfPresent(Int.self, forKey: .inboxCount) + error = try container.decodeIfPresent(String.self, forKey: .error) + } +} + +extension CronJob { + public var contentProjectID: String? { + meta?["content_project_id"]?.stringValue + } + + public var contentStage: String? { + meta?["content_stage"]?.stringValue + } + + public var publishMode: String? { + meta?["publish_mode"]?.stringValue + } +} + +extension APIClient { + public func listCreatorProjects() async throws -> CreatorProjectsResponse { + try await get("/api/content-creator/projects") + } + + public func creatorProject(_ id: String) async throws -> CreatorProject { + try await get("/api/content-creator/projects/\(encodedPath(id))") + } + + public func createCreatorProject(_ draft: CreatorProjectDraft) async throws -> CreatorProject { + try await post("/api/content-creator/projects", body: draft) + } + + public func updateCreatorProject(_ project: CreatorProject) async throws -> CreatorProject { + try await put("/api/content-creator/projects/\(encodedPath(project.id))", body: project) + } + + public func creatorAction(_ id: String, _ request: CreatorActionRequest) async throws -> CreatorProject { + try await post("/api/content-creator/projects/\(encodedPath(id))/action", body: request) + } + + public func runCreatorProject(_ id: String, stage: String, publishMode: String) async throws -> CreatorRunResponse { + try await post( + "/api/content-creator/projects/\(encodedPath(id))/run", + body: CreatorRunRequest(stage: stage, publishMode: publishMode) + ) + } + + public func reconcileCreatorProject(_ id: String, proof: String) async throws -> CreatorProject { + try await post( + "/api/content-creator/projects/\(encodedPath(id))/reconcile", + body: CreatorReconcileRequest(proof: proof, notPublished: true) + ) + } + + public func creatorSettings() async throws -> CreatorSettings { + try await get("/api/content-creator/settings") + } + + public func saveCreatorSettings(_ patch: CreatorSettingsPatch) async throws -> CreatorSettings { + try await post("/api/content-creator/settings", body: patch) + } + + public func creatorArtifact(projectID: String, path: String) async throws -> Data { + try await getData( + "/api/content-creator/projects/\(encodedPath(projectID))/artifact", + query: [URLQueryItem(name: "path", value: path)] + ) + } + + public func creatorArtifactURL(projectID: String, path: String) -> URL { + var components = URLComponents( + url: url("/api/content-creator/projects/\(encodedPath(projectID))/artifact"), + resolvingAgainstBaseURL: true + )! + var items = [URLQueryItem(name: "path", value: path)] + if let bearerToken, !bearerToken.isEmpty { + items.append(URLQueryItem(name: "token", value: bearerToken)) + } + components.queryItems = items + return components.url! + } + + public func createCreatorCronJob(_ request: CreatorCronJobRequest) async throws -> CronJob { + try await post("/api/cron/jobs", body: request) + } + + public func socialStatus() async throws -> SocialStatus { + try await get("/api/social/status") + } + + public func listSocialAccounts() async throws -> [SocialAccount] { + try await get("/api/social/accounts") + } + + public func setupSocialEncryption() async throws -> SocialEncryptionSetupResponse { + try await post("/api/social/encryption/setup", body: EmptyBody()) + } + + public func testSocialIMAP(_ request: SocialIMAPRequest) async throws -> SocialIMAPTestResponse { + try await post("/api/social/imap/test", body: request) + } + + public func saveSocialIMAP(_ request: SocialIMAPRequest) async throws -> OKResponse { + try await post("/api/social/imap/save", body: request) + } + + public func startSocialBrowser() async throws -> OKResponse { + try await post("/api/social/browser/start", body: EmptyBody()) + } + + public func stopSocialBrowser() async throws -> OKResponse { + try await post("/api/social/browser/stop", body: EmptyBody()) + } + + public func setSocialAutopilot(enabled: Bool) async throws -> OKResponse { + try await post("/api/social/autopilot", body: SocialAutopilotRequest(enabled: enabled)) + } + + public func addSocialAccount(_ request: SocialAccountRequest) async throws -> SocialAccount { + try await post("/api/social/accounts", body: request) + } + + public func deleteSocialAccount(_ id: String) async throws -> OKResponse { + try await delete("/api/social/accounts/\(encodedPath(id))") + } +} diff --git a/macos/AntaresKit/Sources/AntaresKit/DashboardModels.swift b/macos/AntaresKit/Sources/AntaresKit/DashboardModels.swift new file mode 100644 index 00000000..c803faf4 --- /dev/null +++ b/macos/AntaresKit/Sources/AntaresKit/DashboardModels.swift @@ -0,0 +1,301 @@ +import Foundation + +public struct SessionListResponse: Decodable, Sendable { + public var sessions: [SessionSummary] + public var total: Int +} + +public struct SessionSearchResponse: Decodable, Sendable { + public var hits: [SessionSearchHit] +} + +public struct SessionSearchHit: Decodable, Identifiable, Sendable { + public var sessionID: String + public var sessionTitle: String + public var messageID: String + public var role: String + public var snippet: String + public var createdAt: String + public var score: Double? + + public var id: String { "\(sessionID):\(messageID)" } + + enum CodingKeys: String, CodingKey { + case sessionID = "session_id" + case sessionTitle = "session_title" + case messageID = "message_id" + case role + case snippet + case createdAt = "created_at" + case score + } +} + +public struct DeletedFlagResponse: Decodable, Sendable { + public var deleted: Bool +} + +public struct ActiveModel: Decodable, Sendable { + public var model: String + public var provider: String + + public init(model: String, provider: String) { + self.model = model + self.provider = provider + } +} + +public struct ModelOptionsResponse: Decodable, Sendable { + public var active: ActiveModel + public var providers: [ModelProvider] + + public init(active: ActiveModel, providers: [ModelProvider]) { + self.active = active + self.providers = providers + } +} + +public struct ModelProvider: Decodable, Identifiable, Sendable { + public var id: String + public var label: String + public var kind: String + public var enabled: Bool + public var hasKey: Bool + public var local: Bool + public var baseURL: String + public var active: Bool + public var hint: String? + public var keyHint: String? + public var keyURL: String? + public var keyLabel: String? + public var note: String? + public var needsRegion: Bool? + public var needsAPIVersion: Bool? + public var needsBaseURL: Bool? + public var timeoutSeconds: Int? + public var headers: [String: String]? + public var custom: Bool? + + enum CodingKeys: String, CodingKey { + case id + case label + case kind + case enabled + case hasKey = "has_key" + case local + case baseURL = "base_url" + case active + case hint + case keyHint = "key_hint" + case keyURL = "key_url" + case keyLabel = "key_label" + case note + case needsRegion = "needs_region" + case needsAPIVersion = "needs_api_version" + case needsBaseURL = "needs_base_url" + case timeoutSeconds = "timeout_seconds" + case headers + case custom + } +} + +public struct ModelListAllResponse: Decodable, Sendable { + public var active: ActiveModel + public var models: [ProviderModel] + public var errors: [ProviderModelError] +} + +public struct ProviderModel: Decodable, Sendable { + public var id: String + public var name: String + public var provider: String + public var providerLabel: String + public var contextWindow: Int + public var maxOutput: Int + public var inputCost: Double + public var outputCost: Double + public var vision: Bool + public var tools: Bool + public var reasoning: Bool + + public var stableID: String { "\(provider)/\(id)" } + + enum CodingKeys: String, CodingKey { + case id + case name + case provider + case providerLabel = "provider_label" + case contextWindow = "context_window" + case maxOutput = "max_output" + case inputCost = "input_cost" + case outputCost = "output_cost" + case vision + case tools + case reasoning + } +} + +public struct ProviderModelError: Decodable, Sendable { + public var provider: String + public var label: String + public var error: String +} + +public struct ModelSetRequest: Encodable, Sendable { + public var model: String + public var provider: String + + public init(model: String, provider: String) { + self.model = model + self.provider = provider + } +} + +public struct ModelSetResponse: Decodable, Sendable { + public var model: String + public var provider: String +} + +public struct ToolsResponse: Decodable, Sendable { + public var toolset: String + public var toolsets: [String] + public var tools: [ToolInfo] +} + +public struct ToolInfo: Decodable, Identifiable, Sendable { + public var name: String + public var description: String + public var enabled: Bool + public var requiresApproval: Bool + public var toolsets: [String] + + public var id: String { name } + + enum CodingKeys: String, CodingKey { + case name + case description + case enabled + case requiresApproval = "requires_approval" + case toolsets + } +} + +public struct ToggleToolRequest: Encodable, Sendable { + public var name: String + public var enabled: Bool + + public init(name: String, enabled: Bool) { + self.name = name + self.enabled = enabled + } +} + +public struct OKResponse: Decodable, Sendable { + public var ok: Bool +} + +public struct MemoryListResponse: Decodable, Sendable { + public var memories: [MemoryItem] +} + +public struct MemoryItem: Decodable, Identifiable, Sendable { + public var id: String + public var scope: String + public var scopeKey: String + public var key: String + public var content: String + public var tags: String + public var source: String + public var pinned: Bool + public var createdAt: String + public var updatedAt: String + + enum CodingKeys: String, CodingKey { + case id + case scope + case scopeKey = "scope_key" + case key + case content + case tags + case source + case pinned + case createdAt = "created_at" + case updatedAt = "updated_at" + } +} + +public struct PutMemoryRequest: Encodable, Sendable { + public var id: String + public var key: String + public var content: String + public var scope: String + public var pinned: Bool? + + public init(id: String = "", key: String, content: String, scope: String, pinned: Bool? = nil) { + self.id = id + self.key = key + self.content = content + self.scope = scope + self.pinned = pinned + } +} + +extension APIClient { + public func listSessions(limit: Int = 100, query: String = "") async throws -> SessionListResponse { + var items = [URLQueryItem(name: "limit", value: String(limit))] + if !query.isEmpty { + items.append(URLQueryItem(name: "q", value: query)) + } + return try await get("/api/sessions", query: items) + } + + public func searchSessions(query: String, limit: Int = 30) async throws -> SessionSearchResponse { + try await get("/api/sessions/search", query: [ + URLQueryItem(name: "q", value: query), + URLQueryItem(name: "limit", value: String(limit)), + ]) + } + + public func deleteSession(_ id: String) async throws -> DeletedFlagResponse { + try await delete("/api/sessions/\(id)") + } + + public func modelOptions() async throws -> ModelOptionsResponse { + try await get("/api/model/options") + } + + public func modelListAll() async throws -> ModelListAllResponse { + try await get("/api/model/list-all") + } + + public func setModel(_ model: String, provider: String) async throws -> ModelSetResponse { + try await post("/api/model/set", body: ModelSetRequest(model: model, provider: provider)) + } + + public func listTools() async throws -> ToolsResponse { + try await get("/api/tools") + } + + public func toggleTool(name: String, enabled: Bool) async throws -> OKResponse { + try await post("/api/tools/toggle", body: ToggleToolRequest(name: name, enabled: enabled)) + } + + public func listMemory() async throws -> MemoryListResponse { + try await get("/api/memory") + } + + public func searchMemory(query: String, limit: Int = 30) async throws -> MemoryListResponse { + try await get("/api/memory/search", query: [ + URLQueryItem(name: "q", value: query), + URLQueryItem(name: "limit", value: String(limit)), + ]) + } + + public func putMemory(_ request: PutMemoryRequest) async throws -> MemoryItem { + try await post("/api/memory", body: request) + } + + public func deleteMemory(_ id: String) async throws -> DeletedFlagResponse { + try await delete("/api/memory/\(id)") + } +} diff --git a/macos/AntaresKit/Sources/AntaresKit/IntegrationModels.swift b/macos/AntaresKit/Sources/AntaresKit/IntegrationModels.swift new file mode 100644 index 00000000..469625db --- /dev/null +++ b/macos/AntaresKit/Sources/AntaresKit/IntegrationModels.swift @@ -0,0 +1,762 @@ +import Foundation + +public struct InterceptStatus: Decodable, Sendable { + public var running: Bool + public var addr: String + public var exchanges: Int + public var rules: Int + + enum CodingKeys: String, CodingKey { + case running, addr, exchanges, rules + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + running = try container.decodeIfPresent(Bool.self, forKey: .running) ?? false + addr = try container.decodeIfPresent(String.self, forKey: .addr) ?? "" + exchanges = try container.decodeIfPresent(Int.self, forKey: .exchanges) ?? 0 + rules = try container.decodeIfPresent(Int.self, forKey: .rules) ?? 0 + } +} + +public struct InterceptStartRequest: Encodable, Sendable { + public var port: Int + + public init(port: Int) { + self.port = port + } +} + +public struct InterceptExchangesResponse: Decodable, Sendable { + public var exchanges: [InterceptExchange] + + enum CodingKeys: String, CodingKey { + case exchanges + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + exchanges = try container.decodeIfPresent([InterceptExchange].self, forKey: .exchanges) ?? [] + } +} + +public struct InterceptExchange: Decodable, Identifiable, Sendable, Hashable { + public var id: Int + public var time: String? + public var method: String + public var url: String + public var host: String? + public var secure: Bool + public var reqHeaders: [String: [String]] + public var reqBody: String + public var status: Int + public var respHeaders: [String: [String]] + public var respBody: String + public var durationMS: Int + public var mocked: Bool + public var blocked: Bool + + enum CodingKeys: String, CodingKey { + case id + case time + case method + case url + case host + case secure + case reqHeaders = "req_headers" + case reqBody = "req_body" + case status + case respHeaders = "resp_headers" + case respBody = "resp_body" + case durationMS = "duration_ms" + case mocked + case blocked + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(Int.self, forKey: .id) + time = try container.decodeIfPresent(String.self, forKey: .time) + method = try container.decodeIfPresent(String.self, forKey: .method) ?? "" + url = try container.decodeIfPresent(String.self, forKey: .url) ?? "" + host = try container.decodeIfPresent(String.self, forKey: .host) + secure = try container.decodeIfPresent(Bool.self, forKey: .secure) ?? false + reqHeaders = try container.decodeIfPresent([String: [String]].self, forKey: .reqHeaders) ?? [:] + reqBody = try container.decodeIfPresent(String.self, forKey: .reqBody) ?? "" + status = try container.decodeIfPresent(Int.self, forKey: .status) ?? 0 + respHeaders = try container.decodeIfPresent([String: [String]].self, forKey: .respHeaders) ?? [:] + respBody = try container.decodeIfPresent(String.self, forKey: .respBody) ?? "" + durationMS = try container.decodeIfPresent(Int.self, forKey: .durationMS) ?? 0 + mocked = try container.decodeIfPresent(Bool.self, forKey: .mocked) ?? false + blocked = try container.decodeIfPresent(Bool.self, forKey: .blocked) ?? false + } +} + +public struct InterceptRulesResponse: Decodable, Sendable { + public var rules: [InterceptRule] + + enum CodingKeys: String, CodingKey { + case rules + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + rules = try container.decodeIfPresent([InterceptRule].self, forKey: .rules) ?? [] + } +} + +public struct InterceptRule: Decodable, Identifiable, Sendable { + public var id: Int + public var match: String + public var block: Bool + public var mockStatus: Int + public var mockBody: String + public var breakpoint: Bool? + + enum CodingKeys: String, CodingKey { + case id + case match + case block + case mockStatus = "mock_status" + case mockBody = "mock_body" + case breakpoint + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(Int.self, forKey: .id) + match = try container.decodeIfPresent(String.self, forKey: .match) ?? "" + block = try container.decodeIfPresent(Bool.self, forKey: .block) ?? false + mockStatus = try container.decodeIfPresent(Int.self, forKey: .mockStatus) ?? 0 + mockBody = try container.decodeIfPresent(String.self, forKey: .mockBody) ?? "" + breakpoint = try container.decodeIfPresent(Bool.self, forKey: .breakpoint) + } +} + +public struct InterceptRuleRequest: Encodable, Sendable { + public var match: String + public var block: Bool + public var mockStatus: Int + public var mockBody: String + + public init(match: String, block: Bool, mockStatus: Int, mockBody: String) { + self.match = match + self.block = block + self.mockStatus = mockStatus + self.mockBody = mockBody + } + + enum CodingKeys: String, CodingKey { + case match + case block + case mockStatus = "mock_status" + case mockBody = "mock_body" + } +} + +public struct InterceptorsResponse: Decodable, Sendable { + public var interceptors: [InterceptorInfo] + public var sessions: [InterceptorSession] + + enum CodingKeys: String, CodingKey { + case interceptors, sessions + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + interceptors = try container.decodeIfPresent([InterceptorInfo].self, forKey: .interceptors) ?? [] + sessions = try container.decodeIfPresent([InterceptorSession].self, forKey: .sessions) ?? [] + } +} + +public struct InterceptorInfo: Decodable, Identifiable, Sendable { + public var id: String + public var label: String + public var category: String + public var available: Bool + public var reason: String? +} + +public struct InterceptorSession: Decodable, Identifiable, Sendable { + public var id: String + public var interceptor: String + public var info: [String: FlexibleJSON]? + + public var envText: String? { + info?["env"]?.stringValue + } +} + +public struct InterceptActivateRequest: Encodable, Sendable { + public var interceptor: String + + public init(interceptor: String) { + self.interceptor = interceptor + } +} + +public struct InterceptPausedList: Decodable, Sendable { + public var paused: [InterceptPaused] + + enum CodingKeys: String, CodingKey { + case paused + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + paused = try container.decodeIfPresent([InterceptPaused].self, forKey: .paused) ?? [] + } +} + +public struct InterceptPaused: Decodable, Identifiable, Sendable, Hashable { + public var id: Int + public var method: String + public var url: String + public var headers: [String: String] + public var body: String + + enum CodingKeys: String, CodingKey { + case id, method, url, headers, body + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(Int.self, forKey: .id) + method = try container.decodeIfPresent(String.self, forKey: .method) ?? "" + url = try container.decodeIfPresent(String.self, forKey: .url) ?? "" + headers = try container.decodeIfPresent([String: String].self, forKey: .headers) ?? [:] + body = try container.decodeIfPresent(String.self, forKey: .body) ?? "" + } +} + +public struct MCPStatusResponse: Decodable, Sendable { + public var enabled: Bool + public var servers: [MCPServerStatus] + + enum CodingKeys: String, CodingKey { + case enabled, servers + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? false + servers = try container.decodeIfPresent([MCPServerStatus].self, forKey: .servers) ?? [] + } +} + +public struct MCPServerStatus: Decodable, Identifiable, Sendable { + public var name: String + public var started: Bool + public var connected: Bool + public var error: String? + public var tools: [MCPTool] + + public var id: String { name } + + enum CodingKeys: String, CodingKey { + case name, started, connected, error, tools + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" + started = try container.decodeIfPresent(Bool.self, forKey: .started) ?? false + connected = try container.decodeIfPresent(Bool.self, forKey: .connected) ?? false + error = try container.decodeIfPresent(String.self, forKey: .error) + tools = try container.decodeIfPresent([MCPTool].self, forKey: .tools) ?? [] + } +} + +public struct MCPTool: Decodable, Identifiable, Sendable { + public var name: String + public var description: String + + public var id: String { name } + + enum CodingKeys: String, CodingKey { + case name, description + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" + description = try container.decodeIfPresent(String.self, forKey: .description) ?? "" + } +} + +public struct AddMCPServerRequest: Encodable, Sendable { + public var name: String + public var transport: String + public var command: String? + public var args: [String]? + public var env: [String: String]? + public var url: String? + + public init( + name: String, + transport: String, + command: String? = nil, + args: [String]? = nil, + env: [String: String]? = nil, + url: String? = nil + ) { + self.name = name + self.transport = transport + self.command = command + self.args = args + self.env = env + self.url = url + } + + enum CodingKeys: String, CodingKey { + case name, transport, command, args, env, url + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encode(transport, forKey: .transport) + try container.encodeIfPresent(command, forKey: .command) + try container.encodeIfPresent(args, forKey: .args) + try container.encodeIfPresent(env, forKey: .env) + try container.encodeIfPresent(url, forKey: .url) + } +} + +public enum MCPEnvParser { + public enum Result: Equatable, Sendable { + case ok([String: String]) + case invalid + } + + public static func parse(_ text: String) -> Result { + var env: [String: String] = [:] + for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) { + let trimmed = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } + guard let eq = trimmed.firstIndex(of: "=") else { return .invalid } + let key = String(trimmed[.. String { + value.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? value +} + +extension APIClient { + public func interceptStatus() async throws -> InterceptStatus { + try await get("/api/intercept/status") + } + + public func startIntercept(port: Int) async throws -> InterceptStatus { + try await post("/api/intercept/start", body: InterceptStartRequest(port: port)) + } + + public func stopIntercept() async throws -> ClearedFlagResponse { + try await post("/api/intercept/stop", body: EmptyBody()) + } + + public func interceptExchanges() async throws -> InterceptExchangesResponse { + try await get("/api/intercept/exchanges") + } + + public func clearIntercept() async throws -> ClearedFlagResponse { + try await post("/api/intercept/clear", body: EmptyBody()) + } + + public func interceptCA() async throws -> Data { + try await getData("/api/intercept/ca") + } + + public func interceptRules() async throws -> InterceptRulesResponse { + try await get("/api/intercept/rules") + } + + public func addInterceptRule(_ request: InterceptRuleRequest) async throws -> InterceptRule { + try await post("/api/intercept/rules", body: request) + } + + public func deleteInterceptRule(id: Int) async throws -> ClearedFlagResponse { + try await delete("/api/intercept/rules/\(id)") + } + + public func interceptors() async throws -> InterceptorsResponse { + try await get("/api/intercept/interceptors") + } + + public func activateInterceptor(_ id: String) async throws -> InterceptorSession { + try await post("/api/intercept/activate", body: InterceptActivateRequest(interceptor: id)) + } + + public func deactivateInterceptorSession(_ id: String) async throws -> ClearedFlagResponse { + try await delete("/api/intercept/sessions/\(encodedPath(id))") + } + + public func interceptBreakpoints() async throws -> InterceptPausedList { + try await get("/api/intercept/breakpoints") + } + + public func streamInterceptBreakpoints( + onEvent: @escaping @Sendable (InterceptPausedList) async -> Void + ) async throws { + try await streamGetJSON("/api/intercept/breakpoints/stream", onEvent: onEvent) + } + + public func resumeBreakpoint(id: Int, abort: Bool) async throws -> OKResponse { + let path = abort + ? "/api/intercept/breakpoints/\(id)?abort=1" + : "/api/intercept/breakpoints/\(id)" + return try await post(path, body: EmptyBody()) + } + + public func mcpStatus() async throws -> MCPStatusResponse { + try await get("/api/mcp") + } + + public func refreshMCP() async throws -> MCPStatusResponse { + try await post("/api/mcp/refresh", body: EmptyBody()) + } + + public func addMCPServer(_ request: AddMCPServerRequest) async throws -> NamedOKResponse { + try await post("/api/mcp/servers", body: request) + } + + public func deleteMCPServer(_ name: String) async throws -> NamedOKResponse { + try await delete("/api/mcp/servers/\(encodedPath(name))") + } + + public func hubMCP(query: String = "") async throws -> HubMCPResponse { + try await get("/api/hub/mcp", query: [URLQueryItem(name: "q", value: query)]) + } + + public func installHubMCP(id: String) async throws -> HubInstallResponse { + try await post("/api/hub/mcp/install", body: HubInstallRequest(id: id)) + } + + public func plugins() async throws -> PluginsResponse { + try await get("/api/plugins") + } + + public func addPlugin(_ request: AddPluginRequest) async throws -> NamedOKResponse { + try await post("/api/plugins", body: request) + } + + public func togglePlugin(name: String, enabled: Bool) async throws -> OKResponse { + try await post( + "/api/plugins/\(encodedPath(name))/toggle", + body: ToggleEnabledRequest(enabled: enabled) + ) + } + + public func reloadPlugins() async throws -> NamedOKResponse { + try await post("/api/plugins/reload", body: EmptyBody()) + } + + public func hubPlugins(query: String = "") async throws -> HubPluginsResponse { + try await get("/api/hub/plugins", query: [URLQueryItem(name: "q", value: query)]) + } + + public func installHubPlugin(id: String) async throws -> HubInstallResponse { + try await post("/api/hub/plugins/install", body: HubInstallRequest(id: id)) + } + + public func listProxies() async throws -> ProxyListResponse { + try await get("/api/proxies") + } + + public func batchAddProxies(text: String) async throws -> ProxyBatchResponse { + try await post("/api/proxies/batch", body: ProxyBatchRequest(text: text)) + } + + public func testProxy(id: String) async throws -> ProxyTestResponse { + try await post("/api/proxies/test", body: ProxyTestRequest(id: id)) + } + + public func deleteProxy(_ id: String) async throws -> NamedOKResponse { + try await delete("/api/proxies/\(encodedPath(id))") + } +} diff --git a/macos/AntaresKit/Sources/AntaresKit/OpsModels.swift b/macos/AntaresKit/Sources/AntaresKit/OpsModels.swift new file mode 100644 index 00000000..10ed2008 --- /dev/null +++ b/macos/AntaresKit/Sources/AntaresKit/OpsModels.swift @@ -0,0 +1,697 @@ +import Foundation + +public struct VPSListResponse: Decodable, Sendable { + public var hosts: [VPSHost] + + enum CodingKeys: String, CodingKey { + case hosts + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + hosts = try container.decodeIfPresent([VPSHost].self, forKey: .hosts) ?? [] + } +} + +public struct VPSHost: Decodable, Identifiable, Sendable, Hashable { + public var id: String + public var label: String + public var host: String + public var port: Int + public var username: String + public var authMethod: String + public var hasPassword: Bool + public var hasKey: Bool + public var createdAt: String? + public var updatedAt: String? + + enum CodingKeys: String, CodingKey { + case id + case label + case host + case port + case username + case authMethod = "auth_method" + case hasPassword = "has_password" + case hasKey = "has_key" + case createdAt = "created_at" + case updatedAt = "updated_at" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(String.self, forKey: .id) ?? "" + label = try container.decodeIfPresent(String.self, forKey: .label) ?? "" + host = try container.decodeIfPresent(String.self, forKey: .host) ?? "" + port = try container.decodeIfPresent(Int.self, forKey: .port) ?? 22 + username = try container.decodeIfPresent(String.self, forKey: .username) ?? "" + authMethod = try container.decodeIfPresent(String.self, forKey: .authMethod) ?? "password" + hasPassword = try container.decodeIfPresent(Bool.self, forKey: .hasPassword) ?? false + hasKey = try container.decodeIfPresent(Bool.self, forKey: .hasKey) ?? false + createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) + updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt) + } +} + +public struct VPSSaveRequest: Encodable, Sendable { + public var id: String? + public var label: String + public var host: String + public var port: Int + public var username: String + public var authMethod: String + public var password: String + public var privateKey: String + public var passphrase: String + + enum CodingKeys: String, CodingKey { + case id + case label + case host + case port + case username + case authMethod = "auth_method" + case password + case privateKey = "private_key" + case passphrase + } + + public init( + id: String? = nil, + label: String, + host: String, + port: Int, + username: String, + authMethod: String, + password: String = "", + privateKey: String = "", + passphrase: String = "" + ) { + self.id = id + self.label = label + self.host = host + self.port = port + self.username = username + self.authMethod = authMethod + self.password = password + self.privateKey = privateKey + self.passphrase = passphrase + } +} + +public struct VPSSaveResponse: Decodable, Sendable { + public var ok: Bool + public var id: String? +} + +public struct VPSTestResponse: Decodable, Sendable { + public var ok: Bool + public var asUser: String? + public var error: String? + + enum CodingKeys: String, CodingKey { + case ok + case asUser = "as" + case error + } + + public init(ok: Bool, asUser: String? = nil, error: String? = nil) { + self.ok = ok + self.asUser = asUser + self.error = error + } +} + +public struct VPSMetrics: Decodable, Sendable, Hashable { + public var reachable: Bool + public var error: String? + public var hostname: String + public var os: String + public var kernel: String + public var uptime: String + public var cpuCores: Int + public var cpuPercent: Double + public var load1: Double + public var load5: Double + public var load15: Double + public var memTotalMB: Int + public var memUsedMB: Int + public var memPercent: Double + public var swapTotalMB: Int + public var swapUsedMB: Int + public var swapPercent: Double + public var diskTotalGB: Double + public var diskUsedGB: Double + public var diskPercent: Double + public var processes: Int + public var topProc: [String] + + enum CodingKeys: String, CodingKey { + case reachable + case error + case hostname + case os + case kernel + case uptime + case cpuCores = "cpu_cores" + case cpuPercent = "cpu_percent" + case load1 + case load5 + case load15 + case memTotalMB = "mem_total_mb" + case memUsedMB = "mem_used_mb" + case memPercent = "mem_percent" + case swapTotalMB = "swap_total_mb" + case swapUsedMB = "swap_used_mb" + case swapPercent = "swap_percent" + case diskTotalGB = "disk_total_gb" + case diskUsedGB = "disk_used_gb" + case diskPercent = "disk_percent" + case processes + case topProc = "top_proc" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + reachable = try container.decodeIfPresent(Bool.self, forKey: .reachable) ?? false + error = try container.decodeIfPresent(String.self, forKey: .error) + hostname = try container.decodeIfPresent(String.self, forKey: .hostname) ?? "" + os = try container.decodeIfPresent(String.self, forKey: .os) ?? "" + kernel = try container.decodeIfPresent(String.self, forKey: .kernel) ?? "" + uptime = try container.decodeIfPresent(String.self, forKey: .uptime) ?? "" + cpuCores = try container.decodeIfPresent(Int.self, forKey: .cpuCores) ?? 0 + cpuPercent = try container.decodeIfPresent(Double.self, forKey: .cpuPercent) ?? 0 + load1 = try container.decodeIfPresent(Double.self, forKey: .load1) ?? 0 + load5 = try container.decodeIfPresent(Double.self, forKey: .load5) ?? 0 + load15 = try container.decodeIfPresent(Double.self, forKey: .load15) ?? 0 + memTotalMB = try container.decodeIfPresent(Int.self, forKey: .memTotalMB) ?? 0 + memUsedMB = try container.decodeIfPresent(Int.self, forKey: .memUsedMB) ?? 0 + memPercent = try container.decodeIfPresent(Double.self, forKey: .memPercent) ?? 0 + swapTotalMB = try container.decodeIfPresent(Int.self, forKey: .swapTotalMB) ?? 0 + swapUsedMB = try container.decodeIfPresent(Int.self, forKey: .swapUsedMB) ?? 0 + swapPercent = try container.decodeIfPresent(Double.self, forKey: .swapPercent) ?? 0 + diskTotalGB = try container.decodeIfPresent(Double.self, forKey: .diskTotalGB) ?? 0 + diskUsedGB = try container.decodeIfPresent(Double.self, forKey: .diskUsedGB) ?? 0 + diskPercent = try container.decodeIfPresent(Double.self, forKey: .diskPercent) ?? 0 + processes = try container.decodeIfPresent(Int.self, forKey: .processes) ?? 0 + topProc = try container.decodeIfPresent([String].self, forKey: .topProc) ?? [] + } + + public init(reachable: Bool, error: String? = nil) { + self.reachable = reachable + self.error = error + hostname = "" + os = "" + kernel = "" + uptime = "" + cpuCores = 0 + cpuPercent = 0 + load1 = 0 + load5 = 0 + load15 = 0 + memTotalMB = 0 + memUsedMB = 0 + memPercent = 0 + swapTotalMB = 0 + swapUsedMB = 0 + swapPercent = 0 + diskTotalGB = 0 + diskUsedGB = 0 + diskPercent = 0 + processes = 0 + topProc = [] + } +} + +public struct VPSProcessList: Decodable, Sendable { + public var processes: [VPSProcess] + public var error: String? + + enum CodingKeys: String, CodingKey { + case processes + case error + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + processes = try container.decodeIfPresent([VPSProcess].self, forKey: .processes) ?? [] + error = try container.decodeIfPresent(String.self, forKey: .error) + } +} + +public struct VPSProcess: Decodable, Identifiable, Sendable, Hashable { + public var pid: Int + public var user: String + public var cpu: Double + public var mem: Double + public var command: String + + public var id: Int { pid } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + pid = try container.decodeIfPresent(Int.self, forKey: .pid) ?? 0 + user = try container.decodeIfPresent(String.self, forKey: .user) ?? "" + cpu = try container.decodeIfPresent(Double.self, forKey: .cpu) ?? 0 + mem = try container.decodeIfPresent(Double.self, forKey: .mem) ?? 0 + command = try container.decodeIfPresent(String.self, forKey: .command) ?? "" + } + + enum CodingKeys: String, CodingKey { + case pid, user, cpu, mem, command + } +} + +public struct AnalyticsResponse: Decodable, Sendable { + public var series: [AnalyticsUsagePoint] + public var byModel: [AnalyticsModelUsage] + public var totals: AnalyticsTotals + + enum CodingKeys: String, CodingKey { + case series + case byModel = "by_model" + case totals + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + series = try container.decodeIfPresent([AnalyticsUsagePoint].self, forKey: .series) ?? [] + byModel = try container.decodeIfPresent([AnalyticsModelUsage].self, forKey: .byModel) ?? [] + totals = try container.decodeIfPresent(AnalyticsTotals.self, forKey: .totals) ?? AnalyticsTotals() + } +} + +public struct AnalyticsUsagePoint: Decodable, Identifiable, Sendable, Hashable { + public var bucket: String + public var tokensIn: Int + public var tokensOut: Int + public var cost: Double + public var calls: Int + + public var id: String { bucket } + + enum CodingKeys: String, CodingKey { + case bucket + case tokensIn = "tokens_in" + case tokensOut = "tokens_out" + case cost + case calls + } +} + +public struct AnalyticsModelUsage: Decodable, Identifiable, Sendable, Hashable { + public var model: String + public var provider: String + public var calls: Int + public var tokensIn: Int + public var tokensOut: Int + public var cost: Double + + public var id: String { "\(provider)/\(model)" } + + enum CodingKeys: String, CodingKey { + case model + case provider + case calls + case tokensIn = "tokens_in" + case tokensOut = "tokens_out" + case cost + } +} + +public struct AnalyticsTotals: Decodable, Sendable, Hashable { + public var tokensIn: Int + public var tokensOut: Int + public var cost: Double + public var calls: Int + + enum CodingKeys: String, CodingKey { + case tokensIn = "tokens_in" + case tokensOut = "tokens_out" + case cost + case calls + } + + public init( + tokensIn: Int = 0, + tokensOut: Int = 0, + cost: Double = 0, + calls: Int = 0 + ) { + self.tokensIn = tokensIn + self.tokensOut = tokensOut + self.cost = cost + self.calls = calls + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + tokensIn = try container.decodeIfPresent(Int.self, forKey: .tokensIn) ?? 0 + tokensOut = try container.decodeIfPresent(Int.self, forKey: .tokensOut) ?? 0 + cost = try container.decodeIfPresent(Double.self, forKey: .cost) ?? 0 + calls = try container.decodeIfPresent(Int.self, forKey: .calls) ?? 0 + } +} + +public struct FileListResponse: Decodable, Sendable { + public var path: String + public var parent: String + public var entries: [FileEntry] + + enum CodingKeys: String, CodingKey { + case path + case parent + case entries + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + path = try container.decodeIfPresent(String.self, forKey: .path) ?? "." + parent = try container.decodeIfPresent(String.self, forKey: .parent) ?? "" + entries = try container.decodeIfPresent([FileEntry].self, forKey: .entries) ?? [] + } +} + +public struct FileEntry: Decodable, Identifiable, Sendable, Hashable { + public var name: String + public var path: String + public var isDir: Bool + public var size: Int + public var modified: String + + public var id: String { path } + + enum CodingKeys: String, CodingKey { + case name + case path + case isDir = "is_dir" + case size + case modified + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" + path = try container.decodeIfPresent(String.self, forKey: .path) ?? "" + isDir = try container.decodeIfPresent(Bool.self, forKey: .isDir) ?? false + size = try container.decodeIfPresent(Int.self, forKey: .size) ?? 0 + modified = try container.decodeIfPresent(String.self, forKey: .modified) ?? "" + } +} + +public struct FileReadResponse: Decodable, Sendable { + public var content: String + public var binary: Bool + public var size: Int + + enum CodingKeys: String, CodingKey { + case content + case binary + case size + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + content = try container.decodeIfPresent(String.self, forKey: .content) ?? "" + binary = try container.decodeIfPresent(Bool.self, forKey: .binary) ?? false + size = try container.decodeIfPresent(Int.self, forKey: .size) ?? 0 + } +} + +public struct LogListResponse: Decodable, Sendable { + public var entries: [LogEntry] + + enum CodingKeys: String, CodingKey { + case entries + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + entries = try container.decodeIfPresent([LogEntry].self, forKey: .entries) ?? [] + } +} + +public struct LogEntry: Decodable, Sendable, Hashable { + public var time: String + public var level: String + public var message: String + public var source: String? + public var attrs: [String: FlexibleJSON]? + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + time = try container.decodeIfPresent(String.self, forKey: .time) ?? "" + level = try container.decodeIfPresent(String.self, forKey: .level) ?? "" + message = try container.decodeIfPresent(String.self, forKey: .message) ?? "" + source = try container.decodeIfPresent(String.self, forKey: .source) + attrs = try container.decodeIfPresent([String: FlexibleJSON].self, forKey: .attrs) + } + + enum CodingKeys: String, CodingKey { + case time, level, message, source, attrs + } +} + +public struct SystemStats: Decodable, Sendable { + public var version: String + public var goVersion: String + public var os: String + public var arch: String + public var uptimeSeconds: Int + public var goroutines: Int + public var memoryAlloc: Int + public var memorySys: Int + public var cpuCount: Int + public var workspace: String + public var configPath: String + public var database: SystemDatabaseStats + public var store: SystemStoreStats + public var activeShells: Int + public var model: String + public var provider: String + public var ragProvider: String + + enum CodingKeys: String, CodingKey { + case version + case goVersion = "go_version" + case os + case arch + case uptimeSeconds = "uptime_seconds" + case goroutines + case memoryAlloc = "memory_alloc" + case memorySys = "memory_sys" + case cpuCount = "cpu_count" + case workspace + case configPath = "config_path" + case database + case store + case activeShells = "active_shells" + case model + case provider + case ragProvider = "rag_provider" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + version = try container.decodeIfPresent(String.self, forKey: .version) ?? "" + goVersion = try container.decodeIfPresent(String.self, forKey: .goVersion) ?? "" + os = try container.decodeIfPresent(String.self, forKey: .os) ?? "" + arch = try container.decodeIfPresent(String.self, forKey: .arch) ?? "" + uptimeSeconds = try container.decodeIfPresent(Int.self, forKey: .uptimeSeconds) ?? 0 + goroutines = try container.decodeIfPresent(Int.self, forKey: .goroutines) ?? 0 + memoryAlloc = try container.decodeIfPresent(Int.self, forKey: .memoryAlloc) ?? 0 + memorySys = try container.decodeIfPresent(Int.self, forKey: .memorySys) ?? 0 + cpuCount = try container.decodeIfPresent(Int.self, forKey: .cpuCount) ?? 0 + workspace = try container.decodeIfPresent(String.self, forKey: .workspace) ?? "" + configPath = try container.decodeIfPresent(String.self, forKey: .configPath) ?? "" + database = try container.decodeIfPresent(SystemDatabaseStats.self, forKey: .database) ?? SystemDatabaseStats() + store = try container.decodeIfPresent(SystemStoreStats.self, forKey: .store) ?? SystemStoreStats() + activeShells = try container.decodeIfPresent(Int.self, forKey: .activeShells) ?? 0 + model = try container.decodeIfPresent(String.self, forKey: .model) ?? "" + provider = try container.decodeIfPresent(String.self, forKey: .provider) ?? "" + ragProvider = try container.decodeIfPresent(String.self, forKey: .ragProvider) ?? "" + } +} + +public struct SystemDatabaseStats: Decodable, Sendable, Hashable { + public var driver: String + public var sizeBytes: Int + + enum CodingKeys: String, CodingKey { + case driver + case sizeBytes = "size_bytes" + } + + public init(driver: String = "", sizeBytes: Int = 0) { + self.driver = driver + self.sizeBytes = sizeBytes + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + driver = try container.decodeIfPresent(String.self, forKey: .driver) ?? "" + sizeBytes = try container.decodeIfPresent(Int.self, forKey: .sizeBytes) ?? 0 + } +} + +public struct SystemStoreStats: Decodable, Sendable, Hashable { + public var sessions: Int + public var messages: Int + public var memories: Int + public var cronJobs: Int + public var tokensIn: Int + public var tokensOut: Int + public var cost: Double + public var emptySessions: Int + public var sizeBytes: Int + + enum CodingKeys: String, CodingKey { + case sessions + case messages + case memories + case cronJobs = "cron_jobs" + case tokensIn = "tokens_in" + case tokensOut = "tokens_out" + case cost + case emptySessions = "empty_sessions" + case sizeBytes = "size_bytes" + } + + public init( + sessions: Int = 0, + messages: Int = 0, + memories: Int = 0, + cronJobs: Int = 0, + tokensIn: Int = 0, + tokensOut: Int = 0, + cost: Double = 0, + emptySessions: Int = 0, + sizeBytes: Int = 0 + ) { + self.sessions = sessions + self.messages = messages + self.memories = memories + self.cronJobs = cronJobs + self.tokensIn = tokensIn + self.tokensOut = tokensOut + self.cost = cost + self.emptySessions = emptySessions + self.sizeBytes = sizeBytes + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + sessions = try container.decodeIfPresent(Int.self, forKey: .sessions) ?? 0 + messages = try container.decodeIfPresent(Int.self, forKey: .messages) ?? 0 + memories = try container.decodeIfPresent(Int.self, forKey: .memories) ?? 0 + cronJobs = try container.decodeIfPresent(Int.self, forKey: .cronJobs) ?? 0 + tokensIn = try container.decodeIfPresent(Int.self, forKey: .tokensIn) ?? 0 + tokensOut = try container.decodeIfPresent(Int.self, forKey: .tokensOut) ?? 0 + cost = try container.decodeIfPresent(Double.self, forKey: .cost) ?? 0 + emptySessions = try container.decodeIfPresent(Int.self, forKey: .emptySessions) ?? 0 + sizeBytes = try container.decodeIfPresent(Int.self, forKey: .sizeBytes) ?? 0 + } +} + +public struct RuntimeStatus: Decodable, Sendable { + public var ok: Bool + public var version: String + public var model: String + public var provider: String + public var providerReady: Bool + public var database: String + public var uptimeSeconds: Int + public var activeSessions: Int + public var needsSetup: Bool + + enum CodingKeys: String, CodingKey { + case ok + case version + case model + case provider + case providerReady = "provider_ready" + case database + case uptimeSeconds = "uptime_seconds" + case activeSessions = "active_sessions" + case needsSetup = "needs_setup" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + ok = try container.decodeIfPresent(Bool.self, forKey: .ok) ?? false + version = try container.decodeIfPresent(String.self, forKey: .version) ?? "" + model = try container.decodeIfPresent(String.self, forKey: .model) ?? "" + provider = try container.decodeIfPresent(String.self, forKey: .provider) ?? "" + providerReady = try container.decodeIfPresent(Bool.self, forKey: .providerReady) ?? false + database = try container.decodeIfPresent(String.self, forKey: .database) ?? "" + uptimeSeconds = try container.decodeIfPresent(Int.self, forKey: .uptimeSeconds) ?? 0 + activeSessions = try container.decodeIfPresent(Int.self, forKey: .activeSessions) ?? 0 + needsSetup = try container.decodeIfPresent(Bool.self, forKey: .needsSetup) ?? false + } +} + +extension APIClient { + public func listVPS() async throws -> VPSListResponse { + try await get("/api/vps") + } + + public func saveVPS(_ request: VPSSaveRequest) async throws -> VPSSaveResponse { + try await post("/api/vps", body: request) + } + + public func testVPS(_ request: VPSSaveRequest) async throws -> VPSTestResponse { + try await post("/api/vps/test", body: request) + } + + public func vpsMetrics(id: String) async throws -> VPSMetrics { + try await get("/api/vps/\(encodedPath(id))/metrics") + } + + public func vpsProcesses(id: String) async throws -> VPSProcessList { + try await get("/api/vps/\(encodedPath(id))/processes") + } + + public func deleteVPS(_ id: String) async throws -> OKResponse { + try await delete("/api/vps/\(encodedPath(id))") + } + + public func analytics(range: String, bucket: String) async throws -> AnalyticsResponse { + try await get("/api/analytics", query: [ + URLQueryItem(name: "range", value: range), + URLQueryItem(name: "bucket", value: bucket), + ]) + } + + public func listFiles(path: String = ".") async throws -> FileListResponse { + try await get("/api/files", query: [URLQueryItem(name: "path", value: path)]) + } + + public func readFile(path: String) async throws -> FileReadResponse { + try await get("/api/files/read", query: [URLQueryItem(name: "path", value: path)]) + } + + public func listLogs(limit: Int = 500, level: String = "ALL", query: String = "") async throws -> LogListResponse { + var items = [ + URLQueryItem(name: "limit", value: String(limit)), + URLQueryItem(name: "level", value: level), + ] + if !query.isEmpty { + items.append(URLQueryItem(name: "q", value: query)) + } + return try await get("/api/logs", query: items) + } + + public func streamLogs( + onEvent: @escaping @Sendable (LogEntry) async -> Void + ) async throws { + try await streamGetJSON("/api/logs/stream", onEvent: onEvent) + } + + public func systemStats() async throws -> SystemStats { + try await get("/api/system/stats") + } + + public func runtimeStatus() async throws -> RuntimeStatus { + try await get("/api/status") + } +} diff --git a/macos/AntaresKit/Sources/AntaresKit/RuntimeSupervisor.swift b/macos/AntaresKit/Sources/AntaresKit/RuntimeSupervisor.swift new file mode 100644 index 00000000..d644bd9a --- /dev/null +++ b/macos/AntaresKit/Sources/AntaresKit/RuntimeSupervisor.swift @@ -0,0 +1,211 @@ +import Darwin +import Foundation + +public enum RuntimePlan { + public enum Mode: Equatable { case attached, spawned, conflict } + + public static func decide(healthOK: Bool, portBusy: Bool, childExists: Bool = false) -> Mode { + if childExists { return .spawned } + if healthOK { return .attached } + if portBusy { return .conflict } + return .spawned + } + + public static func shouldStopChild(mode: Mode) -> Bool { mode == .spawned } +} + +public struct HealthResponse: Decodable, Sendable { + public let ok: Bool + public let version: String + + /// Any 2xx /api/health decode means Antares is present; `ok` may be false when db ping fails. + public static func meansAntaresPresent(decodeSucceeded: Bool, ok: Bool = true) -> Bool { + _ = ok + return decodeSucceeded + } +} + +public enum RuntimeSupervisorError: Error, LocalizedError { + case missingBinary + case portConflict + case healthTimeout + case spawnFailed(String) + + public var errorDescription: String? { + switch self { + case .missingBinary: "Bundled antares binary not found" + case .portConflict: "Port in use by a process that is not Antares" + case .healthTimeout: "Backend did not become healthy within 30 seconds" + case .spawnFailed(let detail): detail + } + } +} + +/// Captures stderr lines from a spawned child for LaunchView. +public actor StderrLog { + private var lines: [String] = [] + + public var lastLog: String { lines.last ?? "" } + + func append(_ data: Data) { + let chunk = String(decoding: data, as: UTF8.self) + for line in chunk.split(whereSeparator: \.isNewline) { + let text = String(line) + if !text.isEmpty { lines.append(text) } + } + } +} + +public final class RuntimeSupervisor: @unchecked Sendable { + public private(set) var mode: RuntimePlan.Mode? + public let stderrLog = StderrLog() + public let baseURL: URL + + private let client: APIClient + private var process: Process? + private var stderrTask: Task? + + public init(baseURL: URL? = nil, session: URLSession = .shared) { + let resolved = baseURL ?? AntaresKit.resolveBaseURL() + self.baseURL = resolved + self.client = APIClient(baseURL: resolved, session: session) + } + + /// Probes health, attaches or spawns the bundled backend, then waits until healthy. + /// If a child already exists, keep `.spawned` and return without re-probing — + /// a healthy child must not flip the mode to `.attached` (quit would leak it). + public func start(binary: URL?) async throws { + if process != nil { + mode = .spawned + return + } + + let healthOK = await probeHealth() + let portBusy = !healthOK && isPortBusy(baseURL: baseURL) + let plan = RuntimePlan.decide(healthOK: healthOK, portBusy: portBusy, childExists: false) + mode = plan + + switch plan { + case .attached: + return + case .conflict: + throw RuntimeSupervisorError.portConflict + case .spawned: + guard let binary else { throw RuntimeSupervisorError.missingBinary } + try spawn(binary: binary) + do { + try await waitForHealth() + } catch { + await stop() + throw error + } + } + } + + /// Sends SIGTERM immediately to a spawned child. Call from + /// `NSApplication.willTerminateNotification` on the main actor so the + /// child is signaled even if the async wait in `stop()` never runs. + /// + /// Remaining race: the app can still exit before the 5s wait / SIGKILL + /// in `stop()` finishes. Immediate `process.terminate()` is the part + /// that is guaranteed once this method is invoked. + public func terminateSpawnedChild() { + guard let mode, RuntimePlan.shouldStopChild(mode: mode) else { return } + process?.terminate() + } + + /// Stops a spawned child with SIGTERM, then SIGKILL after 5s. Attached mode is left running. + public func stop() async { + terminateSpawnedChild() + stderrTask?.cancel() + stderrTask = nil + guard let process else { return } + if process.isRunning { + let pid = process.processIdentifier + try? await Task.sleep(for: .seconds(5)) + if process.isRunning { + kill(pid, SIGKILL) + } + } + self.process = nil + } + + private func probeHealth() async -> Bool { + // Attach on any 2xx decode; ok:false is degraded Antares, not a port conflict. + HealthResponse.meansAntaresPresent(decodeSucceeded: (try? await fetchHealth()) != nil) + } + + private func fetchHealth() async throws -> HealthResponse { + try await client.get("/api/health") + } + + private func waitForHealth() async throws { + let deadline = Date().addingTimeInterval(30) + while Date() < deadline { + if process?.isRunning == false { + let detail = await stderrLog.lastLog + throw RuntimeSupervisorError.spawnFailed( + detail.isEmpty ? "Backend process exited before becoming healthy" : detail + ) + } + if await probeHealth() { return } + try await Task.sleep(for: .milliseconds(200)) + } + let detail = await stderrLog.lastLog + throw RuntimeSupervisorError.spawnFailed( + detail.isEmpty ? RuntimeSupervisorError.healthTimeout.localizedDescription : detail + ) + } + + private func spawn(binary: URL) throws { + let child = Process() + child.executableURL = binary + child.arguments = ["--foreground"] + var env = ProcessInfo.processInfo.environment + env["HOME"] = NSHomeDirectory() + child.environment = env + + let pipe = Pipe() + child.standardOutput = FileHandle.nullDevice + child.standardError = pipe + + try child.run() + process = child + stderrTask = Task { [stderrLog] in + let handle = pipe.fileHandleForReading + while !Task.isCancelled { + let data = handle.availableData + if data.isEmpty { break } + await stderrLog.append(data) + } + } + } + + private func isPortBusy(baseURL: URL) -> Bool { + guard let host = baseURL.host else { return false } + let port = baseURL.port ?? (baseURL.scheme == "https" ? 443 : 80) + var hints = addrinfo( + ai_flags: AI_NUMERICHOST, + ai_family: AF_UNSPEC, + ai_socktype: SOCK_STREAM, + ai_protocol: IPPROTO_TCP, + ai_addrlen: 0, + ai_canonname: nil, + ai_addr: nil, + ai_next: nil + ) + var result: UnsafeMutablePointer? + defer { if result != nil { freeaddrinfo(result) } } + guard getaddrinfo(host, String(port), &hints, &result) == 0, let info = result else { + return false + } + let fd = socket(info.pointee.ai_family, info.pointee.ai_socktype, info.pointee.ai_protocol) + guard fd >= 0 else { return false } + defer { close(fd) } + var addr = info.pointee.ai_addr.withMemoryRebound(to: sockaddr.self, capacity: 1) { $0.pointee } + let len = info.pointee.ai_addrlen + return withUnsafePointer(to: &addr) { ptr in + connect(fd, UnsafeRawPointer(ptr).assumingMemoryBound(to: sockaddr.self), len) == 0 + } + } +} diff --git a/macos/AntaresKit/Sources/AntaresKit/SSEClient.swift b/macos/AntaresKit/Sources/AntaresKit/SSEClient.swift new file mode 100644 index 00000000..57360db7 --- /dev/null +++ b/macos/AntaresKit/Sources/AntaresKit/SSEClient.swift @@ -0,0 +1,297 @@ +import Foundation + +public struct SSEAccumulator { + private var buffer = Data() + private var dataLines: [String] = [] + + public init() {} + + public mutating func append( + _ data: Data, + onEvent: ([String: String]) throws -> Void, + onParseError: ((String, Error) -> Void)? = nil, + onPayload: ((Data) throws -> Void)? = nil + ) throws { + buffer.append(data) + try consumeCompleteLines( + final: false, + onEvent: onEvent, + onParseError: onParseError, + onPayload: onPayload + ) + } + + public mutating func finish( + onEvent: ([String: String]) throws -> Void, + onParseError: ((String, Error) -> Void)? = nil, + onPayload: ((Data) throws -> Void)? = nil + ) throws { + try consumeCompleteLines( + final: true, + onEvent: onEvent, + onParseError: onParseError, + onPayload: onPayload + ) + buffer.removeAll(keepingCapacity: false) + dataLines.removeAll(keepingCapacity: false) + } + + private mutating func consumeCompleteLines( + final: Bool, + onEvent: ([String: String]) throws -> Void, + onParseError: ((String, Error) -> Void)?, + onPayload: ((Data) throws -> Void)? + ) throws { + var lineStart = 0 + var i = 0 + + while i < buffer.count { + if buffer[i] == 0x0A { + try flushLine(lineStart.. 0 { + buffer.removeSubrange(0.., + onEvent: ([String: String]) throws -> Void, + onParseError: ((String, Error) -> Void)?, + onPayload: ((Data) throws -> Void)? + ) throws { + let line = String(decoding: buffer[range], as: UTF8.self) + if line.hasPrefix(":") { return } + if line.isEmpty { + let payload = dataLines.joined(separator: "\n") + dataLines = [] + if !payload.isEmpty, payload != "[DONE]" { + try onPayload?(Data(payload.utf8)) + } + try Self.dispatch(payload, onEvent: onEvent, onParseError: onParseError) + return + } + if line.hasPrefix("data:") { + var value = String(line.dropFirst(5)) + if value.first == " " { value.removeFirst() } + dataLines.append(value) + } + } + + private static func stringifyValue(_ value: Any) -> String { + if let string = value as? String { return string } + if let boolean = value as? Bool { return boolean ? "true" : "false" } + return "\(value)" + } + + private static func dispatch( + _ payload: String, + onEvent: ([String: String]) throws -> Void, + onParseError: ((String, Error) -> Void)? + ) throws { + guard !payload.isEmpty, payload != "[DONE]" else { return } + let obj: Any + do { + obj = try JSONSerialization.jsonObject( + with: Data(payload.utf8), + options: .fragmentsAllowed + ) + } catch { + onParseError?(payload, error) + return + } + if let string = obj as? String { + try onEvent(["value": string]) + return + } + guard let dict = obj as? [String: Any] else { return } + var strings: [String: String] = [:] + for (key, value) in dict { + strings[key] = stringifyValue(value) + } + try onEvent(strings) + } +} + +public enum SSEStream { + public static let defaultChunkSize = 4096 + + public static func consume( + _ bytes: S, + chunkSize: Int = defaultChunkSize, + onEvent: @escaping @Sendable ([String: String]) async -> Void, + onParseError: (@Sendable (String, Error) async -> Void)? = nil + ) async throws where S.Element == UInt8 { + let flushAt = max(chunkSize, 1) + var accumulator = SSEAccumulator() + var chunk = Data() + chunk.reserveCapacity(flushAt) + + func flush() async throws { + guard !chunk.isEmpty else { return } + var emitted: [[String: String]] = [] + var parseErrors: [(String, Error)] = [] + try accumulator.append(chunk, onEvent: { event in + emitted.append(event) + }, onParseError: { raw, error in + parseErrors.append((raw, error)) + }) + chunk.removeAll(keepingCapacity: true) + for event in emitted { + await onEvent(event) + } + if let onParseError { + for (raw, error) in parseErrors { + await onParseError(raw, error) + } + } + } + + for try await byte in bytes { + chunk.append(byte) + if chunk.count >= flushAt || byte == 0x0A { + try await flush() + } + } + try await flush() + + var emitted: [[String: String]] = [] + var parseErrors: [(String, Error)] = [] + try accumulator.finish(onEvent: { event in + emitted.append(event) + }, onParseError: { raw, error in + parseErrors.append((raw, error)) + }) + for event in emitted { + await onEvent(event) + } + if let onParseError { + for (raw, error) in parseErrors { + await onParseError(raw, error) + } + } + } + + public static func consumeJSON( + _ bytes: S, + as type: T.Type = T.self, + chunkSize: Int = defaultChunkSize, + onEvent: @escaping @Sendable (T) async -> Void, + onParseError: (@Sendable (String, Error) async -> Void)? = nil + ) async throws where S.Element == UInt8 { + let flushAt = max(chunkSize, 1) + var accumulator = SSEAccumulator() + var chunk = Data() + chunk.reserveCapacity(flushAt) + + func flush() async throws { + guard !chunk.isEmpty else { return } + var decoded: [T] = [] + var parseErrors: [(String, Error)] = [] + try accumulator.append(chunk, onEvent: { _ in }, onParseError: { raw, error in + parseErrors.append((raw, error)) + }, onPayload: { payload in + do { + decoded.append(try JSONDecoder().decode(type, from: payload)) + } catch { + parseErrors.append((String(decoding: payload, as: UTF8.self), error)) + } + }) + chunk.removeAll(keepingCapacity: true) + for event in decoded { + await onEvent(event) + } + if let onParseError { + for (raw, error) in parseErrors { + await onParseError(raw, error) + } + } + } + + for try await byte in bytes { + chunk.append(byte) + if chunk.count >= flushAt || byte == 0x0A { + try await flush() + } + } + try await flush() + + var decoded: [T] = [] + var parseErrors: [(String, Error)] = [] + try accumulator.finish(onEvent: { _ in }, onParseError: { raw, error in + parseErrors.append((raw, error)) + }, onPayload: { payload in + do { + decoded.append(try JSONDecoder().decode(type, from: payload)) + } catch { + parseErrors.append((String(decoding: payload, as: UTF8.self), error)) + } + }) + for event in decoded { + await onEvent(event) + } + if let onParseError { + for (raw, error) in parseErrors { + await onParseError(raw, error) + } + } + } +} + +public enum SSEParser { + public static func parse( + _ data: Data, + onEvent: ([String: String]) throws -> Void, + onParseError: ((String, Error) -> Void)? = nil + ) throws { + var accumulator = SSEAccumulator() + try accumulator.append(data, onEvent: onEvent, onParseError: onParseError) + try accumulator.finish(onEvent: onEvent, onParseError: onParseError) + } + + public static func parseJSON( + _ data: Data, + as type: T.Type = T.self, + decoder: JSONDecoder = JSONDecoder(), + onEvent: @escaping (T) throws -> Void, + onParseError: ((String, Error) -> Void)? = nil + ) throws { + var accumulator = SSEAccumulator() + func decode(_ payload: Data) throws { + do { + try onEvent(decoder.decode(type, from: payload)) + } catch { + onParseError?(String(decoding: payload, as: UTF8.self), error) + } + } + try accumulator.append(data, onEvent: { _ in }, onParseError: onParseError, onPayload: decode) + try accumulator.finish(onEvent: { _ in }, onParseError: onParseError, onPayload: decode) + } +} diff --git a/macos/AntaresKit/Sources/AntaresKit/WorkflowModels.swift b/macos/AntaresKit/Sources/AntaresKit/WorkflowModels.swift new file mode 100644 index 00000000..efb0db81 --- /dev/null +++ b/macos/AntaresKit/Sources/AntaresKit/WorkflowModels.swift @@ -0,0 +1,311 @@ +import Foundation + +public struct AutopilotCardsResponse: Decodable, Sendable { + public var cards: [AutopilotCard] + + enum CodingKeys: String, CodingKey { + case cards + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + cards = try container.decodeIfPresent([AutopilotCard].self, forKey: .cards) ?? [] + } +} + +public struct AutopilotCard: Decodable, Identifiable, Sendable, Hashable { + public var id: String + public var title: String + public var prompt: String + public var status: String + public var result: String? + public var verifyOutput: String? + public var branch: String? + public var pr: String? + public var error: String? + public var createdAt: String + public var updatedAt: String + + enum CodingKeys: String, CodingKey { + case id + case title + case prompt + case status + case result + case verifyOutput = "verify_output" + case branch + case pr + case error + case createdAt = "created_at" + case updatedAt = "updated_at" + } +} + +public struct AutopilotAddRequest: Encodable, Sendable { + public var title: String + public var prompt: String + + public init(title: String, prompt: String) { + self.title = title + self.prompt = prompt + } +} + +public struct AutopilotRunResponse: Decodable, Sendable { + public var started: Int + public var alreadyRunning: Bool? + + enum CodingKeys: String, CodingKey { + case started + case alreadyRunning = "already_running" + } +} + +public struct EngagementSessionsResponse: Decodable, Sendable { + public var sessions: [EngagementSession] + + enum CodingKeys: String, CodingKey { + case sessions + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + sessions = try container.decodeIfPresent([EngagementSession].self, forKey: .sessions) ?? [] + } +} + +public struct EngagementSession: Decodable, Identifiable, Sendable, Hashable { + public var id: String + public var title: String + public var findings: Int + public var intel: Int +} + +public struct EngagementSnapshot: Decodable, Sendable { + public var phases: [EngagementPhase] + public var coverage: [EngagementArea] + public var coveragePercent: Int + public var chains: [EngagementChain] + public var findings: [EngagementFinding] + public var intel: [EngagementIntel] + public var next: String? + public var scope: [String]? + + enum CodingKeys: String, CodingKey { + case phases + case coverage + case coveragePercent = "coverage_percent" + case chains + case findings + case intel + case next + case scope + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + phases = try container.decodeIfPresent([EngagementPhase].self, forKey: .phases) ?? [] + coverage = try container.decodeIfPresent([EngagementArea].self, forKey: .coverage) ?? [] + coveragePercent = try container.decodeIfPresent(Int.self, forKey: .coveragePercent) ?? 0 + chains = try container.decodeIfPresent([EngagementChain].self, forKey: .chains) ?? [] + findings = try container.decodeIfPresent([EngagementFinding].self, forKey: .findings) ?? [] + intel = try container.decodeIfPresent([EngagementIntel].self, forKey: .intel) ?? [] + next = try container.decodeIfPresent(String.self, forKey: .next) + scope = try container.decodeIfPresent([String].self, forKey: .scope) + } +} + +public struct EngagementPhase: Decodable, Identifiable, Sendable { + public var name: String + public var title: String + public var summary: String + public var status: String + public var evidence: Int + public var roles: [String]? + public var requires: [String]? + + public var id: String { name } +} + +public struct EngagementArea: Decodable, Identifiable, Sendable { + public var name: String + public var title: String + public var covered: Bool + + public var id: String { name } +} + +public struct EngagementChain: Decodable, Identifiable, Sendable { + public var name: String + public var impact: String + + public var id: String { name } +} + +public struct EngagementFinding: Decodable, Identifiable, Sendable { + public var id: String + public var title: String + public var severity: String + public var target: String? + public var description: String + public var cwe: String? + public var status: String? + public var createdAt: String? + + enum CodingKeys: String, CodingKey { + case id + case title + case severity + case target + case description + case cwe + case status + case createdAt = "created_at" + } +} + +public struct EngagementIntel: Decodable, Identifiable, Sendable { + public var id: String + public var type: String + public var value: String + public var detail: String? + public var createdAt: String? + + enum CodingKeys: String, CodingKey { + case id + case type + case value + case detail + case createdAt = "created_at" + } +} + +public struct BoardSessionsResponse: Decodable, Sendable { + public var sessions: [BoardSession] + + enum CodingKeys: String, CodingKey { + case sessions + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + sessions = try container.decodeIfPresent([BoardSession].self, forKey: .sessions) ?? [] + } +} + +public struct BoardSession: Decodable, Identifiable, Sendable, Hashable { + public var id: String + public var title: String +} + +public struct BoardSnapshot: Decodable, Sendable { + public var columns: [BoardColumn] + + enum CodingKeys: String, CodingKey { + case columns + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + columns = try container.decodeIfPresent([BoardColumn].self, forKey: .columns) ?? [] + } +} + +public struct BoardColumn: Decodable, Identifiable, Sendable { + public var name: String + public var cards: [BoardCard] + + public var id: String { name } + + enum CodingKeys: String, CodingKey { + case name + case cards + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + cards = try container.decodeIfPresent([BoardCard].self, forKey: .cards) ?? [] + } +} + +public struct BoardCard: Decodable, Identifiable, Sendable, Hashable { + public var id: String + public var title: String + public var note: String? + public var column: String + public var createdAt: String? + public var updatedAt: String? + + enum CodingKeys: String, CodingKey { + case id + case title + case note + case column + case createdAt = "created_at" + case updatedAt = "updated_at" + } +} + +extension APIClient { + public func listAutopilotCards() async throws -> AutopilotCardsResponse { + try await get("/api/autopilot") + } + + public func addAutopilotCard(title: String, prompt: String) async throws -> AutopilotCard { + try await post("/api/autopilot", body: AutopilotAddRequest(title: title, prompt: prompt)) + } + + public func runAutopilot() async throws -> AutopilotRunResponse { + try await post("/api/autopilot/run", body: EmptyBody()) + } + + public func removeAutopilotCard(_ id: String) async throws -> OKResponse { + try await delete("/api/autopilot/\(id)") + } + + public func listEngagementSessions() async throws -> EngagementSessionsResponse { + try await get("/api/engagement/sessions") + } + + public func engagement(sessionID: String) async throws -> EngagementSnapshot { + try await get("/api/engagement", query: [URLQueryItem(name: "session", value: sessionID)]) + } + + public func engagementReport(sessionID: String, title: String) async throws -> String { + let data = try await getData( + "/api/engagement/report", + query: [ + URLQueryItem(name: "session", value: sessionID), + URLQueryItem(name: "title", value: title), + ] + ) + return String(decoding: data, as: UTF8.self) + } + + public func deleteEngagement(sessionID: String) async throws -> OKResponse { + try await delete("/api/engagement/\(sessionID)") + } + + public func listBoardSessions() async throws -> BoardSessionsResponse { + try await get("/api/board/sessions") + } + + public func board(sessionID: String) async throws -> BoardSnapshot { + try await get("/api/board", query: [URLQueryItem(name: "session", value: sessionID)]) + } + + public func deleteBoardCard(sessionID: String, id: String) async throws -> OKResponse { + try await delete( + "/api/board/card", + query: [ + URLQueryItem(name: "session", value: sessionID), + URLQueryItem(name: "id", value: id), + ] + ) + } + + public func clearBoard(sessionID: String) async throws -> OKResponse { + try await delete("/api/board", query: [URLQueryItem(name: "session", value: sessionID)]) + } +} diff --git a/macos/AntaresKit/Tests/AntaresKitTests/APIClientTests.swift b/macos/AntaresKit/Tests/AntaresKitTests/APIClientTests.swift new file mode 100644 index 00000000..cf51cd01 --- /dev/null +++ b/macos/AntaresKit/Tests/AntaresKitTests/APIClientTests.swift @@ -0,0 +1,25 @@ +import XCTest +@testable import AntaresKit + +final class APIClientTests: XCTestCase { + func testDecodeErrorMessageFromJSONBody() { + let body = #"{"error":"nope"}"#.data(using: .utf8)! + let err = APIError(status: 400, body: body) + XCTAssertEqual(err.message, "nope") + } + + func test428IsDashboardPasswordRequired() { + XCTAssertTrue(APIError(status: 428, body: Data()).isDashboardPasswordRequired) + XCTAssertFalse(APIError(status: 401, body: Data()).isDashboardPasswordRequired) + } + + func testJoinPathOntoBase() { + let c = APIClient(baseURL: URL(string: "http://127.0.0.1:8787")!) + XCTAssertEqual(c.url("/api/health").absoluteString, "http://127.0.0.1:8787/api/health") + } + + func test401IsUnauthorized() { + XCTAssertTrue(APIError(status: 401, body: Data()).isUnauthorized) + XCTAssertFalse(APIError(status: 403, body: Data()).isUnauthorized) + } +} diff --git a/macos/AntaresKit/Tests/AntaresKitTests/AntaresKitTests.swift b/macos/AntaresKit/Tests/AntaresKitTests/AntaresKitTests.swift new file mode 100644 index 00000000..3b36bc32 --- /dev/null +++ b/macos/AntaresKit/Tests/AntaresKitTests/AntaresKitTests.swift @@ -0,0 +1,19 @@ +import XCTest +@testable import AntaresKit + +final class AntaresKitTests: XCTestCase { + func testDefaultBaseURLIsLoopback8787() { + XCTAssertEqual(AntaresKit.defaultBaseURL.absoluteString, "http://127.0.0.1:8787") + } + + func testResolveBaseURLUsesANTARES_URL() { + XCTAssertEqual( + AntaresKit.resolveBaseURL(environment: ["ANTARES_URL": "http://127.0.0.1:9999"]).absoluteString, + "http://127.0.0.1:9999" + ) + } + + func testResolveBaseURLFallsBackToDefault() { + XCTAssertEqual(AntaresKit.resolveBaseURL(environment: [:]), AntaresKit.defaultBaseURL) + } +} diff --git a/macos/AntaresKit/Tests/AntaresKitTests/AuthSetupModelsTests.swift b/macos/AntaresKit/Tests/AntaresKitTests/AuthSetupModelsTests.swift new file mode 100644 index 00000000..d6993079 --- /dev/null +++ b/macos/AntaresKit/Tests/AntaresKitTests/AuthSetupModelsTests.swift @@ -0,0 +1,83 @@ +import XCTest +@testable import AntaresKit + +final class AuthSetupModelsTests: XCTestCase { + func testDecodesAuthStatusFixture() throws { + let data = #"{"password_required":true,"authenticated":false}"#.data(using: .utf8)! + + let status = try JSONDecoder().decode(AuthStatus.self, from: data) + + XCTAssertTrue(status.passwordRequired) + XCTAssertFalse(status.authenticated) + } + + func testDecodesSetupStatusWithProvidersFixture() throws { + let data = #""" + { + "needs_setup": true, + "model": "", + "provider": "", + "workspace": "/Users/example/antares", + "home": "/Users/example", + "config_path": "/Users/example/.antares/config.json", + "database": "sqlite", + "providers": [ + { + "id": "openrouter", + "label": "OpenRouter", + "kind": "openai-compatible", + "hint": "One key, most models", + "key_hint": "sk-or-v1-...", + "key_url": "https://openrouter.ai/keys", + "base_url": "https://openrouter.ai/api/v1", + "local": false, + "models": ["anthropic/claude-sonnet-4.5"], + "has_key": false + } + ] + } + """#.data(using: .utf8)! + + let status = try JSONDecoder().decode(SetupStatus.self, from: data) + + XCTAssertTrue(status.needsSetup) + XCTAssertEqual(status.workspace, "/Users/example/antares") + XCTAssertEqual(status.database, "sqlite") + XCTAssertEqual(status.providers.first?.id, "openrouter") + XCTAssertEqual(status.providers.first?.keyHint, "sk-or-v1-...") + XCTAssertEqual(status.providers.first?.models, ["anthropic/claude-sonnet-4.5"]) + XCTAssertFalse(status.providers.first?.hasKey ?? true) + } + + func testLoginRequestEncodesPasswordKey() throws { + let data = try JSONEncoder().encode(LoginRequest(password: "secret")) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + XCTAssertEqual(object["password"] as? String, "secret") + XCTAssertNil(object["apiKey"]) + } + + func testCompleteSetupRequestEncodesHeadersWhenPresent() throws { + let request = CompleteSetupRequest( + provider: "custom", + name: "Local Gateway", + baseURL: "http://127.0.0.1:8080/v1", + apiKey: "", + headers: ["X-API-Key": "secret"], + model: "local-model", + workspace: "/tmp/antares", + database: SetupDatabaseRequest(), + rag: SetupRAGRequest() + ) + + let data = try JSONEncoder().encode(request) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let headers = try XCTUnwrap(object["headers"] as? [String: String]) + + XCTAssertEqual(headers["X-API-Key"], "secret") + XCTAssertEqual(object["base_url"] as? String, "http://127.0.0.1:8080/v1") + XCTAssertEqual(object["api_key"] as? String, "") + XCTAssertNil(object["baseURL"]) + XCTAssertNil(object["apiKey"]) + } +} diff --git a/macos/AntaresKit/Tests/AntaresKitTests/ChatEventsTests.swift b/macos/AntaresKit/Tests/AntaresKitTests/ChatEventsTests.swift new file mode 100644 index 00000000..e0a5baa7 --- /dev/null +++ b/macos/AntaresKit/Tests/AntaresKitTests/ChatEventsTests.swift @@ -0,0 +1,116 @@ +import XCTest +@testable import AntaresKit + +final class ChatEventsTests: XCTestCase { + func testTextDeltasAppendAndDoneMarksComplete() { + var s = ChatTranscript() + s.apply(["type": "session", "id": "s1", "title": "T"]) + s.apply(["type": "text", "delta": "Hel"]) + s.apply(["type": "text", "delta": "lo"]) + s.apply(["type": "done"]) + XCTAssertEqual(s.sessionID, "s1") + XCTAssertEqual(s.title, "T") + XCTAssertEqual(s.assistantText, "Hello") + XCTAssertTrue(s.done) + } + + func testReasoningDeltasAppend() { + var s = ChatTranscript() + s.apply(["type": "reasoning", "delta": "think"]) + s.apply(["type": "reasoning", "delta": "ing"]) + XCTAssertEqual(s.reasoning, "thinking") + } + + func testToolCallRecordsRunningAndResultCompletes() { + var s = ChatTranscript() + s.apply(["type": "tool_call", "id": "t1", "name": "read", "arguments": "{}"]) + XCTAssertEqual(s.toolCalls.count, 1) + XCTAssertEqual(s.toolCalls[0].id, "t1") + XCTAssertEqual(s.toolCalls[0].name, "read") + XCTAssertEqual(s.toolCalls[0].args, "{}") + XCTAssertTrue(s.toolCalls[0].running) + + s.apply(["type": "tool_result", "id": "t1", "content": "ok", "is_error": "false"]) + XCTAssertEqual(s.toolCalls[0].result, "ok") + XCTAssertFalse(s.toolCalls[0].isError) + XCTAssertFalse(s.toolCalls[0].running) + } + + func testToolResultRecordsError() { + var s = ChatTranscript() + s.apply(["type": "tool_call", "id": "t1", "name": "read", "arguments": "{}"]) + s.apply(["type": "tool_result", "id": "t1", "content": "nope", "is_error": "true"]) + XCTAssertEqual(s.toolCalls[0].result, "nope") + XCTAssertTrue(s.toolCalls[0].isError) + XCTAssertFalse(s.toolCalls[0].running) + } + + func testToolProgressChunkAppendsMessageReplaces() { + var s = ChatTranscript() + s.apply(["type": "tool_call", "id": "t1", "name": "run", "arguments": ""]) + s.apply(["type": "tool_progress", "id": "t1", "chunk": "a"]) + s.apply(["type": "tool_progress", "id": "t1", "chunk": "b"]) + XCTAssertEqual(s.toolCalls[0].progress, "ab") + s.apply(["type": "tool_progress", "id": "t1", "message": "done"]) + XCTAssertEqual(s.toolCalls[0].progress, "done") + } + + func testErrorSetsErrorString() { + var s = ChatTranscript() + s.apply(["type": "error", "error": "boom"]) + XCTAssertEqual(s.error, "boom") + } + + func testResetClearsPartialState() { + var s = ChatTranscript() + s.apply(["type": "text", "delta": "partial"]) + s.apply(["type": "reasoning", "delta": "hmm"]) + s.apply(["type": "tool_call", "id": "t1", "name": "read", "arguments": "{}"]) + s.apply(["type": "error", "error": "fail"]) + s.apply(["type": "reset"]) + XCTAssertEqual(s.assistantText, "") + XCTAssertEqual(s.reasoning, "") + XCTAssertTrue(s.toolCalls.isEmpty) + XCTAssertNil(s.error) + } + + func testUnknownTypesAreIgnored() { + var s = ChatTranscript() + s.apply(["type": "unknown_type", "foo": "bar"]) + s.apply([:]) + XCTAssertEqual(s.assistantText, "") + XCTAssertFalse(s.done) + } + + func testHasDisplayableContentIncludesReasoningOnly() { + let empty = ChatTranscript() + XCTAssertFalse(empty.hasDisplayableContent) + + var reasoningOnly = ChatTranscript() + reasoningOnly.apply(["type": "reasoning", "delta": "hmm"]) + XCTAssertTrue(reasoningOnly.hasDisplayableContent) + } + + func testUsageParsesTokenFields() { + var s = ChatTranscript() + s.apply([ + "type": "usage", + "input_tokens": "100", + "output_tokens": "50", + "context_tokens": "512", + "context_window": "8000", + ]) + XCTAssertEqual(s.usage?.inputTokens, 100) + XCTAssertEqual(s.usage?.outputTokens, 50) + XCTAssertEqual(s.usage?.contextTokens, 512) + XCTAssertEqual(s.usage?.contextWindow, 8000) + } + + func testNoticeAndAsk() { + var s = ChatTranscript() + s.apply(["type": "notice", "message": " hello "]) + XCTAssertEqual(s.notice, "hello") + s.apply(["type": "ask", "id": "ask_1"]) + XCTAssertEqual(s.askID, "ask_1") + } +} diff --git a/macos/AntaresKit/Tests/AntaresKitTests/ChatRequestTests.swift b/macos/AntaresKit/Tests/AntaresKitTests/ChatRequestTests.swift new file mode 100644 index 00000000..461b6f50 --- /dev/null +++ b/macos/AntaresKit/Tests/AntaresKitTests/ChatRequestTests.swift @@ -0,0 +1,156 @@ +import XCTest +@testable import AntaresKit + +final class ChatRequestTests: XCTestCase { + func testInterruptBodyEncodesSessionID() throws { + let body = ChatInterruptRequest(sessionID: "session-123") + + let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as? [String: String] + + XCTAssertEqual(json, ["session_id": "session-123"]) + } + + func testEditBodyMatchesSessionEditHandler() throws { + let body = ChatEditRequest(messageID: "msg-456", revert: true) + + let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as? [String: Any] + + XCTAssertEqual(json?["message_id"] as? String, "msg-456") + XCTAssertEqual(json?["revert"] as? Bool, true) + XCTAssertEqual(json?.count, 2) + } + + func testSendBodyEncodesSessionIDAndMessage() throws { + let body = ChatSendRequest(sessionID: "sess_1", message: "hello") + + let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as? [String: Any] + + XCTAssertEqual(json?["session_id"] as? String, "sess_1") + XCTAssertEqual(json?["message"] as? String, "hello") + XCTAssertNil(json?["platform"]) + } + + func testHydrateSkipsHiddenMergesToolResultsAndSurfacesAssistantError() throws { + let data = #""" + { + "session": { + "id": "s1", + "title": "Hello", + "platform": "web", + "channel_id": "", + "user_id": "", + "model": "gpt-4", + "provider": "openai", + "workspace": "", + "message_count": 4, + "tokens_in": 10, + "tokens_out": 20, + "cost": 0.01, + "archived": false, + "pinned": false, + "created_at": "2026-09-18T00:00:00Z", + "updated_at": "2026-09-18T00:00:01Z", + "meta": {"project_dir": "/tmp/proj", "index_rag": true} + }, + "messages": [ + { + "id": "m-hidden", + "session_id": "s1", + "seq": 1, + "role": "user", + "content": "secret", + "created_at": "2026-09-18T00:00:00Z", + "tokens_in": 0, + "tokens_out": 0, + "hidden": true, + "compacted": false, + "meta": null + }, + { + "id": "m-user", + "session_id": "s1", + "seq": 2, + "role": "user", + "content": "hello", + "created_at": "2026-09-18T00:00:01Z", + "tokens_in": 0, + "tokens_out": 0, + "hidden": false, + "compacted": false, + "meta": null + }, + { + "id": "m-asst", + "session_id": "s1", + "seq": 3, + "role": "assistant", + "content": "world", + "reasoning": "think", + "tool_calls": "[{\"id\":\"t1\",\"name\":\"read\",\"arguments\":\"{}\"}]", + "created_at": "2026-09-18T00:00:02Z", + "tokens_in": 5, + "tokens_out": 3, + "hidden": false, + "compacted": false, + "meta": {} + }, + { + "id": "m-tool", + "session_id": "s1", + "seq": 4, + "role": "tool", + "content": "file contents", + "tool_call_id": "t1", + "tool_name": "read", + "created_at": "2026-09-18T00:00:03Z", + "tokens_in": 0, + "tokens_out": 0, + "hidden": false, + "compacted": false, + "meta": {"is_error": true} + }, + { + "id": "m-err", + "session_id": "s1", + "seq": 5, + "role": "assistant", + "content": "{\"error\":\"boom\"}", + "created_at": "2026-09-18T00:00:04Z", + "tokens_in": 0, + "tokens_out": 0, + "hidden": false, + "compacted": false, + "meta": {"is_error": true} + } + ] + } + """#.data(using: .utf8)! + + let detail = try JSONDecoder().decode(SessionDetail.self, from: data) + let rows = hydrateChat(detail) + + XCTAssertEqual(detail.session.id, "s1") + XCTAssertEqual(detail.session.title, "Hello") + XCTAssertEqual(detail.session.meta?["project_dir"]?.stringValue, "/tmp/proj") + XCTAssertEqual(detail.session.meta?["index_rag"]?.boolValue, true) + + XCTAssertEqual(rows.map(\.id), ["m-user", "m-asst", "m-err"]) + XCTAssertEqual(rows[0].role, "user") + XCTAssertEqual(rows[0].content, "hello") + + XCTAssertEqual(rows[1].role, "assistant") + XCTAssertEqual(rows[1].content, "world") + XCTAssertEqual(rows[1].reasoning, "think") + XCTAssertEqual(rows[1].toolCalls.count, 1) + XCTAssertEqual(rows[1].toolCalls[0].id, "t1") + XCTAssertEqual(rows[1].toolCalls[0].name, "read") + XCTAssertEqual(rows[1].toolCalls[0].args, "{}") + XCTAssertEqual(rows[1].toolCalls[0].result, "file contents") + XCTAssertTrue(rows[1].toolCalls[0].isError) + XCTAssertFalse(rows[1].toolCalls[0].running) + + XCTAssertEqual(rows[2].role, "assistant") + XCTAssertEqual(rows[2].content, "") + XCTAssertEqual(rows[2].error, "boom") + } +} diff --git a/macos/AntaresKit/Tests/AntaresKitTests/ConfigSchemaTests.swift b/macos/AntaresKit/Tests/AntaresKitTests/ConfigSchemaTests.swift new file mode 100644 index 00000000..3291e2e0 --- /dev/null +++ b/macos/AntaresKit/Tests/AntaresKitTests/ConfigSchemaTests.swift @@ -0,0 +1,172 @@ +import XCTest +@testable import AntaresKit + +final class ConfigSchemaTests: XCTestCase { + func testUnknownFieldTypeFallsBackToStringField() throws { + let data = #""" + { + "path": "display.palette", + "label": "Palette", + "group": "display", + "type": "color-palette", + "tier": "advanced", + "default": "default", + "secret": false + } + """#.data(using: .utf8)! + + let field = try JSONDecoder().decode(ConfigField.self, from: data) + + XCTAssertEqual(field.type, .string) + XCTAssertEqual(field.path, "display.palette") + XCTAssertEqual(field.label, "Palette") + XCTAssertEqual(field.group, "display") + } + + func testDecodesKnownFieldTypesWithoutCrashing() throws { + let data = #""" + [ + { + "path": "model.default", + "label": "Default", + "group": "model", + "type": "string", + "tier": "essential", + "default": "gpt-4o", + "secret": false, + "help": "Model id", + "reload": "restart_required" + }, + { + "path": "server.port", + "label": "Port", + "group": "server", + "type": "number", + "tier": "essential", + "default": 8787, + "secret": false + }, + { + "path": "rag.enabled", + "label": "Enabled", + "group": "rag", + "type": "boolean", + "tier": "essential", + "default": true, + "secret": false + }, + { + "path": "tools.toolset", + "label": "Toolset", + "group": "tools", + "type": "string[]", + "tier": "common", + "default": ["web", "files"], + "secret": false, + "enum": ["web", "files", "shell"] + }, + { + "path": "providers", + "label": "Providers", + "group": "providers", + "type": "object", + "tier": "advanced", + "default": {}, + "secret": true + } + ] + """#.data(using: .utf8)! + + let fields = try JSONDecoder().decode([ConfigField].self, from: data) + + XCTAssertEqual(fields.map(\.type), [.string, .number, .boolean, .stringArray, .object]) + XCTAssertEqual(fields[0].help, "Model id") + XCTAssertEqual(fields[0].reload, "restart_required") + XCTAssertEqual(fields[1].defaultValue?.numberValue, 8787) + XCTAssertEqual(fields[2].defaultValue?.boolValue, true) + XCTAssertEqual(fields[3].enumValues, ["web", "files", "shell"]) + XCTAssertTrue(fields[4].secret) + } + + func testDecodesConfigResponseValuesAndSchemaTogether() throws { + let data = #""" + { + "values": { + "model": { "default": "gpt-4o", "provider": "openai" }, + "server": { "port": 8787 } + }, + "schema": [ + { + "path": "model.default", + "label": "Default", + "group": "model", + "type": "string", + "tier": "essential", + "default": "", + "secret": false + }, + { + "path": "weird.flag", + "label": "Flag", + "group": "weird", + "type": "mystery", + "tier": "advanced", + "default": "nope", + "secret": false + } + ], + "restart_fields": ["server.port"], + "profile": "default", + "path": "/tmp/config.yaml" + } + """#.data(using: .utf8)! + + let response = try JSONDecoder().decode(ConfigResponse.self, from: data) + + XCTAssertEqual(response.schema.count, 2) + XCTAssertEqual(response.schema[1].type, .string) + XCTAssertEqual(response.restartFields, ["server.port"]) + XCTAssertEqual(response.profile, "default") + XCTAssertEqual(response.path, "/tmp/config.yaml") + XCTAssertEqual(response.value(at: "model.default")?.stringValue, "gpt-4o") + XCTAssertEqual(response.value(at: "server.port")?.numberValue, 8787) + XCTAssertNil(response.value(at: "missing.key")) + } + + func testUpdateRequestEncodesDottedPathUpdates() throws { + let request = ConfigUpdateRequest(updates: [ + "model.default": .string("claude"), + "server.port": .int(9090), + "rag.enabled": .bool(true), + "tools.toolset": .strings(["web", "files"]), + ]) + + let data = try JSONEncoder().encode(request) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let updates = try XCTUnwrap(object["updates"] as? [String: Any]) + + XCTAssertEqual(updates["model.default"] as? String, "claude") + XCTAssertEqual(updates["server.port"] as? Int, 9090) + XCTAssertEqual(updates["rag.enabled"] as? Bool, true) + XCTAssertEqual(updates["tools.toolset"] as? [String], ["web", "files"]) + } + + func testRawConfigBodiesMatchGoHandlers() throws { + let raw = try JSONDecoder().decode( + ConfigRawResponse.self, + from: #"{"yaml":"model:\n default: gpt-4o\n","path":"/tmp/config.yaml"}"#.data(using: .utf8)! + ) + XCTAssertTrue(raw.yaml.contains("gpt-4o")) + XCTAssertEqual(raw.path, "/tmp/config.yaml") + + let save = try JSONEncoder().encode(ConfigRawRequest(yaml: "model: {}\n")) + let saveObject = try XCTUnwrap(JSONSerialization.jsonObject(with: save) as? [String: Any]) + XCTAssertEqual(saveObject["yaml"] as? String, "model: {}\n") + + let saved = try JSONDecoder().decode( + ConfigRawSaveResponse.self, + from: #"{"saved":true}"#.data(using: .utf8)! + ) + XCTAssertEqual(saved.saved, true) + } +} diff --git a/macos/AntaresKit/Tests/AntaresKitTests/ControlModelsTests.swift b/macos/AntaresKit/Tests/AntaresKitTests/ControlModelsTests.swift new file mode 100644 index 00000000..bde8ed4b --- /dev/null +++ b/macos/AntaresKit/Tests/AntaresKitTests/ControlModelsTests.swift @@ -0,0 +1,445 @@ +import XCTest +@testable import AntaresKit + +final class ControlModelsTests: XCTestCase { + func testDecodesRolesListFixtureFromHandler() throws { + let data = #""" + { + "roles": [ + { + "name": "assistant", + "title": "Orchestrator", + "summary": "Default agent", + "category": "general", + "toolset": "", + "model": "", + "source": "builtin" + }, + { + "name": "security", + "title": "Security", + "summary": "Authorized testing", + "category": "security", + "toolset": "web", + "model": "gpt-4o", + "danger": true, + "subrole": false, + "parent": "", + "effort": "high", + "max_turns": 12, + "tags": ["sec"], + "source": "local" + } + ], + "scope": ["web"], + "active": [ + { + "id": "sub-1", + "role": "reviewer", + "task": "Review the diff", + "parent": "sess_1", + "started_at": "2026-09-18T00:00:00Z" + } + ], + "performance": [ + { + "role": "reviewer", + "missions": 4, + "successes": 3, + "failures": 1, + "kept": 2, + "total_turns": 10, + "score": 75.5, + "updated_at": "2026-09-18T00:00:01Z" + } + ], + "toolsets": ["coding", "web"], + "categories": ["general", "engineering", "research", "writing", "security"] + } + """#.data(using: .utf8)! + + let response = try JSONDecoder().decode(RolesResponse.self, from: data) + + XCTAssertEqual(response.roles.count, 2) + XCTAssertEqual(response.roles[0].name, "assistant") + XCTAssertEqual(response.roles[0].title, "Orchestrator") + XCTAssertEqual(response.roles[0].source, "builtin") + XCTAssertFalse(response.roles[0].isDanger) + XCTAssertFalse(response.roles[0].isSubrole) + XCTAssertEqual(response.roles[1].name, "security") + XCTAssertTrue(response.roles[1].isDanger) + XCTAssertEqual(response.roles[1].effort, "high") + XCTAssertEqual(response.roles[1].maxTurns, 12) + XCTAssertEqual(response.scope, ["web"]) + XCTAssertEqual(response.active?.count, 1) + XCTAssertEqual(response.active?[0].id, "sub-1") + XCTAssertEqual(response.active?[0].role, "reviewer") + XCTAssertEqual(response.active?[0].startedAt, "2026-09-18T00:00:00Z") + XCTAssertEqual(response.performance?.count, 1) + XCTAssertEqual(response.performance?[0].role, "reviewer") + XCTAssertEqual(response.performance?[0].score, 75.5) + XCTAssertEqual(response.toolsets, ["coding", "web"]) + XCTAssertEqual(response.categories?.count, 5) + } + + func testDecodesSessionRoleFixture() throws { + let data = #"{"role":"reviewer"}"#.data(using: .utf8)! + + let response = try JSONDecoder().decode(SessionRoleResponse.self, from: data) + + XCTAssertEqual(response.role, "reviewer") + } + + func testDecodesSoulFixtureFromHandler() throws { + let data = #"{"soul":"I am Antares.","unset":false}"#.data(using: .utf8)! + + let response = try JSONDecoder().decode(SoulResponse.self, from: data) + + XCTAssertEqual(response.soul, "I am Antares.") + XCTAssertFalse(response.unset) + } + + func testEncodesSoulBodyMatchingHandler() throws { + let body = SoulRequest(soul: "Be concise.") + + let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as? [String: String] + + XCTAssertEqual(json, ["soul": "Be concise."]) + } + + func testDecodesSoulSaveFixture() throws { + let data = #"{"ok":true,"unset":true}"#.data(using: .utf8)! + + let response = try JSONDecoder().decode(SoulSaveResponse.self, from: data) + + XCTAssertTrue(response.ok) + XCTAssertTrue(response.unset) + } + + func testDecodesSkillsListFixtureFromHandler() throws { + let data = #""" + { + "skills": [ + { + "name": "deploy", + "description": "Ship the app", + "path": "/tmp/deploy.md", + "enabled": true, + "source": "local", + "tags": ["ops"], + "triggers": ["deploy"], + "updated_at": "2026-09-18T00:00:00Z", + "usage_count": 3 + } + ], + "library": 12 + } + """#.data(using: .utf8)! + + let response = try JSONDecoder().decode(SkillsResponse.self, from: data) + + XCTAssertEqual(response.library, 12) + XCTAssertEqual(response.skills.count, 1) + XCTAssertEqual(response.skills[0].name, "deploy") + XCTAssertEqual(response.skills[0].description, "Ship the app") + XCTAssertTrue(response.skills[0].enabled) + XCTAssertEqual(response.skills[0].source, "local") + XCTAssertEqual(response.skills[0].usageCount, 3) + XCTAssertEqual(response.skills[0].updatedAt, "2026-09-18T00:00:00Z") + } + + func testEncodesToggleSkillBody() throws { + let body = ToggleToolRequest(name: "deploy", enabled: false) + + let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as? [String: Any] + + XCTAssertEqual(json?["name"] as? String, "deploy") + XCTAssertEqual(json?["enabled"] as? Bool, false) + XCTAssertEqual(json?.count, 2) + } + + func testDecodesHubSkillsFixtureFromHandler() throws { + let data = #""" + { + "skills": [ + { + "id": "github:acme/deploy", + "kind": "skill", + "name": "deploy", + "summary": "Ship it", + "source": "github", + "tags": ["ops"], + "homepage": "https://example.com", + "author": "acme", + "installed": false + } + ] + } + """#.data(using: .utf8)! + + let response = try JSONDecoder().decode(HubSkillsResponse.self, from: data) + + XCTAssertEqual(response.skills.count, 1) + XCTAssertEqual(response.skills[0].id, "github:acme/deploy") + XCTAssertEqual(response.skills[0].name, "deploy") + XCTAssertEqual(response.skills[0].summary, "Ship it") + XCTAssertEqual(response.skills[0].source, "github") + XCTAssertFalse(response.skills[0].installed) + XCTAssertNil(response.error) + } + + func testEncodesHubInstallBody() throws { + let body = HubInstallRequest(id: "github:acme/deploy") + + let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as? [String: String] + + XCTAssertEqual(json, ["id": "github:acme/deploy"]) + } + + func testDecodesHubInstallResultFixture() throws { + let ok = #"{"ok":true,"name":"deploy","path":"/tmp/deploy.md","summary":"Ship it"}"#.data(using: .utf8)! + let failed = #"{"ok":false,"error":"not found"}"#.data(using: .utf8)! + + let success = try JSONDecoder().decode(HubInstallResponse.self, from: ok) + let failure = try JSONDecoder().decode(HubInstallResponse.self, from: failed) + + XCTAssertTrue(success.ok) + XCTAssertEqual(success.name, "deploy") + XCTAssertEqual(success.path, "/tmp/deploy.md") + XCTAssertFalse(failure.ok) + XCTAssertEqual(failure.error, "not found") + } + + func testDecodesCronJobsFixtureFromHandler() throws { + let data = #""" + { + "jobs": [ + { + "id": "job_1", + "name": "Morning brief", + "schedule": "0 8 * * *", + "prompt": "Summarize overnight mail", + "enabled": true, + "target": "", + "timezone": "", + "last_run": "2026-09-18T01:00:00Z", + "next_run": null, + "last_state": "ok", + "created_at": "2026-09-17T00:00:00Z", + "updated_at": "2026-09-18T01:00:00Z", + "meta": {} + } + ] + } + """#.data(using: .utf8)! + + let response = try JSONDecoder().decode(CronJobsResponse.self, from: data) + + XCTAssertEqual(response.jobs.count, 1) + XCTAssertEqual(response.jobs[0].id, "job_1") + XCTAssertEqual(response.jobs[0].name, "Morning brief") + XCTAssertEqual(response.jobs[0].schedule, "0 8 * * *") + XCTAssertEqual(response.jobs[0].prompt, "Summarize overnight mail") + XCTAssertTrue(response.jobs[0].enabled) + XCTAssertEqual(response.jobs[0].lastRun, "2026-09-18T01:00:00Z") + XCTAssertNil(response.jobs[0].nextRun) + XCTAssertEqual(response.jobs[0].lastState, "ok") + } + + func testDecodesNullCronJobsAsEmptyList() throws { + let data = #"{"jobs":null}"#.data(using: .utf8)! + + let response = try JSONDecoder().decode(CronJobsResponse.self, from: data) + + XCTAssertEqual(response.jobs.count, 0) + } + + func testEncodesCreateCronBodyMatchingHandler() throws { + let body = CreateCronRequest(name: "Morning brief", schedule: "0 8 * * *", prompt: "Summarize") + + let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as? [String: String] + + XCTAssertEqual(json, [ + "name": "Morning brief", + "schedule": "0 8 * * *", + "prompt": "Summarize", + ]) + } + + func testEncodesToggleCronBody() throws { + let body = ToggleEnabledRequest(enabled: false) + + let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as? [String: Bool] + + XCTAssertEqual(json, ["enabled": false]) + } + + func testDecodesCronStartedFixture() throws { + let data = #"{"started":true}"#.data(using: .utf8)! + + let response = try JSONDecoder().decode(StartedFlagResponse.self, from: data) + + XCTAssertTrue(response.started) + } + + func testDecodesChannelsAndPairingsFixtureFromHandler() throws { + let data = #""" + { + "channels": [ + { + "id": "telegram", + "label": "Telegram", + "enabled": true, + "connected": false, + "configured": true, + "has_token": true, + "bot_name": "AntaresBot", + "detail": "Telegram bot — long polling", + "docs": "https://t.me/BotFather", + "fields": [ + { + "key": "bot_token", + "label": "Bot token", + "secret": true, + "set": true + } + ] + } + ], + "pairings": [ + { + "id": "pair_1", + "platform": "telegram", + "external_id": "123", + "display_name": "Ada", + "status": "pending", + "code": "ABCD", + "created_at": "2026-09-18T00:00:00Z", + "updated_at": "2026-09-18T00:00:01Z" + } + ] + } + """#.data(using: .utf8)! + + let response = try JSONDecoder().decode(ChannelsResponse.self, from: data) + + XCTAssertEqual(response.channels.count, 1) + XCTAssertEqual(response.channels[0].id, "telegram") + XCTAssertEqual(response.channels[0].label, "Telegram") + XCTAssertTrue(response.channels[0].enabled) + XCTAssertFalse(response.channels[0].connected) + XCTAssertTrue(response.channels[0].configured) + XCTAssertEqual(response.channels[0].botName, "AntaresBot") + XCTAssertEqual(response.channels[0].fields.count, 1) + XCTAssertEqual(response.channels[0].fields[0].key, "bot_token") + XCTAssertTrue(response.channels[0].fields[0].secret) + XCTAssertTrue(response.channels[0].fields[0].set) + XCTAssertEqual(response.pairings.count, 1) + XCTAssertEqual(response.pairings[0].id, "pair_1") + XCTAssertEqual(response.pairings[0].platform, "telegram") + XCTAssertEqual(response.pairings[0].displayName, "Ada") + XCTAssertEqual(response.pairings[0].status, "pending") + XCTAssertEqual(response.pairings[0].externalID, "123") + } + + func testEncodesChannelTokenBody() throws { + let body = ChannelTokenRequest(token: "123:abc") + + let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as? [String: String] + + XCTAssertEqual(json, ["token": "123:abc"]) + } + + func testDecodesChannelTokenResultFixture() throws { + let ok = #"{"ok":true,"restart_required":true}"#.data(using: .utf8)! + let failed = #"{"ok":false,"error":"invalid token"}"#.data(using: .utf8)! + + let success = try JSONDecoder().decode(ChannelMutationResponse.self, from: ok) + let failure = try JSONDecoder().decode(ChannelMutationResponse.self, from: failed) + + XCTAssertTrue(success.ok) + XCTAssertEqual(success.restartRequired, true) + XCTAssertFalse(failure.ok) + XCTAssertEqual(failure.error, "invalid token") + } + + func testEncodesPairingIDBody() throws { + let body = PairingIDRequest(id: "pair_1") + + let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as? [String: String] + + XCTAssertEqual(json, ["id": "pair_1"]) + } + + func testEncodesChannelConfigBody() throws { + let body = ChannelConfigRequest(fields: ["bot_token": "123:abc"], enabled: true) + + let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as? [String: Any] + + XCTAssertEqual((json?["fields"] as? [String: String])?["bot_token"], "123:abc") + XCTAssertEqual(json?["enabled"] as? Bool, true) + } + + func testSignalConfigPayloadPostsApiURLAndNumber() throws { + let payload = ChannelConfigForm.collectedFields([ + "api_url": " http://localhost:8080 ", + "number": "+15551234567", + ]) + + XCTAssertEqual(payload, [ + "api_url": "http://localhost:8080", + "number": "+15551234567", + ]) + } + + func testBlankConfigValuesAreOmittedSoExistingSecretsAreKept() { + let payload = ChannelConfigForm.collectedFields([ + "app_token": "xapp-1", + "bot_token": " ", + ]) + + XCTAssertEqual(payload, ["app_token": "xapp-1"]) + } + + func testWhatsAppRequiredFieldsIgnoreListenAddrAndPath() { + let fields = [ + ChannelField(key: "token", label: "Graph API token", secret: true, set: false), + ChannelField(key: "phone_number_id", label: "Phone number id", secret: false, set: false), + ChannelField(key: "verify_token", label: "Webhook verify token", secret: true, set: false), + ChannelField(key: "listen_addr", label: "Listen address", secret: false, placeholder: ":8090", set: false), + ChannelField(key: "path", label: "Webhook path", secret: false, placeholder: "/webhook", set: false), + ] + + XCTAssertTrue(ChannelConfigForm.hasMissingRequired(fields, values: [:])) + XCTAssertTrue(ChannelConfigForm.hasMissingRequired(fields, values: [ + "token": "t", + "listen_addr": ":8090", + ])) + XCTAssertFalse(ChannelConfigForm.hasMissingRequired(fields, values: [ + "token": "t", + "phone_number_id": "1", + "verify_token": "v", + ])) + } + + func testAlreadySetRequiredFieldIsNotMissingWhenLeftBlank() { + let fields = [ + ChannelField(key: "homeserver", label: "Homeserver URL", secret: false, set: true), + ChannelField(key: "user_id", label: "Bot user id", secret: false, placeholder: "@bot:matrix.org", set: false), + ChannelField(key: "access_token", label: "Access token", secret: true, set: true), + ] + + XCTAssertFalse(ChannelConfigForm.hasMissingRequired(fields, values: [:])) + } + + func testSlackConfigPayloadPostsBothTokens() { + let payload = ChannelConfigForm.collectedFields([ + "app_token": "xapp-1", + "bot_token": "xoxb-2", + ]) + + XCTAssertEqual(payload["app_token"], "xapp-1") + XCTAssertEqual(payload["bot_token"], "xoxb-2") + XCTAssertEqual(payload.count, 2) + } +} diff --git a/macos/AntaresKit/Tests/AntaresKitTests/CreatorSocialModelsTests.swift b/macos/AntaresKit/Tests/AntaresKitTests/CreatorSocialModelsTests.swift new file mode 100644 index 00000000..8ebc9fdc --- /dev/null +++ b/macos/AntaresKit/Tests/AntaresKitTests/CreatorSocialModelsTests.swift @@ -0,0 +1,455 @@ +import XCTest +@testable import AntaresKit + +final class CreatorSocialModelsTests: XCTestCase { + func testDecodesCreatorProjectsListTreatingNullAsEmpty() throws { + let empty = try JSONDecoder().decode( + CreatorProjectsResponse.self, + from: #"{"projects":null}"#.data(using: .utf8)! + ) + let data = #""" + { + "projects": [ + { + "id": "video_1", + "title": "Launch clip", + "brief": "Show the dashboard", + "platform": "youtube", + "account_id": "soc_1", + "style": "clean", + "size": "720x1280", + "target_seconds": 24, + "caption": "Ship it", + "revision": 2, + "publish_mode": "draft", + "status": "draft", + "error": "", + "created_at": "2026-09-18T00:00:00Z", + "updated_at": "2026-09-18T00:00:01Z", + "references": null, + "shots": [], + "research": [], + "ideas": [], + "final_path": "", + "publication": { + "status": "not_started", + "post_url": "", + "error": "", + "account_id": "", + "artifact_hash": "" + } + } + ] + } + """#.data(using: .utf8)! + + let list = try JSONDecoder().decode(CreatorProjectsResponse.self, from: data) + + XCTAssertEqual(empty.projects.count, 0) + XCTAssertEqual(list.projects.count, 1) + XCTAssertEqual(list.projects[0].id, "video_1") + XCTAssertEqual(list.projects[0].title, "Launch clip") + XCTAssertEqual(list.projects[0].brief, "Show the dashboard") + XCTAssertEqual(list.projects[0].platform, "youtube") + XCTAssertEqual(list.projects[0].accountID, "soc_1") + XCTAssertEqual(list.projects[0].targetSeconds, 24) + XCTAssertEqual(list.projects[0].revision, 2) + XCTAssertEqual(list.projects[0].publishMode, "draft") + XCTAssertEqual(list.projects[0].references.count, 0) + XCTAssertEqual(list.projects[0].publication.status, "not_started") + } + + func testDecodesCreatorProjectNestedMediaAndRunStatus() throws { + let data = #""" + { + "id": "video_2", + "title": "Trend recap", + "brief": "What shipped this week", + "platform": "tiktok", + "account_id": "", + "style": "bold", + "size": "720x1280", + "target_seconds": 16, + "caption": "", + "revision": 1, + "publish_mode": "auto", + "status": "producing", + "error": "", + "created_at": "2026-09-18T00:00:00Z", + "updated_at": "2026-09-18T00:01:00Z", + "run_session_id": "ses_creator_abc", + "run_stage": "produce", + "run_status": "running", + "last_run_error": "", + "references": [ + { + "id": "ref_hero", + "kind": "character", + "name": "Hero", + "prompt": "A founder at a desk", + "path": "refs/hero.png", + "status": "ready", + "error": "" + } + ], + "shots": [ + { + "id": "shot_1", + "prompt": "Wide of the desk", + "reference_ids": ["ref_hero"], + "duration_seconds": 8, + "continuity": "cut", + "notes": "", + "keyframe_path": "shots/1.png", + "video_job_id": "job_9", + "video_path": "", + "last_frame_path": "", + "status": "rendering", + "error": "", + "progress": 40 + } + ], + "research": [ + { + "url": "https://example.test/trend", + "title": "Short form recaps", + "platform": "tiktok", + "observed_at": "2026-09-18T00:00:00Z", + "notes": "High completion", + "metrics": {"views": 1200} + } + ], + "ideas": [ + { + "id": "idea_1", + "title": "Desk to dashboard", + "hook": "One screen, every agent", + "rationale": "Matches the brief", + "selected": true + } + ], + "final_path": "", + "publication": { + "status": "blocked", + "post_url": "", + "error": "login required", + "account_id": "soc_1", + "artifact_hash": "abc" + } + } + """#.data(using: .utf8)! + + let project = try JSONDecoder().decode(CreatorProject.self, from: data) + + XCTAssertEqual(project.id, "video_2") + XCTAssertEqual(project.runSessionID, "ses_creator_abc") + XCTAssertEqual(project.runStage, "produce") + XCTAssertEqual(project.runStatus, "running") + XCTAssertEqual(project.references[0].id, "ref_hero") + XCTAssertEqual(project.references[0].kind, "character") + XCTAssertEqual(project.references[0].path, "refs/hero.png") + XCTAssertEqual(project.shots[0].id, "shot_1") + XCTAssertEqual(project.shots[0].referenceIDs, ["ref_hero"]) + XCTAssertEqual(project.shots[0].durationSeconds, 8) + XCTAssertEqual(project.shots[0].progress, 40) + XCTAssertEqual(project.research[0].title, "Short form recaps") + XCTAssertEqual(project.research[0].metrics?["views"], 1200) + XCTAssertEqual(project.ideas[0].id, "idea_1") + XCTAssertTrue(project.ideas[0].selected) + XCTAssertEqual(project.publication.status, "blocked") + XCTAssertEqual(project.publication.error, "login required") + } + + func testEncodesCreatorCreateAndUpdateBodiesMatchingHandlers() throws { + let create = CreatorProjectDraft( + title: "Launch clip", + brief: "Show the dashboard", + platform: "youtube", + size: "720x1280", + targetSeconds: 24, + style: "clean", + publishMode: "draft" + ) + var project = try JSONDecoder().decode( + CreatorProject.self, + from: #""" + { + "id": "video_1", + "title": "Launch clip", + "brief": "Show the dashboard", + "platform": "youtube", + "account_id": "soc_1", + "style": "clean", + "size": "720x1280", + "target_seconds": 24, + "caption": "Ship it", + "revision": 2, + "publish_mode": "draft", + "status": "draft", + "error": "", + "created_at": "2026-09-18T00:00:00Z", + "updated_at": "2026-09-18T00:00:01Z", + "references": [], + "shots": [], + "research": [], + "ideas": [], + "final_path": "", + "publication": { + "status": "not_started", + "post_url": "", + "error": "", + "account_id": "", + "artifact_hash": "" + } + } + """#.data(using: .utf8)! + ) + project.caption = "Updated caption" + + let createJSON = try JSONSerialization.jsonObject(with: JSONEncoder().encode(create)) as? [String: Any] + let updateJSON = try JSONSerialization.jsonObject(with: JSONEncoder().encode(project)) as? [String: Any] + + XCTAssertEqual(createJSON?["title"] as? String, "Launch clip") + XCTAssertEqual(createJSON?["brief"] as? String, "Show the dashboard") + XCTAssertEqual(createJSON?["platform"] as? String, "youtube") + XCTAssertEqual(createJSON?["size"] as? String, "720x1280") + XCTAssertEqual(createJSON?["target_seconds"] as? Int, 24) + XCTAssertEqual(createJSON?["style"] as? String, "clean") + XCTAssertEqual(createJSON?["publish_mode"] as? String, "draft") + XCTAssertEqual(updateJSON?["id"] as? String, "video_1") + XCTAssertEqual(updateJSON?["revision"] as? Int, 2) + XCTAssertEqual(updateJSON?["account_id"] as? String, "soc_1") + XCTAssertEqual(updateJSON?["caption"] as? String, "Updated caption") + XCTAssertEqual(updateJSON?["target_seconds"] as? Int, 24) + } + + func testEncodesCreatorActionRunAndReconcileBodies() throws { + let action = CreatorActionRequest(action: "generate_video", targetID: "shot_1") + let reset = CreatorActionRequest(action: "reset_shot", targetID: "shot_1", confirmed: true) + let run = CreatorRunRequest(stage: "produce", publishMode: "draft") + let reconcile = CreatorReconcileRequest(proof: "Checked the account", notPublished: true) + + let actionJSON = try JSONSerialization.jsonObject(with: JSONEncoder().encode(action)) as? [String: Any] + let resetJSON = try JSONSerialization.jsonObject(with: JSONEncoder().encode(reset)) as? [String: Any] + let runJSON = try JSONSerialization.jsonObject(with: JSONEncoder().encode(run)) as? [String: String] + let reconcileJSON = try JSONSerialization.jsonObject(with: JSONEncoder().encode(reconcile)) as? [String: Any] + + XCTAssertEqual(actionJSON?["action"] as? String, "generate_video") + XCTAssertEqual(actionJSON?["target_id"] as? String, "shot_1") + XCTAssertNil(actionJSON?["confirmed"]) + XCTAssertEqual(resetJSON?["action"] as? String, "reset_shot") + XCTAssertEqual(resetJSON?["confirmed"] as? Bool, true) + XCTAssertEqual(runJSON, ["stage": "produce", "publish_mode": "draft"]) + XCTAssertEqual(reconcileJSON?["proof"] as? String, "Checked the account") + XCTAssertEqual(reconcileJSON?["not_published"] as? Bool, true) + } + + func testDecodesCreatorRunAndSettingsFixtures() throws { + let runData = #"{"session_id":"ses_creator_abc"}"#.data(using: .utf8)! + let settingsData = #""" + { + "image": { + "enabled": true, + "provider": "openai", + "model": "gpt-image-1", + "base_url": "https://api.openai.com/v1", + "size": "1024x1024", + "has_key": true + }, + "video": { + "enabled": false, + "provider": "", + "model": "sora-2", + "base_url": "", + "size": "720x1280", + "seconds": 8, + "has_key": false + }, + "ffmpeg": true, + "ffprobe": false + } + """#.data(using: .utf8)! + + let run = try JSONDecoder().decode(CreatorRunResponse.self, from: runData) + let settings = try JSONDecoder().decode(CreatorSettings.self, from: settingsData) + let patch = CreatorSettingsPatch( + image: CreatorProviderPatch(enabled: true, model: "gpt-image-1", apiKey: "sk-test"), + video: CreatorProviderPatch(seconds: 8) + ) + let patchJSON = try JSONSerialization.jsonObject(with: JSONEncoder().encode(patch)) as? [String: Any] + let image = patchJSON?["image"] as? [String: Any] + + XCTAssertEqual(run.sessionID, "ses_creator_abc") + XCTAssertTrue(settings.image.enabled) + XCTAssertEqual(settings.image.provider, "openai") + XCTAssertEqual(settings.image.model, "gpt-image-1") + XCTAssertTrue(settings.image.hasKey) + XCTAssertFalse(settings.video.enabled) + XCTAssertEqual(settings.video.seconds, 8) + XCTAssertTrue(settings.ffmpeg) + XCTAssertFalse(settings.ffprobe) + XCTAssertEqual(image?["enabled"] as? Bool, true) + XCTAssertEqual(image?["model"] as? String, "gpt-image-1") + XCTAssertEqual(image?["api_key"] as? String, "sk-test") + XCTAssertEqual((patchJSON?["video"] as? [String: Any])?["seconds"] as? Int, 8) + } + + func testEncodesCreatorCronJobBodyMatchingHandler() throws { + let body = CreatorCronJobRequest( + name: "Morning research", + schedule: "0 8 * * *", + role: "content-creator", + contentProjectID: "video_1", + contentStage: "research", + publishMode: "draft" + ) + + let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as? [String: String] + + XCTAssertEqual(json?["name"], "Morning research") + XCTAssertEqual(json?["schedule"], "0 8 * * *") + XCTAssertEqual(json?["role"], "content-creator") + XCTAssertEqual(json?["content_project_id"], "video_1") + XCTAssertEqual(json?["content_stage"], "research") + XCTAssertEqual(json?["publish_mode"], "draft") + XCTAssertNil(json?["prompt"]) + } + + func testDecodesSocialStatusAndAccountsFixtures() throws { + let statusData = #""" + { + "enabled": true, + "encryption_ready": true, + "imap_configured": true, + "imap_host": "imap.gmail.com", + "imap_port": 993, + "imap_username": "ops@example.test", + "browser": {"enabled": true, "state": "running", "error": ""}, + "autopilot_enabled": false, + "accounts": [ + { + "id": "soc_1", + "platform": "instagram", + "display_name": "Ops", + "username": "antares", + "profile_url": "https://instagram.com/antares", + "status": "connected", + "rag_namespace": "social/instagram", + "skill_name": "social-instagram", + "created_at": "2026-09-18T00:00:00Z", + "updated_at": "2026-09-18T00:00:01Z", + "has_password": true, + "has_recovery": false + } + ] + } + """#.data(using: .utf8)! + let emptyAccounts = try JSONDecoder().decode( + SocialStatus.self, + from: #""" + { + "enabled": false, + "encryption_ready": false, + "imap_configured": false, + "imap_host": "", + "imap_port": 0, + "imap_username": "", + "browser": {"enabled": false, "state": "disabled", "error": ""}, + "autopilot_enabled": false, + "accounts": null + } + """#.data(using: .utf8)! + ) + let listData = #""" + [ + { + "id": "soc_2", + "platform": "youtube", + "display_name": "", + "username": "studio", + "profile_url": "", + "status": "connected", + "rag_namespace": "social/youtube", + "skill_name": "social-youtube", + "created_at": "2026-09-18T00:00:00Z", + "updated_at": "2026-09-18T00:00:00Z", + "has_password": false, + "has_recovery": true + } + ] + """#.data(using: .utf8)! + + let status = try JSONDecoder().decode(SocialStatus.self, from: statusData) + let accounts = try JSONDecoder().decode([SocialAccount].self, from: listData) + + XCTAssertTrue(status.enabled) + XCTAssertTrue(status.encryptionReady) + XCTAssertTrue(status.imapConfigured) + XCTAssertEqual(status.imapHost, "imap.gmail.com") + XCTAssertEqual(status.imapPort, 993) + XCTAssertEqual(status.imapUsername, "ops@example.test") + XCTAssertEqual(status.browser.state, "running") + XCTAssertFalse(status.autopilotEnabled) + XCTAssertEqual(status.accounts.count, 1) + XCTAssertEqual(status.accounts[0].id, "soc_1") + XCTAssertEqual(status.accounts[0].platform, "instagram") + XCTAssertEqual(status.accounts[0].displayName, "Ops") + XCTAssertTrue(status.accounts[0].hasPassword) + XCTAssertEqual(emptyAccounts.accounts.count, 0) + XCTAssertEqual(accounts.count, 1) + XCTAssertEqual(accounts[0].id, "soc_2") + XCTAssertEqual(accounts[0].username, "studio") + XCTAssertTrue(accounts[0].hasRecovery) + } + + func testEncodesSocialRequestBodiesMatchingHandlers() throws { + let imap = SocialIMAPRequest( + host: "imap.gmail.com", + port: 993, + username: "ops@example.test", + password: "app-pass" + ) + let account = SocialAccountRequest( + platform: "instagram", + username: "antares", + password: "secret", + recoveryCodes: "abc", + profileURL: "https://instagram.com/antares", + status: "connected" + ) + let autopilot = SocialAutopilotRequest(enabled: true) + + let imapJSON = try JSONSerialization.jsonObject(with: JSONEncoder().encode(imap)) as? [String: Any] + let accountJSON = try JSONSerialization.jsonObject(with: JSONEncoder().encode(account)) as? [String: String] + let autopilotJSON = try JSONSerialization.jsonObject(with: JSONEncoder().encode(autopilot)) as? [String: Bool] + + XCTAssertEqual(imapJSON?["host"] as? String, "imap.gmail.com") + XCTAssertEqual(imapJSON?["port"] as? Int, 993) + XCTAssertEqual(imapJSON?["username"] as? String, "ops@example.test") + XCTAssertEqual(imapJSON?["password"] as? String, "app-pass") + XCTAssertEqual(accountJSON?["platform"], "instagram") + XCTAssertEqual(accountJSON?["username"], "antares") + XCTAssertEqual(accountJSON?["password"], "secret") + XCTAssertEqual(accountJSON?["recovery_codes"], "abc") + XCTAssertEqual(accountJSON?["profile_url"], "https://instagram.com/antares") + XCTAssertEqual(accountJSON?["status"], "connected") + XCTAssertEqual(autopilotJSON, ["enabled": true]) + } + + func testDecodesSocialEncryptionAndIMAPTestFixtures() throws { + let keyData = #"{"ok":true,"recovery_key":"abc123","message":"Save this recovery key"}"#.data(using: .utf8)! + let okData = #"{"ok":true,"inbox_count":4}"#.data(using: .utf8)! + let failData = #"{"ok":false,"error":"auth failed"}"#.data(using: .utf8)! + + let key = try JSONDecoder().decode(SocialEncryptionSetupResponse.self, from: keyData) + let ok = try JSONDecoder().decode(SocialIMAPTestResponse.self, from: okData) + let fail = try JSONDecoder().decode(SocialIMAPTestResponse.self, from: failData) + + XCTAssertTrue(key.ok) + XCTAssertEqual(key.recoveryKey, "abc123") + XCTAssertTrue(ok.ok) + XCTAssertEqual(ok.inboxCount, 4) + XCTAssertFalse(fail.ok) + XCTAssertEqual(fail.error, "auth failed") + } +} diff --git a/macos/AntaresKit/Tests/AntaresKitTests/DashboardModelsTests.swift b/macos/AntaresKit/Tests/AntaresKitTests/DashboardModelsTests.swift new file mode 100644 index 00000000..14047a4f --- /dev/null +++ b/macos/AntaresKit/Tests/AntaresKitTests/DashboardModelsTests.swift @@ -0,0 +1,297 @@ +import XCTest +@testable import AntaresKit + +final class DashboardModelsTests: XCTestCase { + func testDecodesSessionListFixtureFromListHandler() throws { + let data = #""" + { + "sessions": [ + { + "id": "sess_1", + "title": "Hello", + "platform": "web", + "channel_id": "", + "user_id": "", + "model": "gpt-4", + "provider": "openai", + "workspace": "/tmp/proj", + "message_count": 4, + "tokens_in": 10, + "tokens_out": 20, + "cost": 0.01, + "archived": false, + "pinned": false, + "created_at": "2026-09-18T00:00:00Z", + "updated_at": "2026-09-18T00:00:01Z", + "meta": {"project_dir": "/tmp/proj"} + } + ], + "total": 1 + } + """#.data(using: .utf8)! + + let list = try JSONDecoder().decode(SessionListResponse.self, from: data) + + XCTAssertEqual(list.total, 1) + XCTAssertEqual(list.sessions.count, 1) + XCTAssertEqual(list.sessions[0].id, "sess_1") + XCTAssertEqual(list.sessions[0].title, "Hello") + XCTAssertEqual(list.sessions[0].platform, "web") + XCTAssertEqual(list.sessions[0].model, "gpt-4") + XCTAssertEqual(list.sessions[0].messageCount, 4) + XCTAssertEqual(list.sessions[0].tokensIn, 10) + XCTAssertEqual(list.sessions[0].updatedAt, "2026-09-18T00:00:01Z") + XCTAssertEqual(list.sessions[0].meta?["project_dir"]?.stringValue, "/tmp/proj") + } + + func testDecodesSessionSearchHitsFixture() throws { + let data = #""" + { + "hits": [ + { + "session_id": "sess_1", + "session_title": "Hello", + "message_id": "msg_9", + "role": "user", + "snippet": "deploy the app", + "created_at": "2026-09-18T00:00:02Z", + "score": 1.5 + } + ] + } + """#.data(using: .utf8)! + + let search = try JSONDecoder().decode(SessionSearchResponse.self, from: data) + + XCTAssertEqual(search.hits.count, 1) + XCTAssertEqual(search.hits[0].sessionID, "sess_1") + XCTAssertEqual(search.hits[0].sessionTitle, "Hello") + XCTAssertEqual(search.hits[0].messageID, "msg_9") + XCTAssertEqual(search.hits[0].role, "user") + XCTAssertEqual(search.hits[0].snippet, "deploy the app") + XCTAssertEqual(search.hits[0].createdAt, "2026-09-18T00:00:02Z") + XCTAssertEqual(search.hits[0].score, 1.5) + } + + func testDecodesDeletedFlagFixture() throws { + let data = #"{"deleted":true}"#.data(using: .utf8)! + + let response = try JSONDecoder().decode(DeletedFlagResponse.self, from: data) + + XCTAssertTrue(response.deleted) + } + + func testDecodesModelOptionsFixtureFromHandler() throws { + let data = #""" + { + "active": {"model": "gpt-4o", "provider": "openai"}, + "providers": [ + { + "id": "openai", + "label": "OpenAI", + "kind": "openai", + "enabled": true, + "has_key": true, + "local": false, + "base_url": "https://api.openai.com/v1", + "active": true, + "hint": "Official API", + "key_hint": "sk-...", + "key_url": "https://platform.openai.com/api-keys", + "timeout_seconds": 60 + }, + { + "id": "ollama", + "label": "Ollama", + "kind": "openai-compatible", + "enabled": true, + "has_key": false, + "local": true, + "base_url": "http://127.0.0.1:11434/v1", + "active": false, + "custom": false + } + ] + } + """#.data(using: .utf8)! + + let options = try JSONDecoder().decode(ModelOptionsResponse.self, from: data) + + XCTAssertEqual(options.active.model, "gpt-4o") + XCTAssertEqual(options.active.provider, "openai") + XCTAssertEqual(options.providers.count, 2) + XCTAssertEqual(options.providers[0].id, "openai") + XCTAssertTrue(options.providers[0].hasKey) + XCTAssertTrue(options.providers[0].active) + XCTAssertEqual(options.providers[0].baseURL, "https://api.openai.com/v1") + XCTAssertEqual(options.providers[0].timeoutSeconds, 60) + XCTAssertTrue(options.providers[1].local) + XCTAssertFalse(options.providers[1].hasKey) + } + + func testDecodesModelListAllFixtureFromHandler() throws { + let data = #""" + { + "active": {"model": "gpt-4o", "provider": "openai"}, + "models": [ + { + "id": "gpt-4o", + "name": "GPT-4o", + "provider": "openai", + "provider_label": "OpenAI", + "context_window": 128000, + "max_output": 16384, + "input_cost": 2.5, + "output_cost": 10, + "vision": true, + "tools": true, + "reasoning": false + } + ], + "errors": [ + { + "provider": "ollama", + "label": "Ollama", + "error": "connection refused" + } + ] + } + """#.data(using: .utf8)! + + let list = try JSONDecoder().decode(ModelListAllResponse.self, from: data) + + XCTAssertEqual(list.active.model, "gpt-4o") + XCTAssertEqual(list.models.count, 1) + XCTAssertEqual(list.models[0].id, "gpt-4o") + XCTAssertEqual(list.models[0].name, "GPT-4o") + XCTAssertEqual(list.models[0].provider, "openai") + XCTAssertEqual(list.models[0].providerLabel, "OpenAI") + XCTAssertEqual(list.models[0].contextWindow, 128000) + XCTAssertTrue(list.models[0].vision) + XCTAssertEqual(list.errors.count, 1) + XCTAssertEqual(list.errors[0].provider, "ollama") + XCTAssertEqual(list.errors[0].error, "connection refused") + } + + func testEncodesModelSetBodyWithSnakeCaseProvider() throws { + let body = ModelSetRequest(model: "gpt-4o", provider: "openai") + + let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as? [String: String] + + XCTAssertEqual(json, ["model": "gpt-4o", "provider": "openai"]) + } + + func testDecodesModelSetResponseFixture() throws { + let data = #"{"model":"gpt-4o","provider":"openai"}"#.data(using: .utf8)! + + let response = try JSONDecoder().decode(ModelSetResponse.self, from: data) + + XCTAssertEqual(response.model, "gpt-4o") + XCTAssertEqual(response.provider, "openai") + } + + func testDecodesToolsFixtureFromListHandler() throws { + let data = #""" + { + "toolset": "coding", + "toolsets": ["coding", "web"], + "tools": [ + { + "name": "read", + "description": "Read a file", + "enabled": true, + "requires_approval": false, + "toolsets": ["coding"] + }, + { + "name": "bash", + "description": "Run a shell command", + "enabled": false, + "requires_approval": true, + "toolsets": ["coding", "web"] + } + ] + } + """#.data(using: .utf8)! + + let response = try JSONDecoder().decode(ToolsResponse.self, from: data) + + XCTAssertEqual(response.toolset, "coding") + XCTAssertEqual(response.toolsets, ["coding", "web"]) + XCTAssertEqual(response.tools.count, 2) + XCTAssertEqual(response.tools[0].name, "read") + XCTAssertTrue(response.tools[0].enabled) + XCTAssertFalse(response.tools[0].requiresApproval) + XCTAssertEqual(response.tools[1].name, "bash") + XCTAssertTrue(response.tools[1].requiresApproval) + XCTAssertEqual(response.tools[1].toolsets, ["coding", "web"]) + } + + func testEncodesToggleToolBody() throws { + let body = ToggleToolRequest(name: "bash", enabled: false) + + let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as? [String: Any] + + XCTAssertEqual(json?["name"] as? String, "bash") + XCTAssertEqual(json?["enabled"] as? Bool, false) + XCTAssertEqual(json?.count, 2) + } + + func testDecodesOkResponseFixture() throws { + let data = #"{"ok":true}"#.data(using: .utf8)! + + let response = try JSONDecoder().decode(OKResponse.self, from: data) + + XCTAssertTrue(response.ok) + } + + func testDecodesMemoryListFixtureFromHandler() throws { + let data = #""" + { + "memories": [ + { + "id": "mem_1", + "scope": "global", + "scope_key": "", + "key": "prefs", + "content": "Prefers concise answers", + "tags": "[]", + "source": "dashboard", + "pinned": true, + "created_at": "2026-09-18T00:00:00Z", + "updated_at": "2026-09-18T00:00:01Z" + } + ] + } + """#.data(using: .utf8)! + + let list = try JSONDecoder().decode(MemoryListResponse.self, from: data) + + XCTAssertEqual(list.memories.count, 1) + XCTAssertEqual(list.memories[0].id, "mem_1") + XCTAssertEqual(list.memories[0].scope, "global") + XCTAssertEqual(list.memories[0].scopeKey, "") + XCTAssertEqual(list.memories[0].key, "prefs") + XCTAssertEqual(list.memories[0].content, "Prefers concise answers") + XCTAssertEqual(list.memories[0].source, "dashboard") + XCTAssertEqual(list.memories[0].pinned, true) + XCTAssertEqual(list.memories[0].updatedAt, "2026-09-18T00:00:01Z") + } + + func testEncodesPutMemoryBodyMatchingHandler() throws { + let body = PutMemoryRequest( + id: "", + key: "prefs", + content: "Prefers concise answers", + scope: "global" + ) + + let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as? [String: Any] + + XCTAssertEqual(json?["id"] as? String, "") + XCTAssertEqual(json?["key"] as? String, "prefs") + XCTAssertEqual(json?["content"] as? String, "Prefers concise answers") + XCTAssertEqual(json?["scope"] as? String, "global") + XCTAssertNil(json?["pinned"]) + } +} diff --git a/macos/AntaresKit/Tests/AntaresKitTests/IntegrationModelsTests.swift b/macos/AntaresKit/Tests/AntaresKitTests/IntegrationModelsTests.swift new file mode 100644 index 00000000..7e61b240 --- /dev/null +++ b/macos/AntaresKit/Tests/AntaresKitTests/IntegrationModelsTests.swift @@ -0,0 +1,496 @@ +import XCTest +@testable import AntaresKit + +final class IntegrationModelsTests: XCTestCase { + func testDecodesInterceptStatusFixtureFromHandler() throws { + let data = #"{"running":true,"addr":"127.0.0.1:8899","exchanges":3,"rules":1}"#.data(using: .utf8)! + + let status = try JSONDecoder().decode(InterceptStatus.self, from: data) + + XCTAssertTrue(status.running) + XCTAssertEqual(status.addr, "127.0.0.1:8899") + XCTAssertEqual(status.exchanges, 3) + XCTAssertEqual(status.rules, 1) + } + + func testEncodesInterceptStartBodyMatchingHandler() throws { + let body = InterceptStartRequest(port: 8899) + + let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as? [String: Int] + + XCTAssertEqual(json, ["port": 8899]) + } + + func testDecodesInterceptExchangesTreatingNullAsEmpty() throws { + let empty = try JSONDecoder().decode( + InterceptExchangesResponse.self, + from: #"{"exchanges":null}"#.data(using: .utf8)! + ) + let data = #""" + { + "exchanges": [ + { + "id": 12, + "time": "2026-09-18T00:00:00Z", + "method": "GET", + "url": "https://example.test/login", + "host": "example.test", + "secure": true, + "req_headers": {"Accept": ["application/json"]}, + "req_body": "", + "status": 200, + "resp_headers": {"Content-Type": ["application/json"]}, + "resp_body": "{\"ok\":true}", + "duration_ms": 42, + "mocked": false, + "blocked": false + } + ] + } + """#.data(using: .utf8)! + + let list = try JSONDecoder().decode(InterceptExchangesResponse.self, from: data) + + XCTAssertEqual(empty.exchanges.count, 0) + XCTAssertEqual(list.exchanges.count, 1) + XCTAssertEqual(list.exchanges[0].id, 12) + XCTAssertEqual(list.exchanges[0].method, "GET") + XCTAssertEqual(list.exchanges[0].url, "https://example.test/login") + XCTAssertEqual(list.exchanges[0].status, 200) + XCTAssertEqual(list.exchanges[0].durationMS, 42) + XCTAssertEqual(list.exchanges[0].reqHeaders["Accept"], ["application/json"]) + XCTAssertEqual(list.exchanges[0].respBody, "{\"ok\":true}") + XCTAssertTrue(list.exchanges[0].secure) + XCTAssertFalse(list.exchanges[0].mocked) + XCTAssertFalse(list.exchanges[0].blocked) + } + + func testDecodesInterceptRulesAndEncodesAddBody() throws { + let data = #""" + { + "rules": [ + { + "id": 3, + "match": "/admin", + "block": true, + "mock_status": 403, + "mock_body": "nope", + "breakpoint": false + } + ] + } + """#.data(using: .utf8)! + let body = InterceptRuleRequest(match: "/admin", block: true, mockStatus: 403, mockBody: "nope") + + let list = try JSONDecoder().decode(InterceptRulesResponse.self, from: data) + let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as? [String: Any] + + XCTAssertEqual(list.rules.count, 1) + XCTAssertEqual(list.rules[0].id, 3) + XCTAssertEqual(list.rules[0].match, "/admin") + XCTAssertTrue(list.rules[0].block) + XCTAssertEqual(list.rules[0].mockStatus, 403) + XCTAssertEqual(list.rules[0].mockBody, "nope") + XCTAssertEqual(json?["match"] as? String, "/admin") + XCTAssertEqual(json?["block"] as? Bool, true) + XCTAssertEqual(json?["mock_status"] as? Int, 403) + XCTAssertEqual(json?["mock_body"] as? String, "nope") + } + + func testDecodesNullInterceptRulesAsEmptyList() throws { + let response = try JSONDecoder().decode( + InterceptRulesResponse.self, + from: #"{"rules":null}"#.data(using: .utf8)! + ) + + XCTAssertEqual(response.rules.count, 0) + } + + func testDecodesInterceptorsAndActivateSessionEnv() throws { + let data = #""" + { + "interceptors": [ + { + "id": "terminal", + "label": "Terminal", + "category": "system", + "available": true + }, + { + "id": "chrome", + "label": "Chrome", + "category": "browser", + "available": false, + "reason": "not installed" + } + ], + "sessions": [ + { + "id": "sess_term", + "interceptor": "terminal", + "info": {"env": "export HTTP_PROXY=http://127.0.0.1:8899"} + } + ] + } + """#.data(using: .utf8)! + let activate = InterceptActivateRequest(interceptor: "terminal") + + let response = try JSONDecoder().decode(InterceptorsResponse.self, from: data) + let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(activate)) as? [String: String] + + XCTAssertEqual(response.interceptors.count, 2) + XCTAssertEqual(response.interceptors[0].id, "terminal") + XCTAssertTrue(response.interceptors[0].available) + XCTAssertEqual(response.interceptors[1].reason, "not installed") + XCTAssertEqual(response.sessions.count, 1) + XCTAssertEqual(response.sessions[0].id, "sess_term") + XCTAssertEqual(response.sessions[0].envText, "export HTTP_PROXY=http://127.0.0.1:8899") + XCTAssertEqual(json, ["interceptor": "terminal"]) + } + + func testDecodesPausedBreakpointsFromHandlerAndSSEPayload() throws { + let data = #""" + { + "paused": [ + { + "id": 7, + "method": "POST", + "url": "https://example.test/login", + "headers": {"content-type": "application/json"}, + "body": "{\"user\":\"ada\"}" + } + ] + } + """#.data(using: .utf8)! + let compact = #"{"paused":[{"id":7,"method":"POST","url":"https://example.test/login","headers":{"content-type":"application/json"},"body":"{\"user\":\"ada\"}"}]}"# + let raw = "data: \(compact)\n\n" + + let list = try JSONDecoder().decode(InterceptPausedList.self, from: data) + var snapshots: [InterceptPausedList] = [] + try SSEParser.parseJSON(Data(raw.utf8), as: InterceptPausedList.self) { event in + snapshots.append(event) + } + + XCTAssertEqual(list.paused.count, 1) + XCTAssertEqual(list.paused[0].id, 7) + XCTAssertEqual(list.paused[0].method, "POST") + XCTAssertEqual(list.paused[0].url, "https://example.test/login") + XCTAssertEqual(list.paused[0].headers["content-type"], "application/json") + XCTAssertEqual(list.paused[0].body, "{\"user\":\"ada\"}") + XCTAssertEqual(snapshots.count, 1) + XCTAssertEqual(snapshots[0].paused[0].id, 7) + } + + func testDecodesNullPausedBreakpointsAsEmptyList() throws { + let list = try JSONDecoder().decode( + InterceptPausedList.self, + from: #"{"paused":null}"#.data(using: .utf8)! + ) + + XCTAssertEqual(list.paused.count, 0) + } + + func testDecodesMCPStatusTreatingNullToolsAsEmpty() throws { + let data = #""" + { + "enabled": true, + "servers": [ + { + "name": "filesystem", + "started": true, + "connected": true, + "tools": [ + {"name": "read_file", "description": "Read a file"} + ] + }, + { + "name": "broken", + "started": false, + "connected": false, + "error": "spawn failed", + "tools": null + } + ] + } + """#.data(using: .utf8)! + + let response = try JSONDecoder().decode(MCPStatusResponse.self, from: data) + + XCTAssertTrue(response.enabled) + XCTAssertEqual(response.servers.count, 2) + XCTAssertEqual(response.servers[0].name, "filesystem") + XCTAssertTrue(response.servers[0].connected) + XCTAssertEqual(response.servers[0].tools.count, 1) + XCTAssertEqual(response.servers[0].tools[0].name, "read_file") + XCTAssertEqual(response.servers[1].error, "spawn failed") + XCTAssertEqual(response.servers[1].tools.count, 0) + } + + func testDecodesNullMCPServersAsEmptyList() throws { + let response = try JSONDecoder().decode( + MCPStatusResponse.self, + from: #"{"enabled":false,"servers":null}"#.data(using: .utf8)! + ) + + XCTAssertFalse(response.enabled) + XCTAssertEqual(response.servers.count, 0) + } + + func testEncodesAddMCPStdioAndHTTPBodies() throws { + let stdio = AddMCPServerRequest( + name: "filesystem", + transport: "stdio", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem"], + env: ["FOO": "bar"], + url: nil + ) + let http = AddMCPServerRequest( + name: "remote", + transport: "http", + command: nil, + args: nil, + env: nil, + url: "https://example.test/mcp" + ) + + let stdioJSON = try JSONSerialization.jsonObject(with: JSONEncoder().encode(stdio)) as? [String: Any] + let httpJSON = try JSONSerialization.jsonObject(with: JSONEncoder().encode(http)) as? [String: Any] + + XCTAssertEqual(stdioJSON?["name"] as? String, "filesystem") + XCTAssertEqual(stdioJSON?["transport"] as? String, "stdio") + XCTAssertEqual(stdioJSON?["command"] as? String, "npx") + XCTAssertEqual(stdioJSON?["args"] as? [String], ["-y", "@modelcontextprotocol/server-filesystem"]) + XCTAssertEqual((stdioJSON?["env"] as? [String: String])?["FOO"], "bar") + XCTAssertNil(stdioJSON?["url"]) + XCTAssertEqual(httpJSON?["name"] as? String, "remote") + XCTAssertEqual(httpJSON?["transport"] as? String, "http") + XCTAssertEqual(httpJSON?["url"] as? String, "https://example.test/mcp") + XCTAssertNil(httpJSON?["command"]) + } + + func testDecodesHubMCPServersFixture() throws { + let data = #""" + { + "servers": [ + { + "id": "builtin/github", + "kind": "mcp", + "name": "github", + "summary": "GitHub tools", + "source": "builtin", + "tags": ["git"], + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "needs_keys": ["GITHUB_TOKEN"], + "setup": "export GITHUB_TOKEN", + "installed": false + } + ] + } + """#.data(using: .utf8)! + + let response = try JSONDecoder().decode(HubMCPResponse.self, from: data) + + XCTAssertEqual(response.servers.count, 1) + XCTAssertEqual(response.servers[0].id, "builtin/github") + XCTAssertEqual(response.servers[0].name, "github") + XCTAssertEqual(response.servers[0].command, "npx") + XCTAssertEqual(response.servers[0].needsKeys, ["GITHUB_TOKEN"]) + XCTAssertFalse(response.servers[0].installed) + XCTAssertNil(response.error) + } + + func testDecodesHubInstallMCPMissingKeys() throws { + let data = #"{"ok":true,"missing_keys":["GITHUB_TOKEN"]}"#.data(using: .utf8)! + + let response = try JSONDecoder().decode(HubInstallResponse.self, from: data) + + XCTAssertTrue(response.ok) + XCTAssertEqual(response.missingKeys, ["GITHUB_TOKEN"]) + } + + func testDecodesPluginsListFixtureFromHandler() throws { + let data = #""" + { + "enabled": true, + "plugins": [ + { + "name": "audit", + "description": "Log every terminal command", + "version": "1.0", + "command": "./run.sh", + "hooks": ["pre_tool_call"], + "dir": "/tmp/plugins/audit", + "enabled": true + }, + { + "name": "broken", + "description": "", + "version": "", + "command": "./missing.sh", + "dir": "/tmp/plugins/broken", + "enabled": false, + "error": "executable not found" + } + ], + "dirs": ["/tmp/plugins"] + } + """#.data(using: .utf8)! + + let response = try JSONDecoder().decode(PluginsResponse.self, from: data) + + XCTAssertTrue(response.enabled) + XCTAssertEqual(response.dirs, ["/tmp/plugins"]) + XCTAssertEqual(response.plugins.count, 2) + XCTAssertEqual(response.plugins[0].name, "audit") + XCTAssertEqual(response.plugins[0].hooks, ["pre_tool_call"]) + XCTAssertTrue(response.plugins[0].enabled) + XCTAssertEqual(response.plugins[1].error, "executable not found") + } + + func testDecodesNullPluginsAsEmptyList() throws { + let response = try JSONDecoder().decode( + PluginsResponse.self, + from: #"{"enabled":false,"plugins":null}"#.data(using: .utf8)! + ) + + XCTAssertFalse(response.enabled) + XCTAssertEqual(response.plugins.count, 0) + XCTAssertEqual(response.dirs, []) + } + + func testEncodesAddPluginAndToggleBodies() throws { + let add = AddPluginRequest( + name: "audit", + description: "Log commands", + command: "./run.sh", + args: ["--verbose"], + hooks: ["pre_tool_call", "turn_end"] + ) + let toggle = ToggleEnabledRequest(enabled: false) + + let addJSON = try JSONSerialization.jsonObject(with: JSONEncoder().encode(add)) as? [String: Any] + let toggleJSON = try JSONSerialization.jsonObject(with: JSONEncoder().encode(toggle)) as? [String: Bool] + + XCTAssertEqual(addJSON?["name"] as? String, "audit") + XCTAssertEqual(addJSON?["description"] as? String, "Log commands") + XCTAssertEqual(addJSON?["command"] as? String, "./run.sh") + XCTAssertEqual(addJSON?["args"] as? [String], ["--verbose"]) + XCTAssertEqual(addJSON?["hooks"] as? [String], ["pre_tool_call", "turn_end"]) + XCTAssertEqual(toggleJSON, ["enabled": false]) + } + + func testDecodesHubPluginsFixture() throws { + let data = #""" + { + "plugins": [ + { + "id": "builtin/audit", + "name": "audit", + "summary": "Log tool calls", + "command": "./run.sh", + "hooks": ["pre_tool_call"], + "installed": true + } + ] + } + """#.data(using: .utf8)! + + let response = try JSONDecoder().decode(HubPluginsResponse.self, from: data) + + XCTAssertEqual(response.plugins.count, 1) + XCTAssertEqual(response.plugins[0].id, "builtin/audit") + XCTAssertEqual(response.plugins[0].name, "audit") + XCTAssertEqual(response.plugins[0].command, "./run.sh") + XCTAssertEqual(response.plugins[0].hooks, ["pre_tool_call"]) + XCTAssertTrue(response.plugins[0].installed) + } + + func testDecodesProxyListFixtureFromHandler() throws { + let data = #""" + { + "entries": [ + { + "id": "px_1", + "label": "office", + "scheme": "http", + "host": "proxy.example.test", + "port": 8080, + "username": "ada", + "password": "••••••••", + "url": "http://ada:••••@proxy.example.test:8080" + } + ] + } + """#.data(using: .utf8)! + + let response = try JSONDecoder().decode(ProxyListResponse.self, from: data) + + XCTAssertEqual(response.entries.count, 1) + XCTAssertEqual(response.entries[0].id, "px_1") + XCTAssertEqual(response.entries[0].label, "office") + XCTAssertEqual(response.entries[0].scheme, "http") + XCTAssertEqual(response.entries[0].host, "proxy.example.test") + XCTAssertEqual(response.entries[0].port, 8080) + XCTAssertEqual(response.entries[0].username, "ada") + XCTAssertEqual(response.entries[0].url, "http://ada:••••@proxy.example.test:8080") + } + + func testDecodesNullProxyEntriesAsEmptyList() throws { + let response = try JSONDecoder().decode( + ProxyListResponse.self, + from: #"{"entries":null}"#.data(using: .utf8)! + ) + + XCTAssertEqual(response.entries.count, 0) + } + + func testEncodesProxyBatchAndTestBodies() throws { + let batch = ProxyBatchRequest(text: "host:8080\nsocks5://example.test:1080") + let test = ProxyTestRequest(id: "px_1") + + let batchJSON = try JSONSerialization.jsonObject(with: JSONEncoder().encode(batch)) as? [String: String] + let testJSON = try JSONSerialization.jsonObject(with: JSONEncoder().encode(test)) as? [String: String] + + XCTAssertEqual(batchJSON, ["text": "host:8080\nsocks5://example.test:1080"]) + XCTAssertEqual(testJSON, ["id": "px_1"]) + } + + func testDecodesProxyBatchAndTestResults() throws { + let batchData = #""" + { + "ok": true, + "added": 2, + "failed": [ + {"line": 3, "text": "bad", "error": "missing host"} + ] + } + """#.data(using: .utf8)! + let testOK = #"{"ok":true,"ip":"203.0.113.9"}"#.data(using: .utf8)! + let testFail = #"{"ok":false,"error":"dial timeout"}"#.data(using: .utf8)! + + let batch = try JSONDecoder().decode(ProxyBatchResponse.self, from: batchData) + let ok = try JSONDecoder().decode(ProxyTestResponse.self, from: testOK) + let fail = try JSONDecoder().decode(ProxyTestResponse.self, from: testFail) + + XCTAssertTrue(batch.ok) + XCTAssertEqual(batch.added, 2) + XCTAssertEqual(batch.failed.count, 1) + XCTAssertEqual(batch.failed[0].line, 3) + XCTAssertEqual(batch.failed[0].text, "bad") + XCTAssertTrue(ok.ok) + XCTAssertEqual(ok.ip, "203.0.113.9") + XCTAssertFalse(fail.ok) + XCTAssertEqual(fail.error, "dial timeout") + } + + func testParsesMCPEnvLinesAndRejectsInvalidKeys() { + XCTAssertEqual( + MCPEnvParser.parse("FOO=bar\n# comment\nBAZ=qux=extra"), + .ok(["FOO": "bar", "BAZ": "qux=extra"]) + ) + XCTAssertEqual(MCPEnvParser.parse("1BAD=nope"), .invalid) + XCTAssertEqual(MCPEnvParser.parse(""), .ok([:])) + } +} diff --git a/macos/AntaresKit/Tests/AntaresKitTests/OpsModelsTests.swift b/macos/AntaresKit/Tests/AntaresKitTests/OpsModelsTests.swift new file mode 100644 index 00000000..06a8e30c --- /dev/null +++ b/macos/AntaresKit/Tests/AntaresKitTests/OpsModelsTests.swift @@ -0,0 +1,401 @@ +import XCTest +@testable import AntaresKit + +final class OpsModelsTests: XCTestCase { + func testDecodesVPSHostListFixtureFromHandler() throws { + let data = #""" + { + "hosts": [ + { + "id": "vps_ab12", + "label": "edge", + "host": "203.0.113.9", + "port": 22, + "username": "root", + "auth_method": "key", + "has_password": false, + "has_key": true, + "created_at": "2026-09-18T00:00:00Z", + "updated_at": "2026-09-18T00:00:01Z" + } + ] + } + """#.data(using: .utf8)! + + let list = try JSONDecoder().decode(VPSListResponse.self, from: data) + + XCTAssertEqual(list.hosts.count, 1) + XCTAssertEqual(list.hosts[0].id, "vps_ab12") + XCTAssertEqual(list.hosts[0].label, "edge") + XCTAssertEqual(list.hosts[0].host, "203.0.113.9") + XCTAssertEqual(list.hosts[0].port, 22) + XCTAssertEqual(list.hosts[0].username, "root") + XCTAssertEqual(list.hosts[0].authMethod, "key") + XCTAssertFalse(list.hosts[0].hasPassword) + XCTAssertTrue(list.hosts[0].hasKey) + } + + func testDecodesNullVPSHostsAsEmptyList() throws { + let list = try JSONDecoder().decode( + VPSListResponse.self, + from: #"{"hosts":null}"#.data(using: .utf8)! + ) + + XCTAssertEqual(list.hosts.count, 0) + } + + func testEncodesVPSSaveBodyMatchingHandler() throws { + let body = VPSSaveRequest( + id: "vps_ab12", + label: "edge", + host: "203.0.113.9", + port: 22, + username: "root", + authMethod: "password", + password: "secret", + privateKey: "", + passphrase: "" + ) + + let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as? [String: Any] + + XCTAssertEqual(json?["id"] as? String, "vps_ab12") + XCTAssertEqual(json?["label"] as? String, "edge") + XCTAssertEqual(json?["host"] as? String, "203.0.113.9") + XCTAssertEqual(json?["port"] as? Int, 22) + XCTAssertEqual(json?["username"] as? String, "root") + XCTAssertEqual(json?["auth_method"] as? String, "password") + XCTAssertEqual(json?["password"] as? String, "secret") + XCTAssertEqual(json?["private_key"] as? String, "") + XCTAssertEqual(json?["passphrase"] as? String, "") + } + + func testDecodesVPSMetricsFixtureFromHandler() throws { + let data = #""" + { + "reachable": true, + "hostname": "edge", + "os": "Ubuntu 24.04", + "kernel": "6.8.0", + "uptime": "2 days", + "cpu_cores": 4, + "cpu_percent": 12.5, + "load1": 0.4, + "load5": 0.3, + "load15": 0.2, + "mem_total_mb": 2048, + "mem_used_mb": 512, + "mem_percent": 25.0, + "swap_total_mb": 1024, + "swap_used_mb": 0, + "swap_percent": 0, + "disk_total_gb": 40, + "disk_used_gb": 12.5, + "disk_percent": 31.2, + "processes": 120, + "top_proc": ["1 0.0 0.1 systemd"] + } + """#.data(using: .utf8)! + + let metrics = try JSONDecoder().decode(VPSMetrics.self, from: data) + + XCTAssertTrue(metrics.reachable) + XCTAssertEqual(metrics.hostname, "edge") + XCTAssertEqual(metrics.os, "Ubuntu 24.04") + XCTAssertEqual(metrics.cpuCores, 4) + XCTAssertEqual(metrics.cpuPercent, 12.5) + XCTAssertEqual(metrics.load1, 0.4) + XCTAssertEqual(metrics.memTotalMB, 2048) + XCTAssertEqual(metrics.memPercent, 25.0) + XCTAssertEqual(metrics.diskUsedGB, 12.5) + XCTAssertEqual(metrics.processes, 120) + XCTAssertEqual(metrics.topProc, ["1 0.0 0.1 systemd"]) + } + + func testDecodesUnreachableVPSMetricsWithError() throws { + let data = #"{"reachable":false,"error":"dial timeout"}"#.data(using: .utf8)! + + let metrics = try JSONDecoder().decode(VPSMetrics.self, from: data) + + XCTAssertFalse(metrics.reachable) + XCTAssertEqual(metrics.error, "dial timeout") + XCTAssertEqual(metrics.cpuCores, 0) + XCTAssertEqual(metrics.topProc, []) + } + + func testDecodesVPSProcessListFixture() throws { + let data = #""" + { + "processes": [ + {"pid": 42, "user": "root", "cpu": 12.3, "mem": 1.5, "command": "nginx"} + ] + } + """#.data(using: .utf8)! + + let list = try JSONDecoder().decode(VPSProcessList.self, from: data) + + XCTAssertNil(list.error) + XCTAssertEqual(list.processes.count, 1) + XCTAssertEqual(list.processes[0].pid, 42) + XCTAssertEqual(list.processes[0].user, "root") + XCTAssertEqual(list.processes[0].cpu, 12.3) + XCTAssertEqual(list.processes[0].mem, 1.5) + XCTAssertEqual(list.processes[0].command, "nginx") + } + + func testDecodesVPSProcessErrorAsEmptyList() throws { + let list = try JSONDecoder().decode( + VPSProcessList.self, + from: #"{"error":"ssh failed","processes":[]}"#.data(using: .utf8)! + ) + + XCTAssertEqual(list.error, "ssh failed") + XCTAssertEqual(list.processes.count, 0) + } + + func testDecodesVPSTestResponseFixture() throws { + let ok = try JSONDecoder().decode( + VPSTestResponse.self, + from: #"{"ok":true,"as":"root@edge"}"#.data(using: .utf8)! + ) + let fail = try JSONDecoder().decode( + VPSTestResponse.self, + from: #"{"ok":false,"error":"host is required"}"#.data(using: .utf8)! + ) + + XCTAssertTrue(ok.ok) + XCTAssertEqual(ok.asUser, "root@edge") + XCTAssertFalse(fail.ok) + XCTAssertEqual(fail.error, "host is required") + } + + func testDecodesAnalyticsFixtureFromHandler() throws { + let data = #""" + { + "series": [ + {"bucket": "2026-09-17", "tokens_in": 100, "tokens_out": 200, "cost": 0.0123, "calls": 4} + ], + "by_model": [ + {"model": "gpt-4o", "provider": "openai", "calls": 4, "tokens_in": 100, "tokens_out": 200, "cost": 0.0123} + ], + "totals": {"tokens_in": 100, "tokens_out": 200, "cost": 0.0123, "calls": 4} + } + """#.data(using: .utf8)! + + let analytics = try JSONDecoder().decode(AnalyticsResponse.self, from: data) + + XCTAssertEqual(analytics.series.count, 1) + XCTAssertEqual(analytics.series[0].bucket, "2026-09-17") + XCTAssertEqual(analytics.series[0].tokensIn, 100) + XCTAssertEqual(analytics.series[0].tokensOut, 200) + XCTAssertEqual(analytics.series[0].cost, 0.0123, accuracy: 0.00001) + XCTAssertEqual(analytics.series[0].calls, 4) + XCTAssertEqual(analytics.byModel.count, 1) + XCTAssertEqual(analytics.byModel[0].model, "gpt-4o") + XCTAssertEqual(analytics.byModel[0].provider, "openai") + XCTAssertEqual(analytics.totals.tokensIn, 100) + XCTAssertEqual(analytics.totals.calls, 4) + XCTAssertEqual(analytics.totals.cost, 0.0123, accuracy: 0.00001) + } + + func testDecodesNullAnalyticsSeriesAsEmpty() throws { + let data = #"{"series":null,"by_model":null,"totals":{"tokens_in":0,"tokens_out":0,"cost":0,"calls":0}}"# + .data(using: .utf8)! + + let analytics = try JSONDecoder().decode(AnalyticsResponse.self, from: data) + + XCTAssertEqual(analytics.series.count, 0) + XCTAssertEqual(analytics.byModel.count, 0) + XCTAssertEqual(analytics.totals.calls, 0) + } + + func testDecodesFileListFixtureFromHandler() throws { + let data = #""" + { + "path": "src", + "parent": ".", + "entries": [ + { + "name": "main.go", + "path": "src/main.go", + "is_dir": false, + "size": 128, + "modified": "2026-09-18T00:00:00Z" + }, + { + "name": "pkg", + "path": "src/pkg", + "is_dir": true, + "size": 64, + "modified": "2026-09-18T00:00:01Z" + } + ] + } + """#.data(using: .utf8)! + + let list = try JSONDecoder().decode(FileListResponse.self, from: data) + + XCTAssertEqual(list.path, "src") + XCTAssertEqual(list.parent, ".") + XCTAssertEqual(list.entries.count, 2) + XCTAssertEqual(list.entries[0].name, "main.go") + XCTAssertEqual(list.entries[0].path, "src/main.go") + XCTAssertFalse(list.entries[0].isDir) + XCTAssertEqual(list.entries[0].size, 128) + XCTAssertEqual(list.entries[0].modified, "2026-09-18T00:00:00Z") + XCTAssertTrue(list.entries[1].isDir) + } + + func testDecodesNullFileEntriesAsEmpty() throws { + let list = try JSONDecoder().decode( + FileListResponse.self, + from: #"{"path":".","parent":"","entries":null}"#.data(using: .utf8)! + ) + + XCTAssertEqual(list.path, ".") + XCTAssertEqual(list.parent, "") + XCTAssertEqual(list.entries.count, 0) + } + + func testDecodesFileReadTextAndBinaryFixtures() throws { + let text = try JSONDecoder().decode( + FileReadResponse.self, + from: #"{"content":"hello","size":5}"#.data(using: .utf8)! + ) + let binary = try JSONDecoder().decode( + FileReadResponse.self, + from: #"{"binary":true,"size":2048}"#.data(using: .utf8)! + ) + + XCTAssertEqual(text.content, "hello") + XCTAssertEqual(text.size, 5) + XCTAssertFalse(text.binary) + XCTAssertTrue(binary.binary) + XCTAssertEqual(binary.size, 2048) + XCTAssertEqual(binary.content, "") + } + + func testDecodesLogListFixtureFromHandler() throws { + let data = #""" + { + "entries": [ + { + "time": "2026-09-18T00:00:00Z", + "level": "INFO", + "message": "listening", + "source": "server", + "attrs": {"port": 8787, "ok": true} + } + ] + } + """#.data(using: .utf8)! + + let list = try JSONDecoder().decode(LogListResponse.self, from: data) + + XCTAssertEqual(list.entries.count, 1) + XCTAssertEqual(list.entries[0].time, "2026-09-18T00:00:00Z") + XCTAssertEqual(list.entries[0].level, "INFO") + XCTAssertEqual(list.entries[0].message, "listening") + XCTAssertEqual(list.entries[0].source, "server") + XCTAssertEqual(list.entries[0].attrs?["port"]?.stringValue, nil) + XCTAssertEqual(list.entries[0].attrs?["port"]?.numberValue, 8787) + XCTAssertEqual(list.entries[0].attrs?["ok"]?.boolValue, true) + } + + func testParsesLogStreamSSEPayloadAsLogEntry() throws { + let raw = "data: {\"time\":\"2026-09-18T00:00:02Z\",\"level\":\"WARN\",\"message\":\"slow query\",\"attrs\":{\"ms\":120}}\n\n" + var entries: [LogEntry] = [] + try SSEParser.parseJSON(Data(raw.utf8), as: LogEntry.self) { entry in + entries.append(entry) + } + + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries.first?.time, "2026-09-18T00:00:02Z") + XCTAssertEqual(entries.first?.level, "WARN") + XCTAssertEqual(entries.first?.message, "slow query") + XCTAssertEqual(entries.first?.attrs?["ms"]?.numberValue, 120) + } + + func testDecodesSystemStatsFixtureFromHandler() throws { + let data = #""" + { + "version": "0.4.0", + "go_version": "go1.25.1", + "os": "darwin", + "arch": "arm64", + "uptime_seconds": 90, + "goroutines": 42, + "memory_alloc": 1048576, + "memory_sys": 2097152, + "cpu_count": 10, + "workspace": "/tmp/ws", + "config_path": "/tmp/config.json", + "database": {"driver": "sqlite", "size_bytes": 4096}, + "store": { + "sessions": 3, + "messages": 12, + "memories": 2, + "cron_jobs": 1, + "tokens_in": 100, + "tokens_out": 200, + "cost": 0.01, + "empty_sessions": 0, + "size_bytes": 4096 + }, + "active_shells": 1, + "model": "gpt-4o", + "provider": "openai", + "rag_provider": "openai" + } + """#.data(using: .utf8)! + + let stats = try JSONDecoder().decode(SystemStats.self, from: data) + + XCTAssertEqual(stats.version, "0.4.0") + XCTAssertEqual(stats.goVersion, "go1.25.1") + XCTAssertEqual(stats.os, "darwin") + XCTAssertEqual(stats.arch, "arm64") + XCTAssertEqual(stats.uptimeSeconds, 90) + XCTAssertEqual(stats.goroutines, 42) + XCTAssertEqual(stats.memoryAlloc, 1_048_576) + XCTAssertEqual(stats.cpuCount, 10) + XCTAssertEqual(stats.workspace, "/tmp/ws") + XCTAssertEqual(stats.database.driver, "sqlite") + XCTAssertEqual(stats.database.sizeBytes, 4096) + XCTAssertEqual(stats.store.sessions, 3) + XCTAssertEqual(stats.store.messages, 12) + XCTAssertEqual(stats.store.cost, 0.01, accuracy: 0.00001) + XCTAssertEqual(stats.activeShells, 1) + XCTAssertEqual(stats.model, "gpt-4o") + XCTAssertEqual(stats.provider, "openai") + XCTAssertEqual(stats.ragProvider, "openai") + } + + func testDecodesRuntimeStatusFixtureFromHandler() throws { + let data = #""" + { + "ok": true, + "version": "0.4.0", + "model": "gpt-4o", + "provider": "openai", + "provider_ready": true, + "database": "sqlite", + "uptime_seconds": 90, + "active_sessions": 2, + "needs_setup": false + } + """#.data(using: .utf8)! + + let status = try JSONDecoder().decode(RuntimeStatus.self, from: data) + + XCTAssertTrue(status.ok) + XCTAssertEqual(status.version, "0.4.0") + XCTAssertEqual(status.model, "gpt-4o") + XCTAssertEqual(status.provider, "openai") + XCTAssertTrue(status.providerReady) + XCTAssertEqual(status.database, "sqlite") + XCTAssertEqual(status.uptimeSeconds, 90) + XCTAssertEqual(status.activeSessions, 2) + XCTAssertFalse(status.needsSetup) + } +} diff --git a/macos/AntaresKit/Tests/AntaresKitTests/RuntimeSupervisorTests.swift b/macos/AntaresKit/Tests/AntaresKitTests/RuntimeSupervisorTests.swift new file mode 100644 index 00000000..bdc0c6ff --- /dev/null +++ b/macos/AntaresKit/Tests/AntaresKitTests/RuntimeSupervisorTests.swift @@ -0,0 +1,39 @@ +import XCTest +@testable import AntaresKit + +final class RuntimeSupervisorTests: XCTestCase { + func testHealthyServerMeansAttach() { + XCTAssertEqual(RuntimePlan.decide(healthOK: true, portBusy: false), .attached) + } + + func testUnhealthyAndFreePortMeansSpawn() { + XCTAssertEqual(RuntimePlan.decide(healthOK: false, portBusy: false), .spawned) + } + + func testUnhealthyBusyPortIsError() { + XCTAssertEqual(RuntimePlan.decide(healthOK: false, portBusy: true), .conflict) + } + + func testSpawnedChildIsStoppedOnShutdownAttachedIsNot() { + XCTAssertTrue(RuntimePlan.shouldStopChild(mode: .spawned)) + XCTAssertFalse(RuntimePlan.shouldStopChild(mode: .attached)) + } + + func testHTTP200HealthWithOkFalseStillAttaches() { + XCTAssertTrue(HealthResponse.meansAntaresPresent(decodeSucceeded: true, ok: false)) + XCTAssertTrue(HealthResponse.meansAntaresPresent(decodeSucceeded: true, ok: true)) + XCTAssertFalse(HealthResponse.meansAntaresPresent(decodeSucceeded: false, ok: false)) + } + + func testExistingChildKeepsSpawnedEvenWhenHealthy() { + XCTAssertEqual( + RuntimePlan.decide(healthOK: true, portBusy: false, childExists: true), + .spawned + ) + } + + func testSupervisorUsesInjectedBaseURL() { + let url = URL(string: "http://127.0.0.1:9999")! + XCTAssertEqual(RuntimeSupervisor(baseURL: url).baseURL, url) + } +} diff --git a/macos/AntaresKit/Tests/AntaresKitTests/SSEClientTests.swift b/macos/AntaresKit/Tests/AntaresKitTests/SSEClientTests.swift new file mode 100644 index 00000000..6a9f0b18 --- /dev/null +++ b/macos/AntaresKit/Tests/AntaresKitTests/SSEClientTests.swift @@ -0,0 +1,88 @@ +import XCTest +@testable import AntaresKit + +final class SSEClientTests: XCTestCase { + func testDispatchesJSONOnBlankLineAndStripsLeadingSpace() throws { + let raw = "data: {\"delta\":\"Hel\"}\n\ndata: {\"delta\":\"lo\"}\r\n\r\n" + var events: [[String: String]] = [] + try SSEParser.parse(Data(raw.utf8)) { json in + events.append(json) + } + XCTAssertEqual(events, [["delta": "Hel"], ["delta": "lo"]]) + } + + func testIgnoresCommentsAndDoneSentinel() throws { + let raw = ": keep-alive\n\ndata: [DONE]\n\ndata: {\"ok\":true}\n\n" + var count = 0 + try SSEParser.parse(Data(raw.utf8)) { _ in count += 1 } + XCTAssertEqual(count, 1) + } + + func testDropsUnfinishedTrailingRecord() throws { + let raw = "data: {\"a\":1}\n\ndata: {\"b\":2}" + var count = 0 + try SSEParser.parse(Data(raw.utf8)) { _ in count += 1 } + XCTAssertEqual(count, 1) + } + + func testAccumulatorDispatchesRecordSplitAcrossChunks() throws { + var accumulator = SSEAccumulator() + var events: [[String: String]] = [] + + try accumulator.append(Data("data: {\"type\":\"text\"".utf8)) { event in + events.append(event) + } + try accumulator.append(Data(",\"delta\":\"x\"}\n\n".utf8)) { event in + events.append(event) + } + + XCTAssertEqual(events, [["type": "text", "delta": "x"]]) + } + + func testMalformedJSONKeepsStreamAlive() throws { + let raw = "data: not-json\n\ndata: {\"ok\":true}\n\n" + var events: [[String: String]] = [] + var errors: [String] = [] + try SSEParser.parse(Data(raw.utf8), onEvent: { json in + events.append(json) + }, onParseError: { raw, _ in + errors.append(raw) + }) + XCTAssertEqual(events, [["ok": "true"]]) + XCTAssertEqual(errors, ["not-json"]) + } + + func testStripsSingleLeadingSpaceAfterDataColon() throws { + let raw = "data: \"spaced\"\n\ndata:\"tight\"\n\n" + var events: [[String: String]] = [] + try SSEParser.parse(Data(raw.utf8)) { json in + events.append(json) + } + XCTAssertEqual(events, [["value": "spaced"], ["value": "tight"]]) + } + + func testConsumeAssemblesEventsFromAsyncByteStreamInChunks() async throws { + let raw = "data: {\"type\":\"text\",\"delta\":\"hello\"}\n\n" + let bytes = AsyncStream { continuation in + for byte in raw.utf8 { + continuation.yield(byte) + } + continuation.finish() + } + + let collected = EventCollector() + try await SSEStream.consume(bytes, chunkSize: 8) { event in + collected.append(event) + } + + XCTAssertEqual(collected.events, [["type": "text", "delta": "hello"]]) + } +} + +private final class EventCollector: @unchecked Sendable { + private(set) var events: [[String: String]] = [] + + func append(_ event: [String: String]) { + events.append(event) + } +} diff --git a/macos/AntaresKit/Tests/AntaresKitTests/WorkflowModelsTests.swift b/macos/AntaresKit/Tests/AntaresKitTests/WorkflowModelsTests.swift new file mode 100644 index 00000000..d142967e --- /dev/null +++ b/macos/AntaresKit/Tests/AntaresKitTests/WorkflowModelsTests.swift @@ -0,0 +1,219 @@ +import XCTest +@testable import AntaresKit + +final class WorkflowModelsTests: XCTestCase { + func testDecodesAutopilotCardsFixtureFromListHandler() throws { + let data = #""" + { + "cards": [ + { + "id": "ap_1", + "title": "Add board screen", + "prompt": "Implement Task 12", + "status": "pending", + "created_at": "2026-09-18T00:00:00Z", + "updated_at": "2026-09-18T00:00:01Z" + }, + { + "id": "ap_2", + "title": "Verify runner", + "prompt": "Run tests", + "status": "failed", + "result": "partial", + "verify_output": "exit 1", + "branch": "autopilot/ap_2", + "pr": "https://example.test/pr/2", + "error": "verify failed", + "created_at": "2026-09-18T00:00:02Z", + "updated_at": "2026-09-18T00:00:03Z" + } + ] + } + """#.data(using: .utf8)! + + let list = try JSONDecoder().decode(AutopilotCardsResponse.self, from: data) + + XCTAssertEqual(list.cards.count, 2) + XCTAssertEqual(list.cards[0].id, "ap_1") + XCTAssertEqual(list.cards[0].title, "Add board screen") + XCTAssertEqual(list.cards[0].prompt, "Implement Task 12") + XCTAssertEqual(list.cards[0].status, "pending") + XCTAssertEqual(list.cards[0].updatedAt, "2026-09-18T00:00:01Z") + XCTAssertEqual(list.cards[1].status, "failed") + XCTAssertEqual(list.cards[1].result, "partial") + XCTAssertEqual(list.cards[1].verifyOutput, "exit 1") + XCTAssertEqual(list.cards[1].branch, "autopilot/ap_2") + XCTAssertEqual(list.cards[1].pr, "https://example.test/pr/2") + XCTAssertEqual(list.cards[1].error, "verify failed") + } + + func testEncodesAutopilotAddBodyMatchingHandler() throws { + let body = AutopilotAddRequest(title: "Add board screen", prompt: "Implement Task 12") + + let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as? [String: String] + + XCTAssertEqual(json, ["title": "Add board screen", "prompt": "Implement Task 12"]) + } + + func testDecodesAutopilotRunResponseFixture() throws { + let data = #"{"started":2,"already_running":true}"#.data(using: .utf8)! + + let response = try JSONDecoder().decode(AutopilotRunResponse.self, from: data) + + XCTAssertEqual(response.started, 2) + XCTAssertEqual(response.alreadyRunning, true) + } + + func testDecodesEngagementSessionsFixtureFromHandler() throws { + let data = #""" + { + "sessions": [ + { + "id": "sess_1", + "title": "Web audit", + "findings": 3, + "intel": 5 + } + ] + } + """#.data(using: .utf8)! + + let list = try JSONDecoder().decode(EngagementSessionsResponse.self, from: data) + + XCTAssertEqual(list.sessions.count, 1) + XCTAssertEqual(list.sessions[0].id, "sess_1") + XCTAssertEqual(list.sessions[0].title, "Web audit") + XCTAssertEqual(list.sessions[0].findings, 3) + XCTAssertEqual(list.sessions[0].intel, 5) + } + + func testDecodesEngagementSnapshotTreatingNullFindingsAsEmpty() throws { + let data = #""" + { + "phases": [ + { + "name": "recon", + "title": "Reconnaissance", + "summary": "Mapped the surface", + "status": "complete", + "evidence": 2, + "roles": ["scout"] + } + ], + "coverage": [ + {"name": "auth", "title": "Authentication", "covered": true}, + {"name": "injection", "title": "Injection", "covered": false} + ], + "coverage_percent": 50, + "chains": [ + {"name": "Auth bypass to RCE", "impact": "Full takeover"} + ], + "findings": null, + "intel": [ + { + "id": "i1", + "type": "endpoint", + "value": "/login", + "detail": "POST", + "created_at": "2026-09-18T00:00:00Z" + } + ], + "next": "Probe authentication", + "scope": ["https://example.test"] + } + """#.data(using: .utf8)! + + let snapshot = try JSONDecoder().decode(EngagementSnapshot.self, from: data) + + XCTAssertEqual(snapshot.phases.count, 1) + XCTAssertEqual(snapshot.phases[0].name, "recon") + XCTAssertEqual(snapshot.phases[0].status, "complete") + XCTAssertEqual(snapshot.phases[0].evidence, 2) + XCTAssertEqual(snapshot.coverage.count, 2) + XCTAssertEqual(snapshot.coverage[0].name, "auth") + XCTAssertTrue(snapshot.coverage[0].covered) + XCTAssertEqual(snapshot.coveragePercent, 50) + XCTAssertEqual(snapshot.chains.count, 1) + XCTAssertEqual(snapshot.chains[0].name, "Auth bypass to RCE") + XCTAssertEqual(snapshot.findings.count, 0) + XCTAssertEqual(snapshot.intel.count, 1) + XCTAssertEqual(snapshot.intel[0].type, "endpoint") + XCTAssertEqual(snapshot.intel[0].value, "/login") + XCTAssertEqual(snapshot.next, "Probe authentication") + XCTAssertEqual(snapshot.scope, ["https://example.test"]) + } + + func testDecodesEngagementFindingsFixtureFromHandler() throws { + let data = #""" + { + "findings": [ + { + "id": "f1", + "title": "Open redirect", + "severity": "medium", + "target": "https://example.test/out", + "description": "Unvalidated next param", + "cwe": "CWE-601", + "status": "confirmed", + "created_at": "2026-09-18T00:00:00Z" + } + ] + } + """#.data(using: .utf8)! + + let snapshot = try JSONDecoder().decode(EngagementSnapshot.self, from: data) + + XCTAssertEqual(snapshot.findings.count, 1) + XCTAssertEqual(snapshot.findings[0].id, "f1") + XCTAssertEqual(snapshot.findings[0].title, "Open redirect") + XCTAssertEqual(snapshot.findings[0].severity, "medium") + XCTAssertEqual(snapshot.findings[0].target, "https://example.test/out") + XCTAssertEqual(snapshot.findings[0].cwe, "CWE-601") + XCTAssertEqual(snapshot.findings[0].status, "confirmed") + } + + func testDecodesBoardSessionsAndColumnsFixtureFromHandlers() throws { + let sessionsData = #""" + { + "sessions": [ + {"id": "sess_1", "title": "Ship dashboard"} + ] + } + """#.data(using: .utf8)! + let boardData = #""" + { + "columns": [ + { + "name": "todo", + "cards": [ + { + "id": "c1", + "title": "Write tests", + "note": "DTO first", + "column": "todo", + "created_at": "2026-09-18T00:00:00Z", + "updated_at": "2026-09-18T00:00:01Z" + } + ] + }, + {"name": "doing", "cards": []}, + {"name": "done", "cards": []} + ] + } + """#.data(using: .utf8)! + + let sessions = try JSONDecoder().decode(BoardSessionsResponse.self, from: sessionsData) + let board = try JSONDecoder().decode(BoardSnapshot.self, from: boardData) + + XCTAssertEqual(sessions.sessions.count, 1) + XCTAssertEqual(sessions.sessions[0].id, "sess_1") + XCTAssertEqual(sessions.sessions[0].title, "Ship dashboard") + XCTAssertEqual(board.columns.map(\.name), ["todo", "doing", "done"]) + XCTAssertEqual(board.columns[0].cards.count, 1) + XCTAssertEqual(board.columns[0].cards[0].id, "c1") + XCTAssertEqual(board.columns[0].cards[0].title, "Write tests") + XCTAssertEqual(board.columns[0].cards[0].note, "DTO first") + XCTAssertEqual(board.columns[0].cards[0].column, "todo") + XCTAssertEqual(board.columns[1].cards.count, 0) + } +}