From 13699864d8c135e5091aa98b51fe634ae427622c Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Tue, 12 May 2026 11:05:47 +0200 Subject: [PATCH] New release v2.3.9 - Jarvis integration: manage oAI-Web agents and usage from inside the app (/jarvis command, Settings tab 11) - Model category filter: keyword-based categorisation with popover picker in model selector - Categories shown in ModelInfoView with coloured chips; dot indicators on model rows Co-Authored-By: Claude Sonnet 4.6 --- oAI.xcodeproj/project.pbxproj | 4 +- oAI/Models/JarvisModels.swift | 226 ++++++ oAI/Models/ModelCategory.swift | 176 +++++ oAI/Models/ModelInfo.swift | 1 + oAI/Providers/OpenRouterProvider.swift | 8 +- oAI/Services/JarvisService.swift | 156 ++++ oAI/Services/SettingsService.swift | 41 + oAI/ViewModels/ChatViewModel.swift | 4 + oAI/Views/Main/ChatView.swift | 3 + oAI/Views/Main/InputBar.swift | 3 +- oAI/Views/Screens/HelpView.swift | 8 + oAI/Views/Screens/JarvisView.swift | 866 ++++++++++++++++++++++ oAI/Views/Screens/ModelInfoView.swift | 70 ++ oAI/Views/Screens/ModelSelectorView.swift | 122 ++- oAI/Views/Screens/SettingsView.swift | 115 ++- 15 files changed, 1795 insertions(+), 8 deletions(-) create mode 100644 oAI/Models/JarvisModels.swift create mode 100644 oAI/Models/ModelCategory.swift create mode 100644 oAI/Services/JarvisService.swift create mode 100644 oAI/Views/Screens/JarvisView.swift 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" } }