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:
2026-05-12 11:05:47 +02:00
parent c2010e272e
commit 13699864d8
15 changed files with 1795 additions and 8 deletions
+226
View File
@@ -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)"
}
}
}
+176
View File
@@ -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"]
}
}
}
+1
View File
@@ -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