Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd0ceeab41 | |||
| 13699864d8 | |||
| c2010e272e | |||
| 098c3c3d1e | |||
| 3d6ac578db |
@@ -350,6 +350,12 @@ Contributions are welcome! See [DEVELOPMENT.md](DEVELOPMENT.md) for build instru
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
oAI takes real actions on your behalf — it can send emails, write files, make calendar changes, and post Telegram messages. Review your whitelist and permission settings carefully before use. Content you send is processed by your configured AI provider (Anthropic, OpenRouter, or OpenAI). oAI-Web is provided "as is" without warranty of any kind — the author accepts no responsibility for actions taken by the agent or any consequences thereof. See LICENSE for full terms.
|
||||
|
||||
---
|
||||
|
||||
**⭐ Star this project if you find it useful!**
|
||||
|
||||
**🐛 Found a bug?** [Open an issue](https://gitlab.pm/rune/oai-swift/issues/new)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
var architecture: Architecture? = nil
|
||||
var topProvider: String? = nil
|
||||
var categories: [ModelCategory] = []
|
||||
|
||||
struct Pricing: Codable, Hashable {
|
||||
let prompt: Double // per 1M tokens
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -41,7 +41,7 @@ struct InputBar: View {
|
||||
/// Commands that execute immediately without additional arguments
|
||||
private static let immediateCommands: Set<String> = [
|
||||
"/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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 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",
|
||||
|
||||
@@ -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<ModelCategory> {
|
||||
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<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())
|
||||
.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")
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user