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:
@@ -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