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 <noreply@anthropic.com>
This commit is contained in:
@@ -283,7 +283,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 2.3.8;
|
MARKETING_VERSION = 2.3.9;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -327,7 +327,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 2.3.8;
|
MARKETING_VERSION = 2.3.9;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ struct ModelInfo: Identifiable, Codable, Hashable {
|
|||||||
let capabilities: ModelCapabilities
|
let capabilities: ModelCapabilities
|
||||||
var architecture: Architecture? = nil
|
var architecture: Architecture? = nil
|
||||||
var topProvider: String? = nil
|
var topProvider: String? = nil
|
||||||
|
var categories: [ModelCategory] = []
|
||||||
|
|
||||||
struct Pricing: Codable, Hashable {
|
struct Pricing: Codable, Hashable {
|
||||||
let prompt: Double // per 1M tokens
|
let prompt: Double // per 1M tokens
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ class OpenRouterProvider: AIProvider {
|
|||||||
let promptPrice = Double(modelData.pricing.prompt) ?? 0.0
|
let promptPrice = Double(modelData.pricing.prompt) ?? 0.0
|
||||||
let completionPrice = Double(modelData.pricing.completion) ?? 0.0
|
let completionPrice = Double(modelData.pricing.completion) ?? 0.0
|
||||||
|
|
||||||
return ModelInfo(
|
var info = ModelInfo(
|
||||||
id: modelData.id,
|
id: modelData.id,
|
||||||
name: modelData.name,
|
name: modelData.name,
|
||||||
description: modelData.description,
|
description: modelData.description,
|
||||||
@@ -122,6 +122,12 @@ class OpenRouterProvider: AIProvider {
|
|||||||
},
|
},
|
||||||
topProvider: modelData.id.components(separatedBy: "/").first
|
topProvider: modelData.id.components(separatedBy: "/").first
|
||||||
)
|
)
|
||||||
|
info.categories = ModelCategory.infer(
|
||||||
|
name: modelData.name,
|
||||||
|
id: modelData.id,
|
||||||
|
description: modelData.description
|
||||||
|
)
|
||||||
|
return info
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
//
|
||||||
|
// JarvisService.swift
|
||||||
|
// oAI
|
||||||
|
//
|
||||||
|
// HTTP client for the Jarvis (oAI-Web) REST API.
|
||||||
|
// Auth: Authorization: Bearer <api-key>
|
||||||
|
//
|
||||||
|
// 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<T: Decodable>(_ path: String) async throws -> T {
|
||||||
|
let data = try await rawData("GET", path: path)
|
||||||
|
return try JSONDecoder().decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func post<T: Decodable, B: Encodable>(_ 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<T: Decodable, B: Encodable>(_ 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<B: Encodable>(_ 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? }
|
||||||
@@ -43,6 +43,7 @@ class SettingsService {
|
|||||||
static let googleSearchEngineID = "googleSearchEngineID"
|
static let googleSearchEngineID = "googleSearchEngineID"
|
||||||
static let anytypeMcpAPIKey = "anytypeMcpAPIKey"
|
static let anytypeMcpAPIKey = "anytypeMcpAPIKey"
|
||||||
static let paperlessAPIToken = "paperlessAPIToken"
|
static let paperlessAPIToken = "paperlessAPIToken"
|
||||||
|
static let jarvisAPIKey = "jarvisAPIKey"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Old keychain keys (for migration only)
|
// Old keychain keys (for migration only)
|
||||||
@@ -500,6 +501,46 @@ class SettingsService {
|
|||||||
return !key.isEmpty
|
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
|
// MARK: - Bash Execution Settings
|
||||||
|
|
||||||
var bashEnabled: Bool {
|
var bashEnabled: Bool {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class ChatViewModel {
|
|||||||
var showHistory: Bool = false
|
var showHistory: Bool = false
|
||||||
var showShortcuts: Bool = false
|
var showShortcuts: Bool = false
|
||||||
var showSkills: Bool = false
|
var showSkills: Bool = false
|
||||||
|
var showJarvis: Bool = false
|
||||||
var modelInfoTarget: ModelInfo? = nil
|
var modelInfoTarget: ModelInfo? = nil
|
||||||
var commandHistory: [String] = []
|
var commandHistory: [String] = []
|
||||||
var historyIndex: Int = 0
|
var historyIndex: Int = 0
|
||||||
@@ -742,6 +743,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
case "/skills":
|
case "/skills":
|
||||||
showSkills = true
|
showSkills = true
|
||||||
|
|
||||||
|
case "/jarvis":
|
||||||
|
showJarvis = true
|
||||||
|
|
||||||
case "/mcp":
|
case "/mcp":
|
||||||
handleMCPCommand(args: args)
|
handleMCPCommand(args: args)
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ struct ChatView: View {
|
|||||||
.sheet(isPresented: $viewModel.showSkills) {
|
.sheet(isPresented: $viewModel.showSkills) {
|
||||||
AgentSkillsView()
|
AgentSkillsView()
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $viewModel.showJarvis) {
|
||||||
|
JarvisView()
|
||||||
|
}
|
||||||
.sheet(item: Binding(
|
.sheet(item: Binding(
|
||||||
get: { MCPService.shared.pendingBashCommand },
|
get: { MCPService.shared.pendingBashCommand },
|
||||||
set: { _ in }
|
set: { _ in }
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ struct InputBar: View {
|
|||||||
/// Commands that execute immediately without additional arguments
|
/// Commands that execute immediately without additional arguments
|
||||||
private static let immediateCommands: Set<String> = [
|
private static let immediateCommands: Set<String> = [
|
||||||
"/help", "/history", "/model", "/clear", "/retry", "/stats", "/config",
|
"/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",
|
"/memory on", "/memory off", "/online on", "/online off",
|
||||||
"/mcp on", "/mcp off", "/mcp status", "/mcp list",
|
"/mcp on", "/mcp off", "/mcp status", "/mcp list",
|
||||||
"/mcp write on", "/mcp write off",
|
"/mcp write on", "/mcp write off",
|
||||||
@@ -279,6 +279,7 @@ struct CommandSuggestionsView: View {
|
|||||||
("/retry", "Retry last message"),
|
("/retry", "Retry last message"),
|
||||||
("/shortcuts", "Manage your prompt shortcuts"),
|
("/shortcuts", "Manage your prompt shortcuts"),
|
||||||
("/skills", "Manage your agent skills"),
|
("/skills", "Manage your agent skills"),
|
||||||
|
("/jarvis", "Open Jarvis agent manager"),
|
||||||
("/memory on", "Enable conversation memory"),
|
("/memory on", "Enable conversation memory"),
|
||||||
("/memory off", "Disable conversation memory"),
|
("/memory off", "Disable conversation memory"),
|
||||||
("/online on", "Enable web search"),
|
("/online on", "Enable web search"),
|
||||||
|
|||||||
@@ -180,6 +180,14 @@ private let helpCategories: [CommandCategory] = [
|
|||||||
examples: ["/mcp write on", "/mcp write off"]
|
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: [
|
CommandCategory(name: "Settings & Stats", icon: "gearshape", commands: [
|
||||||
CommandDetail(
|
CommandDetail(
|
||||||
command: "/config",
|
command: "/config",
|
||||||
|
|||||||
@@ -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<String> = []
|
||||||
|
|
||||||
|
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<Content: View>(_ label: LocalizedStringKey, @ViewBuilder content: () -> Content) -> some View {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.frame(width: 100, alignment: .trailing)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,6 +145,32 @@ struct ModelInfoView: View {
|
|||||||
capabilityBadge(icon: "brain", label: "Thinking", active: model.capabilities.thinking)
|
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)
|
// Architecture (if available)
|
||||||
if let arch = model.architecture {
|
if let arch = model.architecture {
|
||||||
Divider()
|
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 {
|
#Preview {
|
||||||
ModelInfoView(model: ModelInfo(
|
ModelInfoView(model: ModelInfo(
|
||||||
id: "anthropic/claude-sonnet-4",
|
id: "anthropic/claude-sonnet-4",
|
||||||
|
|||||||
@@ -38,11 +38,17 @@ struct ModelSelectorView: View {
|
|||||||
@State private var filterImageGen = false
|
@State private var filterImageGen = false
|
||||||
@State private var filterThinking = false
|
@State private var filterThinking = false
|
||||||
@State private var filterFavorites = 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 keyboardIndex: Int = -1
|
||||||
@State private var sortOrder: ModelSortOrder = .default
|
@State private var sortOrder: ModelSortOrder = .default
|
||||||
@State private var selectedInfoModel: ModelInfo? = nil
|
@State private var selectedInfoModel: ModelInfo? = nil
|
||||||
@Bindable private var settings = SettingsService.shared
|
@Bindable private var settings = SettingsService.shared
|
||||||
|
|
||||||
|
private var categoriesWithModels: Set<ModelCategory> {
|
||||||
|
Set(models.flatMap(\.categories))
|
||||||
|
}
|
||||||
|
|
||||||
private var filteredModels: [ModelInfo] {
|
private var filteredModels: [ModelInfo] {
|
||||||
let q = searchText.lowercased()
|
let q = searchText.lowercased()
|
||||||
let filtered = models.filter { model in
|
let filtered = models.filter { model in
|
||||||
@@ -57,8 +63,9 @@ struct ModelSelectorView: View {
|
|||||||
let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration
|
let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration
|
||||||
let matchesThinking = !filterThinking || model.capabilities.thinking
|
let matchesThinking = !filterThinking || model.capabilities.thinking
|
||||||
let matchesFavorites = !filterFavorites || settings.favoriteModelIds.contains(model.id)
|
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
|
let favIds = settings.favoriteModelIds
|
||||||
@@ -100,6 +107,40 @@ struct ModelSelectorView: View {
|
|||||||
|
|
||||||
Spacer()
|
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
|
// Favorites filter star
|
||||||
Button(action: { filterFavorites.toggle(); keyboardIndex = -1 }) {
|
Button(action: { filterFavorites.toggle(); keyboardIndex = -1 }) {
|
||||||
Image(systemName: filterFavorites ? "star.fill" : "star")
|
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")
|
.navigationTitle("Select Model")
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.onKeyPress(.downArrow) {
|
.onKeyPress(.downArrow) {
|
||||||
@@ -255,6 +296,7 @@ struct FilterToggle: View {
|
|||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text(icon)
|
Text(icon)
|
||||||
Text(label)
|
Text(label)
|
||||||
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
@@ -264,6 +306,68 @@ struct FilterToggle: View {
|
|||||||
.cornerRadius(6)
|
.cornerRadius(6)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.fixedSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Category Picker Popover
|
||||||
|
|
||||||
|
struct CategoryPickerPopover: View {
|
||||||
|
let categoriesWithModels: Set<ModelCategory>
|
||||||
|
@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())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { onSelect() }
|
.onTapGesture { onSelect() }
|
||||||
|
|
||||||
// Right side: capabilities + info button
|
// Right side: capabilities + category dots + info button
|
||||||
VStack(alignment: .trailing, spacing: 6) {
|
VStack(alignment: .trailing, spacing: 6) {
|
||||||
// Capability icons
|
// Capability icons
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
@@ -335,6 +439,18 @@ struct ModelRowView: View {
|
|||||||
if model.capabilities.thinking { Text("\u{1F9E0}").font(.caption) }
|
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
|
// Info button
|
||||||
Button(action: onInfo) {
|
Button(action: onInfo) {
|
||||||
Image(systemName: "info.circle")
|
Image(systemName: "info.circle")
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ struct SettingsView: View {
|
|||||||
@State private var isTestingAnytype = false
|
@State private var isTestingAnytype = false
|
||||||
@State private var anytypeTestResult: String?
|
@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
|
// Default model picker state
|
||||||
@State private var showDefaultModelPicker = false
|
@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(8, icon: "doc.text", label: "Paperless", beta: true)
|
||||||
tabButton(9, icon: "icloud.and.arrow.up", label: "Backup")
|
tabButton(9, icon: "icloud.and.arrow.up", label: "Backup")
|
||||||
tabButton(10, icon: "square.stack.3d.up", label: "Anytype")
|
tabButton(10, icon: "square.stack.3d.up", label: "Anytype")
|
||||||
|
tabButton(11, icon: "server.rack", label: "Jarvis")
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.bottom, 12)
|
.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
|
backupTab
|
||||||
case 10:
|
case 10:
|
||||||
anytypeTab
|
anytypeTab
|
||||||
|
case 11:
|
||||||
|
jarvisTab
|
||||||
default:
|
default:
|
||||||
generalTab
|
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 {
|
private func testAnytypeConnection() async {
|
||||||
isTestingAnytype = true
|
isTestingAnytype = true
|
||||||
anytypeTestResult = nil
|
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))
|
.font(.system(size: 11))
|
||||||
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
|
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
|
||||||
}
|
}
|
||||||
.frame(minWidth: 60)
|
.frame(minWidth: 55)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
.background(selectedTab == tag ? Color.blue.opacity(0.1) : Color.clear)
|
.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 7: return "Skills"
|
||||||
case 8: return "Paperless"
|
case 8: return "Paperless"
|
||||||
case 9: return "Backup"
|
case 9: return "Backup"
|
||||||
|
case 10: return "Anytype"
|
||||||
|
case 11: return "Jarvis"
|
||||||
default: return "Settings"
|
default: return "Settings"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user