diff --git a/oAI.xcodeproj/project.pbxproj b/oAI.xcodeproj/project.pbxproj
index a7570e5..11fd0a9 100644
--- a/oAI.xcodeproj/project.pbxproj
+++ b/oAI.xcodeproj/project.pbxproj
@@ -283,7 +283,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2;
- MARKETING_VERSION = 2.3.8;
+ MARKETING_VERSION = 2.3.9;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -327,7 +327,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2;
- MARKETING_VERSION = 2.3.8;
+ MARKETING_VERSION = 2.3.9;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
diff --git a/oAI/Models/JarvisModels.swift b/oAI/Models/JarvisModels.swift
new file mode 100644
index 0000000..8a7cd72
--- /dev/null
+++ b/oAI/Models/JarvisModels.swift
@@ -0,0 +1,226 @@
+//
+// JarvisModels.swift
+// oAI
+//
+// Data models for the Jarvis (oAI-Web) API integration.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Copyright (C) 2026 Rune Olsen
+
+import Foundation
+
+// MARK: - Agent
+
+struct JarvisAgent: Identifiable, Codable, Hashable, Sendable {
+ let id: String
+ var name: String
+ var description: String
+ var prompt: String
+ var model: String
+ var enabled: Bool
+ var schedule: String?
+ var canCreateSubagents: Bool
+ var allowedTools: [String]
+ var maxToolCalls: Int?
+ var promptMode: String
+ let createdAt: String?
+ var isRunning: Bool?
+ var lastRunAt: String?
+ var lastRunStatus: String?
+
+ enum CodingKeys: String, CodingKey {
+ case id, name, description, prompt, model, enabled, schedule
+ case canCreateSubagents = "can_create_subagents"
+ case allowedTools = "allowed_tools"
+ case maxToolCalls = "max_tool_calls"
+ case promptMode = "prompt_mode"
+ case createdAt = "created_at"
+ case isRunning = "is_running"
+ case lastRunAt = "last_run_at"
+ case lastRunStatus = "last_run_status"
+ }
+}
+
+// MARK: - Agent Input (create / update)
+
+struct JarvisAgentInput: Codable, Sendable {
+ var name: String
+ var prompt: String
+ var model: String
+ var description: String = ""
+ var enabled: Bool = true
+ var schedule: String? = nil
+ var canCreateSubagents: Bool = false
+ var allowedTools: [String] = []
+ var maxToolCalls: Int? = nil
+ var promptMode: String = "combined"
+
+ enum CodingKeys: String, CodingKey {
+ case name, prompt, model, description, enabled, schedule
+ case canCreateSubagents = "can_create_subagents"
+ case allowedTools = "allowed_tools"
+ case maxToolCalls = "max_tool_calls"
+ case promptMode = "prompt_mode"
+ }
+}
+
+// MARK: - Agent Run
+
+struct JarvisAgentRun: Identifiable, Codable, Sendable {
+ let id: String
+ let agentId: String?
+ let status: String // "running" | "completed" | "failed" | "stopped"
+ let startedAt: String?
+ let finishedAt: String?
+ let output: String?
+ let error: String?
+ let costUsd: Double?
+ let inputTokens: Int?
+ let outputTokens: Int?
+ let triggerType: String?
+
+ enum CodingKeys: String, CodingKey {
+ case id, status, output, error
+ case agentId = "agent_id"
+ case startedAt = "started_at"
+ case finishedAt = "finished_at"
+ case costUsd = "cost_usd"
+ case inputTokens = "input_tokens"
+ case outputTokens = "output_tokens"
+ case triggerType = "trigger_type"
+ }
+
+ var isActive: Bool { status == "running" }
+
+ var totalTokens: Int { (inputTokens ?? 0) + (outputTokens ?? 0) }
+
+ var formattedStarted: String {
+ guard let s = startedAt else { return "—" }
+ return isoFormatter.string(from: isoParser.date(from: s) ?? Date())
+ }
+
+ var formattedDuration: String? {
+ guard let s = startedAt, let f = finishedAt,
+ let sd = isoParser.date(from: s), let fd = isoParser.date(from: f) else { return nil }
+ let secs = Int(fd.timeIntervalSince(sd))
+ if secs < 60 { return "\(secs)s" }
+ return "\(secs / 60)m \(secs % 60)s"
+ }
+}
+
+private let isoParser: ISO8601DateFormatter = {
+ let f = ISO8601DateFormatter()
+ f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return f
+}()
+
+private let isoFormatter: DateFormatter = {
+ let f = DateFormatter()
+ f.dateStyle = .short
+ f.timeStyle = .short
+ return f
+}()
+
+// MARK: - Usage
+
+struct JarvisUsageStat: Identifiable, Codable, Sendable {
+ let agentId: String?
+ let agentName: String?
+ let model: String?
+ let runCount: Int?
+ let totalInputTokens: Int?
+ let totalOutputTokens: Int?
+ let totalCostUsd: Double?
+
+ var id: String { agentId ?? agentName ?? "unknown" }
+ var totalTokens: Int { (totalInputTokens ?? 0) + (totalOutputTokens ?? 0) }
+ var displayName: String { agentName ?? agentId ?? "Unknown" }
+
+ enum CodingKeys: String, CodingKey {
+ case agentId = "agent_id"
+ case agentName = "agent_name"
+ case model
+ case runCount = "runs"
+ case totalInputTokens = "input_tokens"
+ case totalOutputTokens = "output_tokens"
+ case totalCostUsd = "cost_usd"
+ }
+}
+
+// MARK: - Usage Response (top-level wrapper)
+
+struct JarvisUsageResponse: Decodable, Sendable {
+ let summary: JarvisUsageSummary?
+ let byAgent: [JarvisUsageStat]?
+
+ enum CodingKeys: String, CodingKey {
+ case summary
+ case byAgent = "by_agent"
+ }
+}
+
+struct JarvisUsageSummary: Codable, Sendable {
+ let runs: Int?
+ let inputTokens: Int?
+ let outputTokens: Int?
+ let costUsd: Double?
+
+ enum CodingKeys: String, CodingKey {
+ case runs
+ case inputTokens = "input_tokens"
+ case outputTokens = "output_tokens"
+ case costUsd = "cost_usd"
+ }
+}
+
+// MARK: - Credits
+
+struct JarvisCreditsResponse: Codable, Sendable {
+ let totalCredits: Double?
+ let totalUsage: Double?
+ let balance: Double?
+
+ enum CodingKeys: String, CodingKey {
+ case totalCredits = "total_credits"
+ case totalUsage = "total_usage"
+ case balance
+ }
+
+ var remainingBalance: Double? {
+ if let b = balance { return b }
+ if let c = totalCredits, let u = totalUsage { return c - u }
+ return nil
+ }
+}
+
+// MARK: - Queue / System status
+
+struct JarvisQueueStatus: Codable, Sendable {
+ let paused: Bool?
+ let queueLength: Int?
+ let runningCount: Int?
+
+ enum CodingKeys: String, CodingKey {
+ case paused
+ case queueLength = "queue_length"
+ case runningCount = "running_count"
+ }
+}
+
+// MARK: - Errors
+
+enum JarvisError: LocalizedError {
+ case invalidURL
+ case noAPIKey
+ case invalidResponse
+ case serverError(Int, String)
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidURL: return "Invalid Jarvis URL"
+ case .noAPIKey: return "No API key configured — add one in Settings → Jarvis"
+ case .invalidResponse: return "Invalid server response"
+ case .serverError(let c, let m): return "Server error \(c): \(m)"
+ }
+ }
+}
diff --git a/oAI/Models/ModelCategory.swift b/oAI/Models/ModelCategory.swift
new file mode 100644
index 0000000..58ed66d
--- /dev/null
+++ b/oAI/Models/ModelCategory.swift
@@ -0,0 +1,176 @@
+//
+// ModelCategory.swift
+// oAI
+//
+// Category tags for AI models, inferred from model name/id/description.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Copyright (C) 2026 Rune Olsen
+//
+// This file is part of oAI.
+//
+// oAI is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// oAI is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+// Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public
+// License along with oAI. If not, see .
+
+
+import SwiftUI
+
+enum ModelCategory: String, CaseIterable, Codable, Sendable {
+ case programming = "Programming"
+ case math = "Math"
+ case medical = "Medical"
+ case translation = "Translation"
+ case roleplay = "Roleplay"
+ case creative = "Creative"
+ case science = "Science"
+ case finance = "Finance"
+ case legal = "Legal"
+
+ var color: Color {
+ switch self {
+ case .programming: return .blue
+ case .math: return .orange
+ case .medical: return .red
+ case .translation: return .teal
+ case .roleplay: return .pink
+ case .creative: return .purple
+ case .science: return .green
+ case .finance: return Color(red: 0.75, green: 0.60, blue: 0.0)
+ case .legal: return Color(red: 0.55, green: 0.40, blue: 0.20)
+ }
+ }
+
+ var systemImage: String {
+ switch self {
+ case .programming: return "chevron.left.forwardslash.chevron.right"
+ case .math: return "function"
+ case .medical: return "cross.fill"
+ case .translation: return "globe"
+ case .roleplay: return "theatermasks.fill"
+ case .creative: return "pencil.and.outline"
+ case .science: return "atom"
+ case .finance: return "chart.line.uptrend.xyaxis"
+ case .legal: return "building.columns.fill"
+ }
+ }
+
+ // MARK: - Category Inference
+
+ /// Infer categories from a model's name, id, and description.
+ static func infer(name: String, id: String, description: String?) -> [ModelCategory] {
+ let nameId = (name + " " + id).lowercased()
+ let desc = description?.lowercased() ?? ""
+ return allCases.filter { $0.matches(nameId: nameId, desc: desc) }
+ }
+
+ private func matches(nameId: String, desc: String) -> Bool {
+ if nameKeywords.contains(where: { nameId.contains($0) }) { return true }
+ if desc.count > 40 && descKeywords.contains(where: { desc.contains($0) }) { return true }
+ return false
+ }
+
+ // Patterns matched against lowercased "name + id" string
+ private var nameKeywords: [String] {
+ switch self {
+ case .programming:
+ return ["code", "coder", "codex", "codellama", "starcoder", "phind",
+ "codestral", "opencoder", "swe-", "devin-", "wizard-code",
+ "replit-code", "qwen-coder", "deepseek-coder", "devstral",
+ "granite-code", "yi-coder", "artigenz", "wavecoder",
+ "programming", "software-", "cursor-"]
+ case .math:
+ return ["math", "mathem", "numina", "minerva-math", "wizard-math",
+ "deepseek-math", "qwen-math", "numinamath", "mathstral",
+ "qwq", "internlm-math", "mammoth", "mathcoder", "orion-math",
+ "abel-", "metamath"]
+ case .medical:
+ return ["medical", "meditron", "med42", "medllama", "biomed",
+ "health-llm", "biosage", "clinicalbert", "pubmedbert",
+ "clinical", "llama-med", "openbiomed", "pmc-llama",
+ "doctorglm", "biolm", "biomistral", "medalpaca",
+ "medpalm", "pharmallm", "mimic"]
+ case .translation:
+ return ["nllb", "madlad", "-aya-", "seamless", "tower-instruct",
+ "alma-", "bayling", "opus-mt", "m2m-100", "mbart",
+ "translate", "multilingual-", "xglm", "madlad-400"]
+ case .roleplay:
+ return ["roleplay", "role-play", "mytho", "capybara", "cinematika",
+ "manticore", "weaver-", "noromaid", "airoboros", "toppy",
+ "dolphin", "hermes", "openhermes", "psyfighter",
+ "bluemoon", "midnight", "remm", "rose-20b"]
+ case .creative:
+ return ["creative-writing", "story-writer", "storyllm", "fimbulvetr",
+ "rp-", "goliath", "lzlv", "mlewd"]
+ case .science:
+ return ["scibert", "biogpt", "galactica", "science-llm", "scillm",
+ "darwin-", "newton-", "eureka-", "sci-"]
+ case .finance:
+ return ["fingpt", "finma", "finance-llm", "financellm", "pixiu",
+ "flang-", "alphafin", "bloom-finance", "finbert",
+ "stockgpt", "traderllm"]
+ case .legal:
+ return ["lawbench", "legalbench", "legalbert", "lawgpt", "legal-llm",
+ "lawyerllm", "legalai", "chatlaw", "jurisllm"]
+ }
+ }
+
+ // Phrases matched against lowercased description (only when description > 40 chars)
+ private var descKeywords: [String] {
+ switch self {
+ case .programming:
+ return ["code generation", "designed for coding", "built for code",
+ "coding-focused", "programming assistant", "specialized for code",
+ "optimized for coding", "coding tasks", "software engineering",
+ "for developers", "code completion", "software development",
+ "coding and", "and coding", "writing code"]
+ case .math:
+ return ["mathematical reasoning", "math competition", "math olympiad",
+ "designed for math", "theorem proving", "quantitative reasoning",
+ "numerical problem", "math, code", "math and code",
+ "mathematics and", "advanced math", "math tasks",
+ "solving math", "competition math", "math problems"]
+ case .medical:
+ return ["medical knowledge", "clinical reasoning", "healthcare",
+ "biomedical research", "medical question", "trained on medical",
+ "medical domain", "medical literature", "clinical decision",
+ "health information", "medical text", "medical imaging"]
+ case .translation:
+ return ["machine translation", "language translation",
+ "multilingual translation", "cross-lingual",
+ "translation tasks", "translation between",
+ "natural language translation"]
+ case .roleplay:
+ return ["designed for roleplay", "roleplay scenarios",
+ "creative roleplay", "interactive roleplay", "character roleplay",
+ "role-playing", "roleplaying", "uncensored", "nsfw",
+ "adult content", "creative fiction", "interactive story"]
+ case .creative:
+ return ["creative writing", "storytelling", "narrative generation",
+ "fiction writing", "prose generation", "story writing",
+ "creative text", "story generation", "write stories"]
+ case .science:
+ return ["scientific literature", "scientific research",
+ "chemistry tasks", "biology research", "physics",
+ "scientific reasoning", "science tasks", "stem tasks",
+ "scientific knowledge"]
+ case .finance:
+ return ["financial analysis", "quantitative finance",
+ "financial modeling", "market analysis", "financial reasoning",
+ "investment analysis", "economic analysis", "trading"]
+ case .legal:
+ return ["legal document", "case law", "legal reasoning",
+ "legal text", "law and legal", "legal questions",
+ "legal analysis", "legal research", "contract analysis"]
+ }
+ }
+}
diff --git a/oAI/Models/ModelInfo.swift b/oAI/Models/ModelInfo.swift
index 7d0f049..e3387db 100644
--- a/oAI/Models/ModelInfo.swift
+++ b/oAI/Models/ModelInfo.swift
@@ -34,6 +34,7 @@ struct ModelInfo: Identifiable, Codable, Hashable {
let capabilities: ModelCapabilities
var architecture: Architecture? = nil
var topProvider: String? = nil
+ var categories: [ModelCategory] = []
struct Pricing: Codable, Hashable {
let prompt: Double // per 1M tokens
diff --git a/oAI/Providers/OpenRouterProvider.swift b/oAI/Providers/OpenRouterProvider.swift
index 071b824..18e7fed 100644
--- a/oAI/Providers/OpenRouterProvider.swift
+++ b/oAI/Providers/OpenRouterProvider.swift
@@ -80,7 +80,7 @@ class OpenRouterProvider: AIProvider {
let promptPrice = Double(modelData.pricing.prompt) ?? 0.0
let completionPrice = Double(modelData.pricing.completion) ?? 0.0
- return ModelInfo(
+ var info = ModelInfo(
id: modelData.id,
name: modelData.name,
description: modelData.description,
@@ -122,6 +122,12 @@ class OpenRouterProvider: AIProvider {
},
topProvider: modelData.id.components(separatedBy: "/").first
)
+ info.categories = ModelCategory.infer(
+ name: modelData.name,
+ id: modelData.id,
+ description: modelData.description
+ )
+ return info
}
}
diff --git a/oAI/Services/JarvisService.swift b/oAI/Services/JarvisService.swift
new file mode 100644
index 0000000..e58ded0
--- /dev/null
+++ b/oAI/Services/JarvisService.swift
@@ -0,0 +1,156 @@
+//
+// JarvisService.swift
+// oAI
+//
+// HTTP client for the Jarvis (oAI-Web) REST API.
+// Auth: Authorization: Bearer
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Copyright (C) 2026 Rune Olsen
+
+import Foundation
+import os
+
+final class JarvisService: Sendable {
+ static let shared = JarvisService()
+ private init() {}
+
+ private let log = Logger(subsystem: "com.oai.oAI", category: "jarvis")
+
+ private var baseURL: String { SettingsService.shared.jarvisURL }
+ private var apiKey: String? { SettingsService.shared.jarvisAPIKey }
+
+ // MARK: - Agents
+
+ func listAgents() async throws -> [JarvisAgent] {
+ try await get("/api/agents")
+ }
+
+ func createAgent(_ input: JarvisAgentInput) async throws -> JarvisAgent {
+ try await post("/api/agents", body: input)
+ }
+
+ func updateAgent(id: String, _ input: JarvisAgentInput) async throws -> JarvisAgent {
+ try await put("/api/agents/\(id)", body: input)
+ }
+
+ func deleteAgent(id: String) async throws {
+ try await voidRequest("DELETE", path: "/api/agents/\(id)")
+ }
+
+ func toggleAgent(id: String) async throws -> JarvisAgent {
+ try await post("/api/agents/\(id)/toggle", body: Empty())
+ }
+
+ func runAgent(id: String) async throws {
+ try await voidRequest("POST", path: "/api/agents/\(id)/run")
+ }
+
+ func stopAgent(id: String) async throws {
+ try await voidRequest("POST", path: "/api/agents/\(id)/stop")
+ }
+
+ func agentRuns(id: String) async throws -> [JarvisAgentRun] {
+ try await get("/api/agents/\(id)/runs")
+ }
+
+ // MARK: - Usage
+
+ func usage() async throws -> [JarvisUsageStat] {
+ let data = try await rawData("GET", path: "/api/usage")
+ // API returns {summary:{...}, by_agent:[...], chat:{...}}
+ if let w = try? JSONDecoder().decode(JarvisUsageResponse.self, from: data) {
+ return w.byAgent ?? []
+ }
+ // Fallback: plain array
+ if let arr = try? JSONDecoder().decode([JarvisUsageStat].self, from: data) { return arr }
+ return []
+ }
+
+ func credits() async throws -> JarvisCreditsResponse {
+ try await get("/api/usage/openrouter-credits")
+ }
+
+ // MARK: - Control
+
+ func queueStatus() async throws -> JarvisQueueStatus {
+ try await get("/api/status")
+ }
+
+ func pauseAll() async throws {
+ try await voidRequest("POST", path: "/api/pause")
+ }
+
+ func resumeAll() async throws {
+ try await voidRequest("POST", path: "/api/resume")
+ }
+
+ // MARK: - Connection test
+
+ func testConnection() async -> Bool {
+ guard !baseURL.isEmpty, let key = apiKey, !key.isEmpty else { return false }
+ do {
+ let _: [JarvisAgent] = try await get("/api/agents")
+ return true
+ } catch {
+ log.warning("Jarvis connection test failed: \(error.localizedDescription)")
+ return false
+ }
+ }
+
+ // MARK: - HTTP core
+
+ private func get(_ path: String) async throws -> T {
+ let data = try await rawData("GET", path: path)
+ return try JSONDecoder().decode(T.self, from: data)
+ }
+
+ private func post(_ path: String, body: B) async throws -> T {
+ let data = try await rawData("POST", path: path, body: body)
+ return try JSONDecoder().decode(T.self, from: data)
+ }
+
+ private func put(_ path: String, body: B) async throws -> T {
+ let data = try await rawData("PUT", path: path, body: body)
+ return try JSONDecoder().decode(T.self, from: data)
+ }
+
+ private func voidRequest(_ method: String, path: String) async throws {
+ _ = try await rawData(method, path: path)
+ }
+
+ private func rawData(_ method: String, path: String, body: B? = nil as Empty?) async throws -> Data {
+ guard let url = URL(string: baseURL.trimmingCharacters(in: .whitespaces) + path) else {
+ throw JarvisError.invalidURL
+ }
+ guard let key = apiKey, !key.isEmpty else {
+ throw JarvisError.noAPIKey
+ }
+
+ var req = URLRequest(url: url)
+ req.httpMethod = method
+ req.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
+ req.setValue("application/json", forHTTPHeaderField: "Accept")
+ req.timeoutInterval = 30
+
+ if let body {
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = try JSONEncoder().encode(body)
+ }
+
+ let (data, response) = try await URLSession.shared.data(for: req)
+
+ guard let http = response as? HTTPURLResponse else {
+ throw JarvisError.invalidResponse
+ }
+ guard (200..<300).contains(http.statusCode) else {
+ let msg = (try? JSONDecoder().decode(JarvisErrBody.self, from: data))?.detail ?? "HTTP \(http.statusCode)"
+ log.error("Jarvis \(method) \(path) → \(http.statusCode): \(msg)")
+ throw JarvisError.serverError(http.statusCode, msg)
+ }
+ return data
+ }
+}
+
+private struct Empty: Codable {}
+private struct JarvisErrBody: Decodable { let detail: String? }
diff --git a/oAI/Services/SettingsService.swift b/oAI/Services/SettingsService.swift
index 0f1b0b2..67a1403 100644
--- a/oAI/Services/SettingsService.swift
+++ b/oAI/Services/SettingsService.swift
@@ -43,6 +43,7 @@ class SettingsService {
static let googleSearchEngineID = "googleSearchEngineID"
static let anytypeMcpAPIKey = "anytypeMcpAPIKey"
static let paperlessAPIToken = "paperlessAPIToken"
+ static let jarvisAPIKey = "jarvisAPIKey"
}
// Old keychain keys (for migration only)
@@ -500,6 +501,46 @@ class SettingsService {
return !key.isEmpty
}
+ // MARK: - Jarvis Settings
+
+ var jarvisEnabled: Bool {
+ get { cache["jarvisEnabled"] == "true" }
+ set {
+ cache["jarvisEnabled"] = String(newValue)
+ DatabaseService.shared.setSetting(key: "jarvisEnabled", value: String(newValue))
+ }
+ }
+
+ var jarvisURL: String {
+ get { cache["jarvisURL"] ?? "" }
+ set {
+ let trimmed = newValue.trimmingCharacters(in: .whitespaces)
+ if trimmed.isEmpty {
+ cache.removeValue(forKey: "jarvisURL")
+ DatabaseService.shared.deleteSetting(key: "jarvisURL")
+ } else {
+ cache["jarvisURL"] = trimmed
+ DatabaseService.shared.setSetting(key: "jarvisURL", value: trimmed)
+ }
+ }
+ }
+
+ var jarvisAPIKey: String? {
+ get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.jarvisAPIKey) }
+ set {
+ if let value = newValue, !value.isEmpty {
+ try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.jarvisAPIKey, value: value)
+ } else {
+ DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.jarvisAPIKey)
+ }
+ }
+ }
+
+ var jarvisConfigured: Bool {
+ guard let key = jarvisAPIKey else { return false }
+ return !jarvisURL.isEmpty && !key.isEmpty
+ }
+
// MARK: - Bash Execution Settings
var bashEnabled: Bool {
diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift
index 55fbc3f..ab5a8e9 100644
--- a/oAI/ViewModels/ChatViewModel.swift
+++ b/oAI/ViewModels/ChatViewModel.swift
@@ -53,6 +53,7 @@ class ChatViewModel {
var showHistory: Bool = false
var showShortcuts: Bool = false
var showSkills: Bool = false
+ var showJarvis: Bool = false
var modelInfoTarget: ModelInfo? = nil
var commandHistory: [String] = []
var historyIndex: Int = 0
@@ -742,6 +743,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
case "/skills":
showSkills = true
+ case "/jarvis":
+ showJarvis = true
+
case "/mcp":
handleMCPCommand(args: args)
diff --git a/oAI/Views/Main/ChatView.swift b/oAI/Views/Main/ChatView.swift
index ceb06cf..52180c1 100644
--- a/oAI/Views/Main/ChatView.swift
+++ b/oAI/Views/Main/ChatView.swift
@@ -106,6 +106,9 @@ struct ChatView: View {
.sheet(isPresented: $viewModel.showSkills) {
AgentSkillsView()
}
+ .sheet(isPresented: $viewModel.showJarvis) {
+ JarvisView()
+ }
.sheet(item: Binding(
get: { MCPService.shared.pendingBashCommand },
set: { _ in }
diff --git a/oAI/Views/Main/InputBar.swift b/oAI/Views/Main/InputBar.swift
index 067b21a..7bc7181 100644
--- a/oAI/Views/Main/InputBar.swift
+++ b/oAI/Views/Main/InputBar.swift
@@ -41,7 +41,7 @@ struct InputBar: View {
/// Commands that execute immediately without additional arguments
private static let immediateCommands: Set = [
"/help", "/history", "/model", "/clear", "/retry", "/stats", "/config",
- "/settings", "/credits", "/list", "/load", "/shortcuts", "/skills",
+ "/settings", "/credits", "/list", "/load", "/shortcuts", "/skills", "/jarvis",
"/memory on", "/memory off", "/online on", "/online off",
"/mcp on", "/mcp off", "/mcp status", "/mcp list",
"/mcp write on", "/mcp write off",
@@ -279,6 +279,7 @@ struct CommandSuggestionsView: View {
("/retry", "Retry last message"),
("/shortcuts", "Manage your prompt shortcuts"),
("/skills", "Manage your agent skills"),
+ ("/jarvis", "Open Jarvis agent manager"),
("/memory on", "Enable conversation memory"),
("/memory off", "Disable conversation memory"),
("/online on", "Enable web search"),
diff --git a/oAI/Views/Screens/HelpView.swift b/oAI/Views/Screens/HelpView.swift
index c5b8231..634ec2f 100644
--- a/oAI/Views/Screens/HelpView.swift
+++ b/oAI/Views/Screens/HelpView.swift
@@ -180,6 +180,14 @@ private let helpCategories: [CommandCategory] = [
examples: ["/mcp write on", "/mcp write off"]
),
]),
+ CommandCategory(name: "Integrations", icon: "server.rack", commands: [
+ CommandDetail(
+ command: "/jarvis",
+ brief: "Open Jarvis agent manager",
+ detail: "Opens the Jarvis panel where you can list, create, run, and monitor agents on your Jarvis (oAI-Web) server, and view usage and cost statistics. Configure the server URL and API key in Settings → Jarvis.",
+ examples: ["/jarvis"]
+ ),
+ ]),
CommandCategory(name: "Settings & Stats", icon: "gearshape", commands: [
CommandDetail(
command: "/config",
diff --git a/oAI/Views/Screens/JarvisView.swift b/oAI/Views/Screens/JarvisView.swift
new file mode 100644
index 0000000..51b4c09
--- /dev/null
+++ b/oAI/Views/Screens/JarvisView.swift
@@ -0,0 +1,866 @@
+//
+// JarvisView.swift
+// oAI
+//
+// Main modal for managing Jarvis (oAI-Web) agents and usage.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Copyright (C) 2026 Rune Olsen
+
+import SwiftUI
+
+// MARK: - Editor context (avoids .sheet timing bug)
+
+private struct AgentEditContext: Identifiable {
+ let id = UUID()
+ let agent: JarvisAgent? // nil → new
+}
+
+// MARK: - JarvisView
+
+struct JarvisView: View {
+ @Environment(\.dismiss) var dismiss
+ @State private var selectedTab = 0
+ @State private var agents: [JarvisAgent] = []
+ @State private var runs: [String: [JarvisAgentRun]] = [:]
+ @State private var usageStats: [JarvisUsageStat] = []
+ @State private var credits: JarvisCreditsResponse? = nil
+ @State private var queueStatus: JarvisQueueStatus? = nil
+ @State private var isLoadingAgents = false
+ @State private var isLoadingUsage = false
+ @State private var selectedAgent: JarvisAgent? = nil
+ @State private var editContext: AgentEditContext? = nil
+ @State private var errorMessage: String? = nil
+ @State private var actionInProgress: Set = []
+
+ private let service = JarvisService.shared
+
+ var body: some View {
+ VStack(spacing: 0) {
+ // Header
+ HStack(alignment: .center) {
+ Label("Jarvis", systemImage: "server.rack")
+ .font(.system(size: 18, weight: .bold))
+ Spacer()
+ if let qs = queueStatus {
+ queueStatusBadge(qs)
+ }
+ Button { dismiss() } label: {
+ Image(systemName: "xmark.circle.fill")
+ .font(.title2).foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ .keyboardShortcut(.escape, modifiers: [])
+ }
+ .padding(.horizontal, 24)
+ .padding(.top, 20)
+ .padding(.bottom, 10)
+
+ // Tab picker
+ Picker("", selection: $selectedTab) {
+ Text("Agents").tag(0)
+ Text("Usage").tag(1)
+ }
+ .pickerStyle(.segmented)
+ .padding(.horizontal, 24)
+ .padding(.bottom, 10)
+
+ Divider()
+
+ if let err = errorMessage {
+ HStack {
+ Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
+ Text(err).font(.callout).foregroundStyle(.secondary)
+ Spacer()
+ Button("Dismiss") { errorMessage = nil }.buttonStyle(.borderless)
+ }
+ .padding(.horizontal, 24)
+ .padding(.vertical, 8)
+ Divider()
+ }
+
+ switch selectedTab {
+ case 0: agentsTab
+ default: usageTab
+ }
+ }
+ .frame(minWidth: 840, idealWidth: 960, minHeight: 560, idealHeight: 720)
+ .task { await loadAll() }
+ .sheet(item: $editContext) { ctx in
+ JarvisAgentEditorSheet(agent: ctx.agent, onSave: { input in
+ await saveAgent(existing: ctx.agent, input: input)
+ })
+ }
+ }
+
+ // MARK: - Agents Tab
+
+ private var agentsTab: some View {
+ NavigationSplitView {
+ agentList
+ } detail: {
+ if let agent = selectedAgent {
+ agentDetailView(agent)
+ } else {
+ ContentUnavailableView("Select an Agent", systemImage: "server.rack",
+ description: Text("Choose an agent from the list to view details and run history"))
+ }
+ }
+ .navigationSplitViewStyle(.balanced)
+ }
+
+ private var agentList: some View {
+ VStack(spacing: 0) {
+ // Toolbar
+ HStack(spacing: 8) {
+ Button {
+ editContext = AgentEditContext(agent: nil)
+ } label: {
+ Label("New Agent", systemImage: "plus")
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+
+ Spacer()
+
+ if isLoadingAgents {
+ ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
+ }
+
+ Button { Task { await loadAgents() } } label: {
+ Image(systemName: "arrow.clockwise")
+ }
+ .buttonStyle(.borderless)
+ .help("Refresh agents")
+
+ // Queue control
+ if let qs = queueStatus {
+ Button {
+ Task {
+ do {
+ if qs.paused == true { try await service.resumeAll() }
+ else { try await service.pauseAll() }
+ await loadQueueStatus()
+ } catch { errorMessage = error.localizedDescription }
+ }
+ } label: {
+ Image(systemName: qs.paused == true ? "play.fill" : "pause.fill")
+ }
+ .buttonStyle(.borderless)
+ .help(qs.paused == true ? "Resume queue" : "Pause queue")
+ }
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+
+ Divider()
+
+ if agents.isEmpty && !isLoadingAgents {
+ ContentUnavailableView("No Agents", systemImage: "server.rack")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ List(agents, id: \.id, selection: $selectedAgent) { agent in
+ agentRow(agent)
+ .tag(agent)
+ }
+ .listStyle(.plain)
+ }
+ }
+ }
+
+ private func agentRow(_ agent: JarvisAgent) -> some View {
+ HStack(spacing: 8) {
+ // Status dot
+ Circle()
+ .fill(agent.isRunning == true ? Color.green : Color.secondary.opacity(0.4))
+ .frame(width: 8, height: 8)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(agent.name)
+ .font(.system(size: 13, weight: .medium))
+ .lineLimit(1)
+ if let schedule = agent.schedule, !schedule.isEmpty {
+ Text(schedule)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ // Enable toggle
+ Toggle("", isOn: Binding(
+ get: { agent.enabled },
+ set: { _ in Task { await toggleAgent(agent) } }
+ ))
+ .toggleStyle(.switch)
+ .controlSize(.mini)
+ .labelsHidden()
+
+ // Run / Stop button
+ if agent.isRunning == true {
+ Button {
+ Task { await stopAgent(agent) }
+ } label: {
+ Image(systemName: "stop.fill")
+ .font(.system(size: 11))
+ .foregroundStyle(.red)
+ }
+ .buttonStyle(.borderless)
+ .disabled(actionInProgress.contains(agent.id))
+ .help("Stop agent")
+ } else {
+ Button {
+ Task { await runAgent(agent) }
+ } label: {
+ Image(systemName: actionInProgress.contains(agent.id) ? "ellipsis" : "play.fill")
+ .font(.system(size: 11))
+ .foregroundStyle(.green)
+ }
+ .buttonStyle(.borderless)
+ .disabled(actionInProgress.contains(agent.id) || !agent.enabled)
+ .help("Run agent now")
+ }
+ }
+ .padding(.vertical, 2)
+ .contentShape(Rectangle())
+ }
+
+ @ViewBuilder
+ private func agentDetailView(_ agent: JarvisAgent) -> some View {
+ VStack(alignment: .leading, spacing: 0) {
+ // Agent info header
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(spacing: 8) {
+ Circle()
+ .fill(agent.isRunning == true ? Color.green : Color.secondary.opacity(0.4))
+ .frame(width: 10, height: 10)
+ Text(agent.name)
+ .font(.system(size: 16, weight: .semibold))
+ if !agent.enabled {
+ Text("Disabled")
+ .font(.caption)
+ .padding(.horizontal, 6).padding(.vertical, 2)
+ .background(Color.orange.opacity(0.15))
+ .foregroundStyle(.orange)
+ .cornerRadius(4)
+ }
+ }
+ Text(agent.model)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ Button {
+ editContext = AgentEditContext(agent: agent)
+ } label: {
+ Label("Edit", systemImage: "pencil")
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+
+ Button(role: .destructive) {
+ Task { await deleteAgent(agent) }
+ } label: {
+ Image(systemName: "trash")
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ }
+
+ if !agent.description.isEmpty {
+ Text(agent.description)
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ }
+
+ if let schedule = agent.schedule, !schedule.isEmpty {
+ Label(schedule, systemImage: "clock")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(16)
+
+ Divider()
+
+ // Prompt preview
+ DisclosureGroup("Prompt") {
+ ScrollView {
+ Text(agent.prompt)
+ .font(.system(size: 12, design: .monospaced))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .textSelection(.enabled)
+ .padding(8)
+ }
+ .frame(maxHeight: 120)
+ .background(Color.gray.opacity(0.06))
+ .cornerRadius(6)
+ .padding(.horizontal, 16)
+ .padding(.bottom, 8)
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 8)
+
+ Divider()
+
+ // Run history
+ Text("Run History")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 16)
+ .padding(.top, 10)
+ .padding(.bottom, 4)
+
+ let agentRuns = runs[agent.id] ?? []
+ if agentRuns.isEmpty {
+ Text("No runs yet")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
+ } else {
+ List(agentRuns) { run in
+ RunHistoryRow(run: run)
+ }
+ .listStyle(.plain)
+ }
+ }
+ .task(id: agent.id) {
+ await loadRuns(for: agent)
+ }
+ }
+
+ // MARK: - Usage Tab
+
+ private var usageTab: some View {
+ VStack(spacing: 0) {
+ // Credits card
+ if let creds = credits, let balance = creds.remainingBalance {
+ HStack(spacing: 16) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("OpenRouter Balance")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Text(String(format: "$%.4f", balance))
+ .font(.system(size: 22, weight: .semibold, design: .monospaced))
+ .foregroundStyle(balance < 1.0 ? .orange : .primary)
+ }
+ Spacer()
+ if let total = creds.totalCredits {
+ VStack(alignment: .trailing, spacing: 4) {
+ Text("Total Credits")
+ .font(.caption).foregroundStyle(.secondary)
+ Text(String(format: "$%.2f", total))
+ .font(.callout).foregroundStyle(.secondary)
+ }
+ }
+ if let used = creds.totalUsage {
+ VStack(alignment: .trailing, spacing: 4) {
+ Text("Total Used")
+ .font(.caption).foregroundStyle(.secondary)
+ Text(String(format: "$%.4f", used))
+ .font(.callout).foregroundStyle(.secondary)
+ }
+ }
+ }
+ .padding(16)
+ .background(Color.blue.opacity(0.06))
+ .cornerRadius(10)
+ .padding(.horizontal, 16)
+ .padding(.top, 12)
+ .padding(.bottom, 8)
+ }
+
+ if isLoadingUsage {
+ ProgressView("Loading usage…")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else if usageStats.isEmpty {
+ ContentUnavailableView("No Usage Data", systemImage: "chart.bar",
+ description: Text("Run some agents to see usage statistics"))
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ // Header row
+ HStack {
+ Text("Agent")
+ .frame(maxWidth: .infinity, alignment: .leading)
+ Text("Runs")
+ .frame(width: 50, alignment: .trailing)
+ Text("Input")
+ .frame(width: 80, alignment: .trailing)
+ Text("Output")
+ .frame(width: 80, alignment: .trailing)
+ Text("Cost")
+ .frame(width: 80, alignment: .trailing)
+ }
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 6)
+
+ Divider()
+
+ ScrollView {
+ VStack(spacing: 0) {
+ ForEach(usageStats) { stat in
+ usageRow(stat)
+ Divider().padding(.leading, 16)
+ }
+
+ // Totals row
+ let totalRuns = usageStats.compactMap(\.runCount).reduce(0, +)
+ let totalInput = usageStats.compactMap(\.totalInputTokens).reduce(0, +)
+ let totalOutput = usageStats.compactMap(\.totalOutputTokens).reduce(0, +)
+ let totalCost = usageStats.compactMap(\.totalCostUsd).reduce(0, +)
+ HStack {
+ Text("Total")
+ .font(.system(size: 13, weight: .semibold))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ Text("\(totalRuns)")
+ .frame(width: 50, alignment: .trailing)
+ .font(.system(size: 13, weight: .semibold))
+ Text(formatTokens(totalInput))
+ .frame(width: 80, alignment: .trailing)
+ .font(.system(size: 13, weight: .semibold))
+ Text(formatTokens(totalOutput))
+ .frame(width: 80, alignment: .trailing)
+ .font(.system(size: 13, weight: .semibold))
+ Text(String(format: "$%.4f", totalCost))
+ .frame(width: 80, alignment: .trailing)
+ .font(.system(size: 13, weight: .semibold, design: .monospaced))
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ .background(Color.gray.opacity(0.07))
+ }
+ }
+
+ HStack {
+ Spacer()
+ Button { Task { await loadUsage() } } label: {
+ Label("Refresh", systemImage: "arrow.clockwise")
+ }
+ .buttonStyle(.borderless)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 6)
+ }
+ }
+ }
+
+ private func usageRow(_ stat: JarvisUsageStat) -> some View {
+ HStack {
+ Text(stat.displayName)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .lineLimit(1)
+ Text("\(stat.runCount ?? 0)")
+ .frame(width: 50, alignment: .trailing)
+ .foregroundStyle(.secondary)
+ Text(formatTokens(stat.totalInputTokens ?? 0))
+ .frame(width: 80, alignment: .trailing)
+ .foregroundStyle(.secondary)
+ Text(formatTokens(stat.totalOutputTokens ?? 0))
+ .frame(width: 80, alignment: .trailing)
+ .foregroundStyle(.secondary)
+ Text(stat.totalCostUsd.map { String(format: "$%.4f", $0) } ?? "—")
+ .frame(width: 80, alignment: .trailing)
+ .font(.system(size: 13, design: .monospaced))
+ }
+ .font(.system(size: 13))
+ .padding(.horizontal, 16)
+ .padding(.vertical, 8)
+ }
+
+ // MARK: - Queue Status Badge
+
+ private func queueStatusBadge(_ qs: JarvisQueueStatus) -> some View {
+ HStack(spacing: 6) {
+ if qs.paused == true {
+ Label("Paused", systemImage: "pause.fill")
+ .foregroundStyle(.orange)
+ } else if let running = qs.runningCount, running > 0 {
+ Label("^[\(running) running](inflect: true)", systemImage: "circle.fill")
+ .foregroundStyle(.green)
+ } else {
+ Label("Idle", systemImage: "checkmark.circle")
+ .foregroundStyle(.secondary)
+ }
+ }
+ .font(.caption)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 4)
+ .background(Color.gray.opacity(0.1))
+ .cornerRadius(6)
+ }
+
+ // MARK: - Data Loading
+
+ private func loadAll() async {
+ await withTaskGroup(of: Void.self) { group in
+ group.addTask { await self.loadAgents() }
+ group.addTask { await self.loadUsage() }
+ group.addTask { await self.loadQueueStatus() }
+ }
+ }
+
+ private func loadAgents() async {
+ isLoadingAgents = true
+ do {
+ let fetched = try await service.listAgents()
+ agents = fetched
+ if let current = selectedAgent {
+ selectedAgent = fetched.first(where: { $0.id == current.id })
+ }
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ isLoadingAgents = false
+ }
+
+ private func loadRuns(for agent: JarvisAgent) async {
+ do {
+ let fetched = try await service.agentRuns(id: agent.id)
+ runs[agent.id] = fetched
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ private func loadUsage() async {
+ isLoadingUsage = true
+ do {
+ async let statsTask = service.usage()
+ async let creditsTask = service.credits()
+ usageStats = try await statsTask
+ credits = try? await creditsTask
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ isLoadingUsage = false
+ }
+
+ private func loadQueueStatus() async {
+ queueStatus = try? await service.queueStatus()
+ }
+
+ // MARK: - Agent Actions
+
+ private func toggleAgent(_ agent: JarvisAgent) async {
+ do {
+ let updated = try await service.toggleAgent(id: agent.id)
+ updateAgent(updated)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ private func runAgent(_ agent: JarvisAgent) async {
+ actionInProgress.insert(agent.id)
+ do {
+ try await service.runAgent(id: agent.id)
+ try? await Task.sleep(for: .seconds(1))
+ await loadAgents()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ actionInProgress.remove(agent.id)
+ }
+
+ private func stopAgent(_ agent: JarvisAgent) async {
+ do {
+ try await service.stopAgent(id: agent.id)
+ try? await Task.sleep(for: .seconds(1))
+ await loadAgents()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ private func deleteAgent(_ agent: JarvisAgent) async {
+ do {
+ try await service.deleteAgent(id: agent.id)
+ agents.removeAll { $0.id == agent.id }
+ if selectedAgent?.id == agent.id { selectedAgent = nil }
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ private func saveAgent(existing: JarvisAgent?, input: JarvisAgentInput) async {
+ do {
+ let result: JarvisAgent
+ if let existing {
+ result = try await service.updateAgent(id: existing.id, input)
+ } else {
+ result = try await service.createAgent(input)
+ }
+ updateAgent(result)
+ selectedAgent = result
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ private func updateAgent(_ updated: JarvisAgent) {
+ if let idx = agents.firstIndex(where: { $0.id == updated.id }) {
+ agents[idx] = updated
+ } else {
+ agents.append(updated)
+ }
+ if selectedAgent?.id == updated.id {
+ selectedAgent = updated
+ }
+ }
+
+ // MARK: - Formatting
+
+ private func formatTokens(_ n: Int) -> String {
+ if n >= 1_000_000 { return String(format: "%.1fM", Double(n) / 1_000_000) }
+ if n >= 1_000 { return String(format: "%.1fK", Double(n) / 1_000) }
+ return "\(n)"
+ }
+}
+
+// MARK: - Run History Row
+
+private struct RunHistoryRow: View {
+ let run: JarvisAgentRun
+ @State private var expanded = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(spacing: 8) {
+ statusIcon
+ VStack(alignment: .leading, spacing: 1) {
+ HStack(spacing: 6) {
+ Text(run.formattedStarted)
+ .font(.system(size: 12))
+ if let dur = run.formattedDuration {
+ Text("· \(dur)")
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ }
+ }
+ HStack(spacing: 8) {
+ if run.totalTokens > 0 {
+ Text("^[\(run.totalTokens) token](inflect: true)")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ if let cost = run.costUsd, cost > 0 {
+ Text(String(format: "$%.5f", cost))
+ .font(.caption2.monospaced())
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ Spacer()
+ if run.output != nil || run.error != nil {
+ Button {
+ withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() }
+ } label: {
+ Image(systemName: expanded ? "chevron.up" : "chevron.down")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+
+ if expanded {
+ if let err = run.error {
+ Text(err)
+ .font(.system(size: 11, design: .monospaced))
+ .foregroundStyle(.red)
+ .padding(8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color.red.opacity(0.06))
+ .cornerRadius(6)
+ .textSelection(.enabled)
+ } else if let out = run.output {
+ Text(out)
+ .font(.system(size: 11, design: .monospaced))
+ .foregroundStyle(.primary)
+ .padding(8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color.gray.opacity(0.07))
+ .cornerRadius(6)
+ .lineLimit(20)
+ .textSelection(.enabled)
+ }
+ }
+ }
+ .padding(.vertical, 4)
+ }
+
+ @ViewBuilder
+ private var statusIcon: some View {
+ switch run.status {
+ case "running":
+ ProgressView().scaleEffect(0.6).frame(width: 14, height: 14)
+ case "completed":
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 14))
+ .foregroundStyle(.green)
+ case "failed":
+ Image(systemName: "xmark.circle.fill")
+ .font(.system(size: 14))
+ .foregroundStyle(.red)
+ case "stopped":
+ Image(systemName: "stop.circle.fill")
+ .font(.system(size: 14))
+ .foregroundStyle(.orange)
+ default:
+ Image(systemName: "circle")
+ .font(.system(size: 14))
+ .foregroundStyle(.secondary)
+ }
+ }
+}
+
+// MARK: - Agent Editor Sheet
+
+struct JarvisAgentEditorSheet: View {
+ let agent: JarvisAgent?
+ let onSave: (JarvisAgentInput) async -> Void
+
+ @Environment(\.dismiss) var dismiss
+ @State private var name: String
+ @State private var description: String
+ @State private var model: String
+ @State private var prompt: String
+ @State private var schedule: String
+ @State private var enabled: Bool
+ @State private var maxToolCalls: String
+ @State private var isSaving = false
+
+ init(agent: JarvisAgent?, onSave: @escaping (JarvisAgentInput) async -> Void) {
+ self.agent = agent
+ self.onSave = onSave
+ _name = State(initialValue: agent?.name ?? "")
+ _description = State(initialValue: agent?.description ?? "")
+ _model = State(initialValue: agent?.model ?? "")
+ _prompt = State(initialValue: agent?.prompt ?? "")
+ _schedule = State(initialValue: agent?.schedule ?? "")
+ _enabled = State(initialValue: agent?.enabled ?? true)
+ _maxToolCalls = State(initialValue: agent.flatMap(\.maxToolCalls).map(String.init) ?? "")
+ }
+
+ var isValid: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty && !model.trimmingCharacters(in: .whitespaces).isEmpty }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ // Header
+ HStack {
+ Text(agent == nil ? "New Agent" : "Edit Agent")
+ .font(.system(size: 16, weight: .semibold))
+ Spacer()
+ Button { dismiss() } label: {
+ Image(systemName: "xmark.circle.fill")
+ .font(.title3).foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(.horizontal, 20)
+ .padding(.top, 16)
+ .padding(.bottom, 12)
+
+ Divider()
+
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ Group {
+ editorField("Name") {
+ TextField("Agent name", text: $name)
+ .textFieldStyle(.roundedBorder)
+ }
+ editorField("Description") {
+ TextField("Optional description", text: $description)
+ .textFieldStyle(.roundedBorder)
+ }
+ editorField("Model") {
+ TextField("e.g. anthropic/claude-sonnet-4-5", text: $model)
+ .textFieldStyle(.roundedBorder)
+ }
+ editorField("Schedule") {
+ TextField("Cron expression (e.g. 0 * * * *)", text: $schedule)
+ .textFieldStyle(.roundedBorder)
+ }
+ editorField("Max Tool Calls") {
+ TextField("Leave blank for default", text: $maxToolCalls)
+ .textFieldStyle(.roundedBorder)
+ .frame(maxWidth: 160)
+ }
+ editorField("Enabled") {
+ Toggle("", isOn: $enabled)
+ .toggleStyle(.switch)
+ .labelsHidden()
+ }
+ }
+
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Prompt")
+ .font(.system(size: 13, weight: .medium))
+ TextEditor(text: $prompt)
+ .font(.system(size: 13, design: .monospaced))
+ .frame(minHeight: 160)
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .strokeBorder(Color.gray.opacity(0.3))
+ )
+ }
+ }
+ .padding(20)
+ }
+
+ Divider()
+
+ HStack(spacing: 10) {
+ Spacer()
+ Button("Cancel") { dismiss() }
+ .keyboardShortcut(.escape, modifiers: [])
+ Button {
+ Task {
+ isSaving = true
+ let input = JarvisAgentInput(
+ name: name.trimmingCharacters(in: .whitespaces),
+ prompt: prompt,
+ model: model.trimmingCharacters(in: .whitespaces),
+ description: description,
+ enabled: enabled,
+ schedule: schedule.isEmpty ? nil : schedule,
+ maxToolCalls: Int(maxToolCalls)
+ )
+ await onSave(input)
+ isSaving = false
+ dismiss()
+ }
+ } label: {
+ if isSaving {
+ ProgressView().scaleEffect(0.8).frame(width: 16, height: 16)
+ } else {
+ Text(agent == nil ? "Create" : "Save")
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(!isValid || isSaving)
+ .keyboardShortcut(.return, modifiers: .command)
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 12)
+ }
+ .frame(minWidth: 560, idealWidth: 620, minHeight: 560, idealHeight: 700)
+ }
+
+ private func editorField(_ label: LocalizedStringKey, @ViewBuilder content: () -> Content) -> some View {
+ HStack(alignment: .firstTextBaseline, spacing: 12) {
+ Text(label)
+ .font(.system(size: 13))
+ .frame(width: 100, alignment: .trailing)
+ content()
+ }
+ }
+}
diff --git a/oAI/Views/Screens/ModelInfoView.swift b/oAI/Views/Screens/ModelInfoView.swift
index 7e7a49b..e785c76 100644
--- a/oAI/Views/Screens/ModelInfoView.swift
+++ b/oAI/Views/Screens/ModelInfoView.swift
@@ -145,6 +145,32 @@ struct ModelInfoView: View {
capabilityBadge(icon: "brain", label: "Thinking", active: model.capabilities.thinking)
}
+ // Categories (if any)
+ if !model.categories.isEmpty {
+ Divider()
+ sectionHeader("Categories")
+ FlowLayout(spacing: 8) {
+ ForEach(model.categories, id: \.rawValue) { cat in
+ HStack(spacing: 5) {
+ Image(systemName: cat.systemImage)
+ .font(.caption2)
+ Text(LocalizedStringKey(cat.rawValue))
+ .font(.caption)
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 6)
+ .background(cat.color.opacity(0.12))
+ .foregroundColor(cat.color)
+ .cornerRadius(6)
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .strokeBorder(cat.color.opacity(0.35), lineWidth: 1)
+ )
+ }
+ }
+ .padding(.leading, 4)
+ }
+
// Architecture (if available)
if let arch = model.architecture {
Divider()
@@ -238,6 +264,50 @@ struct ModelInfoView: View {
}
}
+// MARK: - Flow Layout
+
+struct FlowLayout: Layout {
+ var spacing: CGFloat = 8
+
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
+ let rows = rows(for: subviews, width: proposal.width ?? .infinity)
+ let height = rows.map { row in
+ row.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0
+ }.reduce(0, +) + CGFloat(max(0, rows.count - 1)) * spacing
+ return CGSize(width: proposal.width ?? 0, height: height)
+ }
+
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
+ let rows = rows(for: subviews, width: bounds.width)
+ var y = bounds.minY
+ for row in rows {
+ var x = bounds.minX
+ let rowH = row.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0
+ for sub in row {
+ let size = sub.sizeThatFits(.unspecified)
+ sub.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
+ x += size.width + spacing
+ }
+ y += rowH + spacing
+ }
+ }
+
+ private func rows(for subviews: Subviews, width: CGFloat) -> [[LayoutSubview]] {
+ var rows: [[LayoutSubview]] = [[]]
+ var rowWidth: CGFloat = 0
+ for sub in subviews {
+ let w = sub.sizeThatFits(.unspecified).width
+ if rowWidth + w > width, !rows.last!.isEmpty {
+ rows.append([])
+ rowWidth = 0
+ }
+ rows[rows.count - 1].append(sub)
+ rowWidth += w + spacing
+ }
+ return rows
+ }
+}
+
#Preview {
ModelInfoView(model: ModelInfo(
id: "anthropic/claude-sonnet-4",
diff --git a/oAI/Views/Screens/ModelSelectorView.swift b/oAI/Views/Screens/ModelSelectorView.swift
index 770405c..aac2e2b 100644
--- a/oAI/Views/Screens/ModelSelectorView.swift
+++ b/oAI/Views/Screens/ModelSelectorView.swift
@@ -38,11 +38,17 @@ struct ModelSelectorView: View {
@State private var filterImageGen = false
@State private var filterThinking = false
@State private var filterFavorites = false
+ @State private var selectedCategory: ModelCategory? = nil
+ @State private var showCategoryPicker = false
@State private var keyboardIndex: Int = -1
@State private var sortOrder: ModelSortOrder = .default
@State private var selectedInfoModel: ModelInfo? = nil
@Bindable private var settings = SettingsService.shared
+ private var categoriesWithModels: Set {
+ Set(models.flatMap(\.categories))
+ }
+
private var filteredModels: [ModelInfo] {
let q = searchText.lowercased()
let filtered = models.filter { model in
@@ -57,8 +63,9 @@ struct ModelSelectorView: View {
let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration
let matchesThinking = !filterThinking || model.capabilities.thinking
let matchesFavorites = !filterFavorites || settings.favoriteModelIds.contains(model.id)
+ let matchesCategory = selectedCategory == nil || model.categories.contains(selectedCategory!)
- return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking && matchesFavorites
+ return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking && matchesFavorites && matchesCategory
}
let favIds = settings.favoriteModelIds
@@ -100,6 +107,40 @@ struct ModelSelectorView: View {
Spacer()
+ // Category picker (only shown when at least one category has models)
+ if !categoriesWithModels.isEmpty {
+ Button(action: { showCategoryPicker.toggle() }) {
+ HStack(spacing: 4) {
+ if let cat = selectedCategory {
+ Circle().fill(cat.color).frame(width: 7, height: 7)
+ Text(LocalizedStringKey(cat.rawValue))
+ } else {
+ Image(systemName: "tag")
+ Text("Category")
+ }
+ }
+ .font(.caption)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 6)
+ .background(selectedCategory != nil ? selectedCategory!.color.opacity(0.18) : Color.gray.opacity(0.1))
+ .foregroundColor(selectedCategory != nil ? selectedCategory!.color : .secondary)
+ .cornerRadius(6)
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .strokeBorder(selectedCategory != nil ? selectedCategory!.color.opacity(0.4) : Color.clear, lineWidth: 1)
+ )
+ }
+ .buttonStyle(.plain)
+ .help("Filter by category")
+ .popover(isPresented: $showCategoryPicker, arrowEdge: .bottom) {
+ CategoryPickerPopover(
+ categoriesWithModels: categoriesWithModels,
+ selectedCategory: $selectedCategory,
+ onSelect: { keyboardIndex = -1 }
+ )
+ }
+ } // end if !categoriesWithModels.isEmpty
+
// Favorites filter star
Button(action: { filterFavorites.toggle(); keyboardIndex = -1 }) {
Image(systemName: filterFavorites ? "star.fill" : "star")
@@ -180,7 +221,7 @@ struct ModelSelectorView: View {
}
}
}
- .frame(minWidth: 600, minHeight: 500)
+ .frame(minWidth: 740, minHeight: 500)
.navigationTitle("Select Model")
#if os(macOS)
.onKeyPress(.downArrow) {
@@ -255,6 +296,7 @@ struct FilterToggle: View {
HStack(spacing: 4) {
Text(icon)
Text(label)
+ .lineLimit(1)
}
.font(.caption)
.padding(.horizontal, 10)
@@ -264,6 +306,68 @@ struct FilterToggle: View {
.cornerRadius(6)
}
.buttonStyle(.plain)
+ .fixedSize()
+ }
+}
+
+// MARK: - Category Picker Popover
+
+struct CategoryPickerPopover: View {
+ let categoriesWithModels: Set
+ @Binding var selectedCategory: ModelCategory?
+ let onSelect: () -> Void
+
+ private let columns = [GridItem(.adaptive(minimum: 130), spacing: 8)]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Text("Filter by Category")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ Spacer()
+ if selectedCategory != nil {
+ Button("Clear") {
+ selectedCategory = nil
+ onSelect()
+ }
+ .font(.caption)
+ .buttonStyle(.plain)
+ .foregroundStyle(.blue)
+ }
+ }
+
+ LazyVGrid(columns: columns, spacing: 8) {
+ ForEach(ModelCategory.allCases.filter { categoriesWithModels.contains($0) }, id: \.rawValue) { cat in
+ let isSelected = selectedCategory == cat
+ Button {
+ selectedCategory = isSelected ? nil : cat
+ onSelect()
+ } label: {
+ HStack(spacing: 6) {
+ Image(systemName: cat.systemImage)
+ .font(.caption)
+ .frame(width: 14)
+ Text(LocalizedStringKey(cat.rawValue))
+ .font(.caption)
+ Spacer()
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 7)
+ .background(isSelected ? cat.color.opacity(0.18) : Color.gray.opacity(0.08))
+ .foregroundColor(isSelected ? cat.color : .primary)
+ .cornerRadius(6)
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .strokeBorder(isSelected ? cat.color.opacity(0.45) : Color.clear, lineWidth: 1)
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ .padding(14)
+ .frame(minWidth: 360)
}
}
@@ -324,7 +428,7 @@ struct ModelRowView: View {
.contentShape(Rectangle())
.onTapGesture { onSelect() }
- // Right side: capabilities + info button
+ // Right side: capabilities + category dots + info button
VStack(alignment: .trailing, spacing: 6) {
// Capability icons
HStack(spacing: 4) {
@@ -335,6 +439,18 @@ struct ModelRowView: View {
if model.capabilities.thinking { Text("\u{1F9E0}").font(.caption) }
}
+ // Category dots
+ if !model.categories.isEmpty {
+ HStack(spacing: 3) {
+ ForEach(model.categories, id: \.rawValue) { cat in
+ Circle()
+ .fill(cat.color)
+ .frame(width: 7, height: 7)
+ .help(cat.rawValue)
+ }
+ }
+ }
+
// Info button
Button(action: onInfo) {
Image(systemName: "info.circle")
diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift
index 7e683ec..aed4728 100644
--- a/oAI/Views/Screens/SettingsView.swift
+++ b/oAI/Views/Screens/SettingsView.swift
@@ -65,6 +65,13 @@ struct SettingsView: View {
@State private var isTestingAnytype = false
@State private var anytypeTestResult: String?
+ // Jarvis state
+ @State private var jarvisURL = ""
+ @State private var jarvisAPIKey = ""
+ @State private var showJarvisKey = false
+ @State private var isTestingJarvis = false
+ @State private var jarvisTestResult: String?
+
// Default model picker state
@State private var showDefaultModelPicker = false
@@ -155,6 +162,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
tabButton(8, icon: "doc.text", label: "Paperless", beta: true)
tabButton(9, icon: "icloud.and.arrow.up", label: "Backup")
tabButton(10, icon: "square.stack.3d.up", label: "Anytype")
+ tabButton(11, icon: "server.rack", label: "Jarvis")
}
.padding(.horizontal, 16)
.padding(.bottom, 12)
@@ -186,6 +194,8 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
backupTab
case 10:
anytypeTab
+ case 11:
+ jarvisTab
default:
generalTab
}
@@ -2047,6 +2057,107 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
}
}
+ // MARK: - Jarvis Tab
+
+ private var jarvisTab: some View {
+ VStack(alignment: .leading, spacing: 20) {
+ VStack(alignment: .leading, spacing: 6) {
+ sectionHeader("Jarvis")
+ formSection {
+ row("Enable Jarvis") {
+ Toggle("", isOn: $settingsService.jarvisEnabled)
+ .toggleStyle(.switch)
+ }
+ }
+ }
+
+ if settingsService.jarvisEnabled {
+ VStack(alignment: .leading, spacing: 6) {
+ sectionHeader("Connection")
+ formSection {
+ row("Server URL") {
+ TextField("https://jarvis.example.com", text: $jarvisURL)
+ .textFieldStyle(.roundedBorder)
+ .frame(maxWidth: 300)
+ .onSubmit { settingsService.jarvisURL = jarvisURL }
+ .onChange(of: jarvisURL) { _, new in settingsService.jarvisURL = new }
+ }
+ rowDivider()
+ row("API Key") {
+ HStack(spacing: 6) {
+ if showJarvisKey {
+ TextField("", text: $jarvisAPIKey)
+ .textFieldStyle(.roundedBorder)
+ .frame(maxWidth: 240)
+ .onSubmit { settingsService.jarvisAPIKey = jarvisAPIKey.isEmpty ? nil : jarvisAPIKey }
+ .onChange(of: jarvisAPIKey) { _, new in
+ settingsService.jarvisAPIKey = new.isEmpty ? nil : new
+ }
+ } else {
+ SecureField("", text: $jarvisAPIKey)
+ .textFieldStyle(.roundedBorder)
+ .frame(maxWidth: 240)
+ .onSubmit { settingsService.jarvisAPIKey = jarvisAPIKey.isEmpty ? nil : jarvisAPIKey }
+ .onChange(of: jarvisAPIKey) { _, new in
+ settingsService.jarvisAPIKey = new.isEmpty ? nil : new
+ }
+ }
+ Button(showJarvisKey ? "Hide" : "Show") {
+ showJarvisKey.toggle()
+ }
+ .buttonStyle(.borderless)
+ .font(.system(size: 13))
+ }
+ }
+ rowDivider()
+ HStack(spacing: 12) {
+ Button(action: { Task { await testJarvisConnection() } }) {
+ HStack {
+ if isTestingJarvis {
+ ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
+ } else {
+ Image(systemName: "checkmark.circle")
+ }
+ Text("Test Connection")
+ }
+ }
+ .disabled(isTestingJarvis || !settingsService.jarvisConfigured)
+ if let result = jarvisTestResult {
+ Text(result)
+ .font(.system(size: 13))
+ .foregroundStyle(result.hasPrefix("✓") ? .green : .red)
+ }
+ Spacer()
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ }
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Generate an API key in your Jarvis settings and paste it above.")
+ }
+ .font(.system(size: 13))
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 4)
+ }
+ }
+ .onAppear {
+ jarvisURL = settingsService.jarvisURL
+ jarvisAPIKey = settingsService.jarvisAPIKey ?? ""
+ }
+ }
+
+ private func testJarvisConnection() async {
+ isTestingJarvis = true
+ jarvisTestResult = nil
+ let ok = await JarvisService.shared.testConnection()
+ await MainActor.run {
+ jarvisTestResult = ok ? "✓ Connected" : "✗ Connection failed"
+ isTestingJarvis = false
+ }
+ }
+
private func testAnytypeConnection() async {
isTestingAnytype = true
anytypeTestResult = nil
@@ -2265,7 +2376,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
.font(.system(size: 11))
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
}
- .frame(minWidth: 60)
+ .frame(minWidth: 55)
.padding(.vertical, 6)
.padding(.horizontal, 4)
.background(selectedTab == tag ? Color.blue.opacity(0.1) : Color.clear)
@@ -2286,6 +2397,8 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
case 7: return "Skills"
case 8: return "Paperless"
case 9: return "Backup"
+ case 10: return "Anytype"
+ case 11: return "Jarvis"
default: return "Settings"
}
}