Merge pull request '2.5.2' (#12) from 2.5.2 into main
Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
@@ -388,7 +388,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.5.1;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.Confab;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -440,7 +440,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.5.1;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.Confab;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
|
||||
+6000
-92
File diff suppressed because it is too large
Load Diff
@@ -69,7 +69,7 @@ struct JarvisAgentInput: Codable, Sendable {
|
||||
struct JarvisAgentRun: Identifiable, Codable, Sendable {
|
||||
let id: String
|
||||
let agentId: String?
|
||||
let status: String // "running" | "completed" | "failed" | "stopped"
|
||||
let status: String // "running" | "success" | "failed"/"error" | "stopped" (server-observed; not formally documented)
|
||||
let startedAt: String?
|
||||
let finishedAt: String?
|
||||
let output: String?
|
||||
@@ -80,10 +80,11 @@ struct JarvisAgentRun: Identifiable, Codable, Sendable {
|
||||
let triggerType: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, status, output, error
|
||||
case id, status, error
|
||||
case agentId = "agent_id"
|
||||
case startedAt = "started_at"
|
||||
case finishedAt = "finished_at"
|
||||
case finishedAt = "ended_at"
|
||||
case output = "result"
|
||||
case costUsd = "cost_usd"
|
||||
case inputTokens = "input_tokens"
|
||||
case outputTokens = "output_tokens"
|
||||
|
||||
@@ -25,6 +25,7 @@ import Foundation
|
||||
|
||||
struct UsageStats: Sendable {
|
||||
var totalMessages: Int
|
||||
var totalQuestions: Int
|
||||
var totalTokens: Int
|
||||
var totalCost: Double
|
||||
var hasCostData: Bool
|
||||
@@ -33,6 +34,7 @@ struct UsageStats: Sendable {
|
||||
|
||||
nonisolated init(
|
||||
totalMessages: Int = 0,
|
||||
totalQuestions: Int = 0,
|
||||
totalTokens: Int = 0,
|
||||
totalCost: Double = 0.0,
|
||||
hasCostData: Bool = false,
|
||||
@@ -40,6 +42,7 @@ struct UsageStats: Sendable {
|
||||
lastMessageDate: Date? = nil
|
||||
) {
|
||||
self.totalMessages = totalMessages
|
||||
self.totalQuestions = totalQuestions
|
||||
self.totalTokens = totalTokens
|
||||
self.totalCost = totalCost
|
||||
self.hasCostData = hasCostData
|
||||
@@ -66,6 +69,7 @@ struct ModelUsageStat: Identifiable, Sendable {
|
||||
var id: String { modelId }
|
||||
let modelId: String
|
||||
var messageCount: Int
|
||||
var questionCount: Int
|
||||
var totalTokens: Int
|
||||
var totalCost: Double
|
||||
var hasCostData: Bool
|
||||
@@ -74,6 +78,7 @@ struct ModelUsageStat: Identifiable, Sendable {
|
||||
nonisolated init(
|
||||
modelId: String,
|
||||
messageCount: Int,
|
||||
questionCount: Int = 0,
|
||||
totalTokens: Int,
|
||||
totalCost: Double,
|
||||
hasCostData: Bool,
|
||||
@@ -81,6 +86,7 @@ struct ModelUsageStat: Identifiable, Sendable {
|
||||
) {
|
||||
self.modelId = modelId
|
||||
self.messageCount = messageCount
|
||||
self.questionCount = questionCount
|
||||
self.totalTokens = totalTokens
|
||||
self.totalCost = totalCost
|
||||
self.hasCostData = hasCostData
|
||||
@@ -102,6 +108,74 @@ struct ModelUsageStat: Identifiable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Usage grouped by provider (openrouter/anthropic/openai/ollama/appleOnDevice), from `usage_events`.
|
||||
struct ProviderUsageStat: Identifiable, Sendable {
|
||||
var id: String { provider }
|
||||
let provider: String
|
||||
var questionCount: Int
|
||||
var totalTokens: Int
|
||||
var totalCost: Double
|
||||
var hasCostData: Bool
|
||||
var lastUsed: Date
|
||||
|
||||
nonisolated init(
|
||||
provider: String,
|
||||
questionCount: Int,
|
||||
totalTokens: Int,
|
||||
totalCost: Double,
|
||||
hasCostData: Bool,
|
||||
lastUsed: Date
|
||||
) {
|
||||
self.provider = provider
|
||||
self.questionCount = questionCount
|
||||
self.totalTokens = totalTokens
|
||||
self.totalCost = totalCost
|
||||
self.hasCostData = hasCostData
|
||||
self.lastUsed = lastUsed
|
||||
}
|
||||
|
||||
var totalTokensDisplay: String {
|
||||
if totalTokens >= 1_000_000 {
|
||||
return String(format: "%.1fM", Double(totalTokens) / 1_000_000)
|
||||
} else if totalTokens >= 1000 {
|
||||
return String(format: "%.1fK", Double(totalTokens) / 1000)
|
||||
} else {
|
||||
return "\(totalTokens)"
|
||||
}
|
||||
}
|
||||
|
||||
var totalCostDisplay: String {
|
||||
hasCostData ? String(format: "$%.4f", totalCost) : "N/A"
|
||||
}
|
||||
}
|
||||
|
||||
/// One day's usage totals, for time-series charting in the Analytics view.
|
||||
struct DailyUsageStat: Identifiable, Sendable {
|
||||
var id: Date { day }
|
||||
let day: Date
|
||||
var messageCount: Int
|
||||
var questionCount: Int
|
||||
var totalTokens: Int
|
||||
var totalCost: Double
|
||||
var hasCostData: Bool
|
||||
|
||||
nonisolated init(
|
||||
day: Date,
|
||||
messageCount: Int,
|
||||
questionCount: Int,
|
||||
totalTokens: Int,
|
||||
totalCost: Double,
|
||||
hasCostData: Bool
|
||||
) {
|
||||
self.day = day
|
||||
self.messageCount = messageCount
|
||||
self.questionCount = questionCount
|
||||
self.totalTokens = totalTokens
|
||||
self.totalCost = totalCost
|
||||
self.hasCostData = hasCostData
|
||||
}
|
||||
}
|
||||
|
||||
struct ConversationUsageStat: Identifiable, Sendable {
|
||||
let conversationId: UUID
|
||||
var id: UUID { conversationId }
|
||||
@@ -141,3 +215,33 @@ struct ConversationUsageStat: Identifiable, Sendable {
|
||||
hasCostData ? String(format: "$%.4f", totalCost) : "N/A"
|
||||
}
|
||||
}
|
||||
|
||||
/// Time window for the Analytics view. `.total` means all-time (no lower bound).
|
||||
enum AnalyticsTimeframe: String, CaseIterable, Identifiable, Sendable {
|
||||
case today
|
||||
case last7Days
|
||||
case week
|
||||
case month
|
||||
case year
|
||||
case total
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// Bounds for this timeframe. `start` is nil for `.total` (all time, no lower bound).
|
||||
nonisolated func dateRange(now: Date = Date(), calendar: Calendar = .current) -> (start: Date?, end: Date) {
|
||||
switch self {
|
||||
case .today:
|
||||
return (calendar.startOfDay(for: now), now)
|
||||
case .last7Days:
|
||||
return (calendar.date(byAdding: .day, value: -7, to: now) ?? now, now)
|
||||
case .week:
|
||||
return (calendar.dateInterval(of: .weekOfYear, for: now)?.start ?? now, now)
|
||||
case .month:
|
||||
return (calendar.dateInterval(of: .month, for: now)?.start ?? now, now)
|
||||
case .year:
|
||||
return (calendar.dateInterval(of: .year, for: now)?.start ?? now, now)
|
||||
case .total:
|
||||
return (nil, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,28 +223,32 @@ struct StreamChunk {
|
||||
|
||||
// MARK: - Tool Definition
|
||||
|
||||
struct Tool: Codable {
|
||||
// Plain data types for JSON tool-schema encoding — marked `nonisolated` throughout (each nested
|
||||
// type independently, since `-default-isolation=MainActor` doesn't cascade isolation-freedom from
|
||||
// an outer type to its nested declarations) so they stay usable from `nonisolated` contexts like
|
||||
// ExternalMCPManager's pure conversion helpers, not just from the main actor.
|
||||
nonisolated struct Tool: Codable {
|
||||
let type: String
|
||||
let function: Function
|
||||
|
||||
struct Function: Codable {
|
||||
|
||||
nonisolated struct Function: Codable {
|
||||
let name: String
|
||||
let description: String
|
||||
let parameters: Parameters
|
||||
|
||||
struct Parameters: Codable {
|
||||
|
||||
nonisolated struct Parameters: Codable {
|
||||
let type: String
|
||||
let properties: [String: Property]
|
||||
let required: [String]?
|
||||
|
||||
struct Property: Codable {
|
||||
|
||||
nonisolated struct Property: Codable {
|
||||
let type: String
|
||||
let description: String
|
||||
let `enum`: [String]?
|
||||
let items: Items?
|
||||
|
||||
/// Item schema for `type: "array"` properties (e.g. an array of strings).
|
||||
struct Items: Codable {
|
||||
nonisolated struct Items: Codable {
|
||||
let type: String
|
||||
}
|
||||
|
||||
|
||||
@@ -34,40 +34,20 @@ class AnthropicProvider: AIProvider {
|
||||
maxContextLength: nil
|
||||
)
|
||||
|
||||
enum AuthMode {
|
||||
case apiKey(String)
|
||||
case oauth
|
||||
}
|
||||
|
||||
private let authMode: AuthMode
|
||||
private let apiKey: String
|
||||
private let baseURL = "https://api.anthropic.com/v1"
|
||||
private let apiVersion = "2023-06-01"
|
||||
private let session: URLSession
|
||||
|
||||
/// Create with a standard API key
|
||||
init(apiKey: String) {
|
||||
self.authMode = .apiKey(apiKey)
|
||||
self.apiKey = apiKey
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 180 // 3 minutes for initial response (tool use needs thinking time)
|
||||
config.timeoutIntervalForResource = 600 // 10 minutes total
|
||||
self.session = URLSession(configuration: config)
|
||||
}
|
||||
|
||||
/// Create with OAuth (Pro/Max subscription)
|
||||
init(oauth: Bool) {
|
||||
self.authMode = .oauth
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 180 // 3 minutes for initial response (tool use needs thinking time)
|
||||
config.timeoutIntervalForResource = 600 // 10 minutes total
|
||||
self.session = URLSession(configuration: config)
|
||||
}
|
||||
|
||||
/// Whether this provider is using OAuth authentication
|
||||
var isOAuth: Bool {
|
||||
if case .oauth = authMode { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Models
|
||||
|
||||
/// Local metadata used to enrich API results (pricing, context length) and as offline fallback.
|
||||
@@ -555,29 +535,14 @@ class AnthropicProvider: AIProvider {
|
||||
|
||||
// MARK: - Auth Helpers
|
||||
|
||||
/// Apply auth headers based on mode (API key or OAuth Bearer)
|
||||
/// Apply auth headers
|
||||
private func applyAuth(to request: inout URLRequest) async throws {
|
||||
switch authMode {
|
||||
case .apiKey(let key):
|
||||
request.addValue(key, forHTTPHeaderField: "x-api-key")
|
||||
request.addValue(apiVersion, forHTTPHeaderField: "anthropic-version")
|
||||
|
||||
case .oauth:
|
||||
let token = try await AnthropicOAuthService.shared.getValidAccessToken()
|
||||
request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.addValue(apiVersion, forHTTPHeaderField: "anthropic-version")
|
||||
request.addValue("oauth-2025-04-20,interleaved-thinking-2025-05-14", forHTTPHeaderField: "anthropic-beta")
|
||||
}
|
||||
request.addValue(apiKey, forHTTPHeaderField: "x-api-key")
|
||||
request.addValue(apiVersion, forHTTPHeaderField: "anthropic-version")
|
||||
}
|
||||
|
||||
/// Build the messages endpoint URL, appending ?beta=true for OAuth
|
||||
private var messagesURL: URL {
|
||||
switch authMode {
|
||||
case .apiKey:
|
||||
return URL(string: "\(baseURL)/messages")!
|
||||
case .oauth:
|
||||
return URL(string: "\(baseURL)/messages?beta=true")!
|
||||
}
|
||||
URL(string: "\(baseURL)/messages")!
|
||||
}
|
||||
|
||||
// MARK: - Request Building
|
||||
|
||||
@@ -47,11 +47,18 @@ struct OpenRouterChatRequest: Codable {
|
||||
let modalities: [String]?
|
||||
let reasoning: ReasoningAPIConfig?
|
||||
let cacheControl: CacheControl?
|
||||
let usage: UsageOptions?
|
||||
|
||||
struct CacheControl: Codable {
|
||||
let type: String
|
||||
}
|
||||
|
||||
/// Requests OpenRouter to include the actual billed USD cost in the response's `usage`
|
||||
/// object — needed for models priced outside plain per-token rates (e.g. per-image).
|
||||
struct UsageOptions: Codable {
|
||||
let include: Bool
|
||||
}
|
||||
|
||||
struct APIMessage: Codable {
|
||||
let role: String
|
||||
let content: MessageContent
|
||||
@@ -141,6 +148,7 @@ struct OpenRouterChatRequest: Codable {
|
||||
case toolChoice = "tool_choice"
|
||||
case modalities
|
||||
case reasoning
|
||||
case usage
|
||||
case cacheControl = "cache_control"
|
||||
}
|
||||
}
|
||||
@@ -230,6 +238,9 @@ struct OpenRouterChatResponse: Codable {
|
||||
let completionTokens: Int
|
||||
let totalTokens: Int
|
||||
let promptTokensDetails: PromptTokensDetails?
|
||||
/// Actual billed USD cost — only present when the request opted in via `usage.include`.
|
||||
/// Needed for models priced outside plain per-token rates (e.g. per-image generation).
|
||||
let cost: Double?
|
||||
|
||||
struct PromptTokensDetails: Codable {
|
||||
let cachedTokens: Int?
|
||||
@@ -246,6 +257,7 @@ struct OpenRouterChatResponse: Codable {
|
||||
case completionTokens = "completion_tokens"
|
||||
case totalTokens = "total_tokens"
|
||||
case promptTokensDetails = "prompt_tokens_details"
|
||||
case cost
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -485,10 +497,9 @@ struct OpenRouterImageGenerationResponse: Codable {
|
||||
|
||||
struct OpenRouterErrorResponse: Codable {
|
||||
let error: ErrorDetail
|
||||
|
||||
|
||||
struct ErrorDetail: Codable {
|
||||
let message: String
|
||||
let type: String?
|
||||
let code: String?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +276,8 @@ class OpenRouterProvider: AIProvider {
|
||||
var body: [String: Any] = [
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": false
|
||||
"stream": false,
|
||||
"usage": ["include": true]
|
||||
]
|
||||
if let tools = tools {
|
||||
let toolsData = try JSONEncoder().encode(tools)
|
||||
@@ -347,8 +348,16 @@ class OpenRouterProvider: AIProvider {
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
Log.api.error("OpenRouter stream HTTP \(httpResponse.statusCode)")
|
||||
continuation.finish(throwing: ProviderError.unknown("HTTP \(httpResponse.statusCode)"))
|
||||
var errorBody = ""
|
||||
for try await line in bytes.lines { errorBody += line }
|
||||
if let errorData = errorBody.data(using: .utf8),
|
||||
let errorResponse = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: errorData) {
|
||||
Log.api.error("OpenRouter stream HTTP \(httpResponse.statusCode): \(errorResponse.error.message)")
|
||||
continuation.finish(throwing: ProviderError.unknown(errorResponse.error.message))
|
||||
} else {
|
||||
Log.api.error("OpenRouter stream HTTP \(httpResponse.statusCode)")
|
||||
continuation.finish(throwing: ProviderError.unknown("HTTP \(httpResponse.statusCode)"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -497,7 +506,8 @@ class OpenRouterProvider: AIProvider {
|
||||
toolChoice: request.tools != nil ? "auto" : nil,
|
||||
modalities: request.imageGeneration ? ["text", "image"] : nil,
|
||||
reasoning: reasoningConfig,
|
||||
cacheControl: cacheControl
|
||||
cacheControl: cacheControl,
|
||||
usage: OpenRouterChatRequest.UsageOptions(include: true)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -536,7 +546,8 @@ class OpenRouterProvider: AIProvider {
|
||||
completionTokens: usage.completionTokens,
|
||||
totalTokens: usage.totalTokens,
|
||||
cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens,
|
||||
cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens
|
||||
cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens,
|
||||
rawCostUSD: usage.cost
|
||||
)
|
||||
},
|
||||
created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)),
|
||||
@@ -577,7 +588,8 @@ class OpenRouterProvider: AIProvider {
|
||||
completionTokens: usage.completionTokens,
|
||||
totalTokens: usage.totalTokens,
|
||||
cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens,
|
||||
cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens
|
||||
cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens,
|
||||
rawCostUSD: usage.cost
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -101,8 +101,7 @@ class ProviderRegistry {
|
||||
case .openrouter:
|
||||
return settings.openrouterAPIKey != nil && !settings.openrouterAPIKey!.isEmpty
|
||||
case .anthropic:
|
||||
return AnthropicOAuthService.shared.isAuthenticated
|
||||
|| (settings.anthropicAPIKey != nil && !settings.anthropicAPIKey!.isEmpty)
|
||||
return settings.anthropicAPIKey != nil && !settings.anthropicAPIKey!.isEmpty
|
||||
case .openai:
|
||||
return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty
|
||||
case .ollama:
|
||||
|
||||
@@ -1838,7 +1838,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2026 Confab - Rune Olsen. For support or feedback, visit <a href="https://gitlab.pm/rune/oai-swift">gitlab.pm</a> or <a href="mailto:support@fubar.pm?subject=Confab Support&body=What can I help you with?">Contact Us</a>.</p>
|
||||
<p>© 2025 - <span id="year"></span> <script>document.getElementById('year').textContent = new Date().getFullYear();</script> Confab - Rune Olsen. For support or feedback, visit <a href="https://gitlab.pm/rune/oai-swift">gitlab.pm</a> or <a href="https://confab.no/#contact">Contact Us</a>.</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
//
|
||||
// AnthropicOAuthService.swift
|
||||
// Confab
|
||||
//
|
||||
// OAuth 2.0 PKCE flow for Anthropic Pro/Max subscription login
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
//
|
||||
// This file is part of Confab.
|
||||
//
|
||||
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||
// You may use, study, modify, and share it for any noncommercial
|
||||
// purpose. Commercial use — including selling Confab or any part of
|
||||
// it, standalone or bundled into another product or service —
|
||||
// requires a separate commercial license from the copyright holder.
|
||||
//
|
||||
// See the LICENSE file or
|
||||
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||
// the full license text. For commercial licensing, contact Rune
|
||||
// Olsen via <https://confab.no>.
|
||||
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import Security
|
||||
|
||||
@Observable
|
||||
class AnthropicOAuthService {
|
||||
static let shared = AnthropicOAuthService()
|
||||
|
||||
// OAuth configuration (matches Claude Code CLI)
|
||||
private let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
private let redirectURI = "https://console.anthropic.com/oauth/code/callback"
|
||||
private let scope = "org:create_api_key user:profile user:inference"
|
||||
private let tokenEndpoint = "https://console.anthropic.com/v1/oauth/token"
|
||||
|
||||
// Keychain keys
|
||||
private enum Keys {
|
||||
static let accessToken = "com.oai.anthropic.oauth.accessToken"
|
||||
static let refreshToken = "com.oai.anthropic.oauth.refreshToken"
|
||||
static let expiresAt = "com.oai.anthropic.oauth.expiresAt"
|
||||
}
|
||||
|
||||
// PKCE state for current flow
|
||||
private var currentVerifier: String?
|
||||
|
||||
// Observable state
|
||||
var isAuthenticated: Bool { accessToken != nil }
|
||||
var isLoggingIn = false
|
||||
|
||||
// MARK: - Token Access
|
||||
|
||||
var accessToken: String? {
|
||||
getKeychainValue(for: Keys.accessToken)
|
||||
}
|
||||
|
||||
private var refreshToken: String? {
|
||||
getKeychainValue(for: Keys.refreshToken)
|
||||
}
|
||||
|
||||
private var expiresAt: Date? {
|
||||
guard let str = getKeychainValue(for: Keys.expiresAt),
|
||||
let interval = Double(str) else { return nil }
|
||||
return Date(timeIntervalSince1970: interval)
|
||||
}
|
||||
|
||||
var isTokenExpired: Bool {
|
||||
guard let expires = expiresAt else { return true }
|
||||
return Date() >= expires
|
||||
}
|
||||
|
||||
// MARK: - Step 1: Generate Authorization URL
|
||||
|
||||
func generateAuthorizationURL() -> URL {
|
||||
let verifier = generateCodeVerifier()
|
||||
currentVerifier = verifier
|
||||
let challenge = generateCodeChallenge(from: verifier)
|
||||
|
||||
var components = URLComponents(string: "https://claude.ai/oauth/authorize")!
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "code", value: "true"),
|
||||
URLQueryItem(name: "client_id", value: clientId),
|
||||
URLQueryItem(name: "response_type", value: "code"),
|
||||
URLQueryItem(name: "redirect_uri", value: redirectURI),
|
||||
URLQueryItem(name: "scope", value: scope),
|
||||
URLQueryItem(name: "code_challenge", value: challenge),
|
||||
URLQueryItem(name: "code_challenge_method", value: "S256"),
|
||||
URLQueryItem(name: "state", value: verifier),
|
||||
]
|
||||
|
||||
return components.url!
|
||||
}
|
||||
|
||||
// MARK: - Step 2: Exchange Code for Tokens
|
||||
|
||||
func exchangeCode(_ pastedCode: String) async throws {
|
||||
guard let verifier = currentVerifier else {
|
||||
throw OAuthError.noVerifier
|
||||
}
|
||||
|
||||
// Code format: "auth_code#state"
|
||||
let parts = pastedCode.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "#")
|
||||
let authCode: String
|
||||
let state: String
|
||||
|
||||
if parts.count >= 2 {
|
||||
authCode = parts[0]
|
||||
state = parts.dropFirst().joined(separator: "#")
|
||||
} else {
|
||||
// If no # separator, treat entire string as the code
|
||||
authCode = pastedCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
state = verifier
|
||||
}
|
||||
|
||||
Log.api.info("Exchanging OAuth code for tokens")
|
||||
|
||||
let body: [String: String] = [
|
||||
"code": authCode,
|
||||
"state": state,
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": clientId,
|
||||
"redirect_uri": redirectURI,
|
||||
"code_verifier": verifier,
|
||||
]
|
||||
|
||||
let tokenResponse = try await postTokenRequest(body)
|
||||
saveTokens(tokenResponse)
|
||||
currentVerifier = nil
|
||||
|
||||
Log.api.info("OAuth login successful, token expires in \(tokenResponse.expiresIn)s")
|
||||
}
|
||||
|
||||
// MARK: - Token Refresh
|
||||
|
||||
func refreshAccessToken() async throws {
|
||||
guard let refresh = refreshToken else {
|
||||
throw OAuthError.noRefreshToken
|
||||
}
|
||||
|
||||
Log.api.info("Refreshing OAuth access token")
|
||||
|
||||
let body: [String: String] = [
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh,
|
||||
"client_id": clientId,
|
||||
]
|
||||
|
||||
let tokenResponse = try await postTokenRequest(body)
|
||||
saveTokens(tokenResponse)
|
||||
|
||||
Log.api.info("OAuth token refreshed successfully")
|
||||
}
|
||||
|
||||
/// Returns a valid access token, refreshing if needed
|
||||
func getValidAccessToken() async throws -> String {
|
||||
guard let token = accessToken else {
|
||||
throw OAuthError.notAuthenticated
|
||||
}
|
||||
|
||||
if isTokenExpired {
|
||||
try await refreshAccessToken()
|
||||
guard let newToken = accessToken else {
|
||||
throw OAuthError.notAuthenticated
|
||||
}
|
||||
return newToken
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
// MARK: - Logout
|
||||
|
||||
func logout() {
|
||||
deleteKeychainValue(for: Keys.accessToken)
|
||||
deleteKeychainValue(for: Keys.refreshToken)
|
||||
deleteKeychainValue(for: Keys.expiresAt)
|
||||
currentVerifier = nil
|
||||
Log.api.info("OAuth logout complete")
|
||||
}
|
||||
|
||||
// MARK: - PKCE Helpers
|
||||
|
||||
private func generateCodeVerifier() -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: 32)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
return Data(bytes).base64URLEncoded()
|
||||
}
|
||||
|
||||
private func generateCodeChallenge(from verifier: String) -> String {
|
||||
let data = Data(verifier.utf8)
|
||||
let hash = SHA256.hash(data: data)
|
||||
return Data(hash).base64URLEncoded()
|
||||
}
|
||||
|
||||
// MARK: - Token Request
|
||||
|
||||
private func postTokenRequest(_ body: [String: String]) async throws -> TokenResponse {
|
||||
var request = URLRequest(url: URL(string: tokenEndpoint)!)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONEncoder().encode(body)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw OAuthError.invalidResponse
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||
Log.api.error("OAuth token exchange failed HTTP \(httpResponse.statusCode): \(errorBody)")
|
||||
throw OAuthError.tokenExchangeFailed(httpResponse.statusCode, errorBody)
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(TokenResponse.self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - Token Storage
|
||||
|
||||
private func saveTokens(_ response: TokenResponse) {
|
||||
setKeychainValue(response.accessToken, for: Keys.accessToken)
|
||||
if let refresh = response.refreshToken {
|
||||
setKeychainValue(refresh, for: Keys.refreshToken)
|
||||
}
|
||||
let expiresAt = Date().addingTimeInterval(TimeInterval(response.expiresIn))
|
||||
setKeychainValue(String(expiresAt.timeIntervalSince1970), for: Keys.expiresAt)
|
||||
}
|
||||
|
||||
// MARK: - Keychain Helpers
|
||||
|
||||
private func getKeychainValue(for key: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var ref: AnyObject?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &ref) == errSecSuccess,
|
||||
let data = ref as? Data,
|
||||
let value = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private func setKeychainValue(_ value: String, for key: String) {
|
||||
guard let data = value.data(using: .utf8) else { return }
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key,
|
||||
]
|
||||
let attrs: [String: Any] = [kSecValueData as String: data]
|
||||
let status = SecItemUpdate(query as CFDictionary, attrs as CFDictionary)
|
||||
if status == errSecItemNotFound {
|
||||
var newItem = query
|
||||
newItem[kSecValueData as String] = data
|
||||
SecItemAdd(newItem as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteKeychainValue(for key: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
struct TokenResponse: Decodable {
|
||||
let accessToken: String
|
||||
let refreshToken: String?
|
||||
let expiresIn: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessToken = "access_token"
|
||||
case refreshToken = "refresh_token"
|
||||
case expiresIn = "expires_in"
|
||||
}
|
||||
}
|
||||
|
||||
enum OAuthError: LocalizedError {
|
||||
case noVerifier
|
||||
case noRefreshToken
|
||||
case notAuthenticated
|
||||
case invalidResponse
|
||||
case tokenExchangeFailed(Int, String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noVerifier: return "No PKCE verifier — start the login flow first."
|
||||
case .noRefreshToken: return "No refresh token available. Please log in again."
|
||||
case .notAuthenticated: return "Not authenticated. Please log in."
|
||||
case .invalidResponse: return "Invalid response from Anthropic OAuth server."
|
||||
case .tokenExchangeFailed(let code, let body):
|
||||
return "Token exchange failed (HTTP \(code)): \(body)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Base64URL Encoding
|
||||
|
||||
private extension Data {
|
||||
func base64URLEncoded() -> String {
|
||||
base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
//
|
||||
// CLIServerService.swift
|
||||
// Confab
|
||||
//
|
||||
// Local Unix-socket server for one-shot shell/CLI access to a single fixed model
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
//
|
||||
// This file is part of Confab.
|
||||
//
|
||||
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||
// You may use, study, modify, and share it for any noncommercial
|
||||
// purpose. Commercial use — including selling Confab or any part of
|
||||
// it, standalone or bundled into another product or service —
|
||||
// requires a separate commercial license from the copyright holder.
|
||||
//
|
||||
// See the LICENSE file or
|
||||
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||
// the full license text. For commercial licensing, contact Rune
|
||||
// Olsen via <https://confab.no>.
|
||||
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
import os
|
||||
|
||||
/// Local Unix-domain-socket server exposing one-shot, non-streaming text completions from a
|
||||
/// single fixed model (Settings → MCP → CLI Access) — e.g. an `ai "prompt"` shell function.
|
||||
/// Deliberately minimal for v1: no tool-calling, no streaming, no per-request model override.
|
||||
/// Speaks a minimal HTTP/1.1 subset (just enough for `curl --unix-socket`) rather than a custom
|
||||
/// protocol, so it stays curl-friendly and trivially extensible — new fields can be added to the
|
||||
/// JSON request/response bodies later without changing the transport.
|
||||
final class CLIServerService {
|
||||
static let shared = CLIServerService()
|
||||
private init() {}
|
||||
|
||||
private let log = Logger(subsystem: Log.subsystem, category: "cli")
|
||||
|
||||
/// All listener/connection callbacks run on this single serial queue, so `activeConnections`
|
||||
/// never needs a lock — Network.framework callbacks don't run on the main thread.
|
||||
private let queue = DispatchQueue(label: "com.oai.Confab.cliserver")
|
||||
private var listener: NWListener?
|
||||
private var isListenerReady = false
|
||||
private var activeConnections: [ObjectIdentifier: NWConnection] = [:]
|
||||
|
||||
/// How long to wait for `NWListener` to report `.ready` before giving up. See `scheduleBindTimeout`.
|
||||
private static let bindTimeout: TimeInterval = 8
|
||||
|
||||
static let socketPath: String = {
|
||||
(("~/Library/Application Support/oAI" as NSString).expandingTildeInPath as NSString)
|
||||
.appendingPathComponent("cli.sock")
|
||||
}()
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func start() {
|
||||
guard SettingsService.shared.cliServerEnabled else {
|
||||
log.info("CLI server disabled")
|
||||
return
|
||||
}
|
||||
startListening()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
listener?.cancel()
|
||||
listener = nil
|
||||
for (_, connection) in activeConnections { connection.cancel() }
|
||||
activeConnections.removeAll()
|
||||
try? FileManager.default.removeItem(atPath: Self.socketPath)
|
||||
log.info("CLI server stopped")
|
||||
}
|
||||
|
||||
/// Call after toggling cliServerEnabled or changing the provider/model, so the change takes
|
||||
/// effect immediately instead of requiring an app relaunch.
|
||||
func restart() {
|
||||
stop()
|
||||
start()
|
||||
}
|
||||
|
||||
private func startListening() {
|
||||
// Remove a stale socket file left behind by an unclean shutdown (bind() fails on an
|
||||
// existing path even if nothing is listening on it anymore).
|
||||
try? FileManager.default.removeItem(atPath: Self.socketPath)
|
||||
isListenerReady = false
|
||||
|
||||
let params = NWParameters()
|
||||
params.defaultProtocolStack.transportProtocol = NWProtocolTCP.Options()
|
||||
params.requiredLocalEndpoint = NWEndpoint.unix(path: Self.socketPath)
|
||||
params.allowLocalEndpointReuse = true
|
||||
|
||||
do {
|
||||
let newListener = try NWListener(using: params)
|
||||
newListener.newConnectionHandler = { [weak self] connection in
|
||||
self?.handle(connection: connection)
|
||||
}
|
||||
newListener.stateUpdateHandler = { [weak self] state in
|
||||
switch state {
|
||||
case .ready:
|
||||
self?.isListenerReady = true
|
||||
self?.log.info("CLI server listening at \(Self.socketPath)")
|
||||
case .failed(let error):
|
||||
self?.log.error("CLI server listener failed: \(error.localizedDescription)")
|
||||
self?.listener = nil
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
newListener.start(queue: queue)
|
||||
listener = newListener
|
||||
scheduleBindTimeout(for: newListener)
|
||||
} catch {
|
||||
log.error("Failed to start CLI server: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// `NWListener`'s state transitions are asynchronous and normally reach `.ready` or `.failed`
|
||||
/// within milliseconds — but a real incident (2026-08-14) showed a state where `lsof`
|
||||
/// confirmed Confab held the socket open, yet every connection attempt was refused and
|
||||
/// nothing was ever logged, with no crash and no error. Whatever the exact cause, a listener
|
||||
/// that never resolves either way is indistinguishable from a working one without this: it
|
||||
/// bounds the wait so a stuck bind becomes a visible log line and a cleared listener slot,
|
||||
/// instead of silently pretending to work forever.
|
||||
private func scheduleBindTimeout(for candidate: NWListener) {
|
||||
queue.asyncAfter(deadline: .now() + Self.bindTimeout) { [weak self] in
|
||||
guard let self, self.listener === candidate, !self.isListenerReady else { return }
|
||||
self.log.error("CLI server did not become ready within \(Int(Self.bindTimeout))s — giving up")
|
||||
candidate.cancel()
|
||||
self.listener = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Connection handling
|
||||
|
||||
private func handle(connection: NWConnection) {
|
||||
let id = ObjectIdentifier(connection)
|
||||
activeConnections[id] = connection
|
||||
connection.stateUpdateHandler = { [weak self] state in
|
||||
switch state {
|
||||
case .failed, .cancelled:
|
||||
self?.activeConnections.removeValue(forKey: id)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
readRequest(on: connection, buffer: Data())
|
||||
}
|
||||
|
||||
private func readRequest(on connection: NWConnection, buffer: Data) {
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
|
||||
guard let self else { return }
|
||||
var buffer = buffer
|
||||
if let data { buffer.append(data) }
|
||||
|
||||
if let body = Self.parseRequestBody(from: buffer) {
|
||||
Task {
|
||||
let responseData = await self.processRequest(body)
|
||||
await self.sendAndClose(responseData, on: connection)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if isComplete || error != nil {
|
||||
connection.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
self.readRequest(on: connection, buffer: buffer)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendAndClose(_ data: Data, on connection: NWConnection) {
|
||||
let id = ObjectIdentifier(connection)
|
||||
connection.send(content: data, completion: .contentProcessed { [weak self] _ in
|
||||
connection.cancel()
|
||||
self?.activeConnections.removeValue(forKey: id)
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - Request processing
|
||||
|
||||
private struct AskRequest: Decodable {
|
||||
let prompt: String
|
||||
}
|
||||
|
||||
private func processRequest(_ body: Data) async -> Data {
|
||||
guard let req = try? JSONDecoder().decode(AskRequest.self, from: body),
|
||||
!req.prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return Self.errorResponse("Request body must be JSON: {\"prompt\": \"...\"}")
|
||||
}
|
||||
|
||||
let settings = SettingsService.shared
|
||||
guard settings.cliServerEnabled else {
|
||||
return Self.errorResponse("CLI server is disabled in Settings")
|
||||
}
|
||||
guard let providerType = Settings.Provider(rawValue: settings.cliServerProvider),
|
||||
let provider = ProviderRegistry.shared.getProvider(for: providerType) else {
|
||||
return Self.errorResponse("No provider configured — set one in Settings > MCP > CLI Access")
|
||||
}
|
||||
guard !settings.cliServerModel.isEmpty else {
|
||||
return Self.errorResponse("No model configured — set one in Settings > MCP > CLI Access")
|
||||
}
|
||||
|
||||
do {
|
||||
let request = ChatRequest(
|
||||
messages: [Message(role: .user, content: req.prompt)],
|
||||
model: settings.cliServerModel,
|
||||
stream: false
|
||||
)
|
||||
let response = try await provider.chat(request: request)
|
||||
return Self.successResponse(response.content)
|
||||
} catch {
|
||||
log.error("CLI request failed: \(error.localizedDescription)")
|
||||
return Self.errorResponse(error.localizedDescription, statusCode: 500)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pure helpers (unit tested — see CLIServerServiceTests.swift)
|
||||
|
||||
struct AskResponseBody: Codable {
|
||||
let response: String?
|
||||
let error: String?
|
||||
}
|
||||
|
||||
/// Parses a minimal HTTP/1.1 request out of `buffer`, returning the body once the full
|
||||
/// header block and (per Content-Length) body have arrived, or nil if more data is needed.
|
||||
/// Not a general-purpose HTTP parser — this server only ever talks to `curl --unix-socket`,
|
||||
/// so the method/path/most headers are read past rather than validated.
|
||||
static func parseRequestBody(from buffer: Data) -> Data? {
|
||||
let headerTerminator = Data("\r\n\r\n".utf8)
|
||||
guard let headerEndRange = buffer.range(of: headerTerminator) else { return nil }
|
||||
|
||||
let headerData = buffer.subdata(in: buffer.startIndex..<headerEndRange.lowerBound)
|
||||
guard let headerString = String(data: headerData, encoding: .utf8) else { return nil }
|
||||
|
||||
var contentLength = 0
|
||||
for line in headerString.split(separator: "\r\n") {
|
||||
let parts = line.split(separator: ":", maxSplits: 1)
|
||||
guard parts.count == 2,
|
||||
parts[0].trimmingCharacters(in: .whitespaces).caseInsensitiveCompare("Content-Length") == .orderedSame,
|
||||
let value = Int(parts[1].trimmingCharacters(in: .whitespaces)) else { continue }
|
||||
contentLength = value
|
||||
}
|
||||
|
||||
let bodyStart = headerEndRange.upperBound
|
||||
let availableBodyLength = buffer.distance(from: bodyStart, to: buffer.endIndex)
|
||||
guard availableBodyLength >= contentLength else { return nil }
|
||||
|
||||
let bodyEnd = buffer.index(bodyStart, offsetBy: contentLength)
|
||||
return buffer.subdata(in: bodyStart..<bodyEnd)
|
||||
}
|
||||
|
||||
static func makeHTTPResponse(statusCode: Int, statusText: String, jsonBody: Data) -> Data {
|
||||
let header = "HTTP/1.1 \(statusCode) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(jsonBody.count)\r\nConnection: close\r\n\r\n"
|
||||
var data = Data(header.utf8)
|
||||
data.append(jsonBody)
|
||||
return data
|
||||
}
|
||||
|
||||
static func successResponse(_ text: String) -> Data {
|
||||
let body = (try? JSONEncoder().encode(AskResponseBody(response: text, error: nil))) ?? Data()
|
||||
return makeHTTPResponse(statusCode: 200, statusText: "OK", jsonBody: body)
|
||||
}
|
||||
|
||||
static func errorResponse(_ message: String, statusCode: Int = 400) -> Data {
|
||||
let body = (try? JSONEncoder().encode(AskResponseBody(response: nil, error: message))) ?? Data()
|
||||
let statusText = statusCode == 400 ? "Bad Request" : "Internal Server Error"
|
||||
return makeHTTPResponse(statusCode: statusCode, statusText: statusText, jsonBody: body)
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,19 @@ struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
var modelId: String?
|
||||
}
|
||||
|
||||
struct UsageEventRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
static let databaseTableName = "usage_events"
|
||||
|
||||
var id: String
|
||||
var timestamp: String
|
||||
var provider: String
|
||||
var modelId: String
|
||||
var promptTokens: Int?
|
||||
var completionTokens: Int?
|
||||
var cost: Double?
|
||||
var conversationId: String?
|
||||
}
|
||||
|
||||
struct SettingRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
static let databaseTableName = "settings"
|
||||
|
||||
@@ -404,6 +417,26 @@ final class DatabaseService: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
migrator.registerMigration("v13") { db in
|
||||
// Usage tracking independent of conversation persistence: conversation *text* is only
|
||||
// ever saved when the user explicitly saves (⌘S), but usage (tokens/cost/model/provider)
|
||||
// should be recorded for every completed AI response regardless — one row per finished
|
||||
// generation. conversationId is a best-effort link (set only if the conversation had
|
||||
// already been saved at least once at logging time) and is not a foreign key, since the
|
||||
// referenced conversation may never exist.
|
||||
try db.create(table: "usage_events") { t in
|
||||
t.column("id", .text).primaryKey()
|
||||
t.column("timestamp", .text).notNull()
|
||||
t.column("provider", .text).notNull()
|
||||
t.column("modelId", .text).notNull()
|
||||
t.column("promptTokens", .integer)
|
||||
t.column("completionTokens", .integer)
|
||||
t.column("cost", .double)
|
||||
t.column("conversationId", .text)
|
||||
}
|
||||
try db.create(index: "idx_usage_events_timestamp", on: "usage_events", columns: ["timestamp"])
|
||||
}
|
||||
|
||||
return migrator
|
||||
}
|
||||
|
||||
@@ -806,17 +839,44 @@ final class DatabaseService: Sendable {
|
||||
|
||||
// MARK: - Usage Statistics
|
||||
|
||||
nonisolated func getOverallUsageStats() throws -> UsageStats {
|
||||
/// Builds a `WHERE`-ready timestamp range clause (empty if both bounds are nil) plus its bound arguments.
|
||||
/// `baseClauses` are ANDed in ahead of the range (e.g. "modelId IS NOT NULL") so callers can share one WHERE.
|
||||
private nonisolated static func timestampRangeClause(
|
||||
from: Date?,
|
||||
to: Date?,
|
||||
baseClauses: [String] = []
|
||||
) -> (whereSQL: String, arguments: [String]) {
|
||||
var clauses = baseClauses
|
||||
var arguments: [String] = []
|
||||
if let from {
|
||||
clauses.append("timestamp >= ?")
|
||||
arguments.append(Self.isoString(from: from))
|
||||
}
|
||||
if let to {
|
||||
clauses.append("timestamp <= ?")
|
||||
arguments.append(Self.isoString(from: to))
|
||||
}
|
||||
guard !clauses.isEmpty else { return ("", []) }
|
||||
return (" WHERE " + clauses.joined(separator: " AND "), arguments)
|
||||
}
|
||||
|
||||
/// Overall usage totals. Pass `from`/`to` to restrict to a time window; omit both for all-time.
|
||||
nonisolated func getOverallUsageStats(from: Date? = nil, to: Date? = nil) throws -> UsageStats {
|
||||
try dbQueue.read { db in
|
||||
guard let row = try Row.fetchOne(db, sql: """
|
||||
let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to)
|
||||
let sql = """
|
||||
SELECT COUNT(*) AS cnt,
|
||||
COALESCE(SUM(CASE WHEN role = 'user' THEN 1 ELSE 0 END), 0) AS questions,
|
||||
COALESCE(SUM(tokens), 0) AS tokens,
|
||||
COALESCE(SUM(cost), 0) AS cost,
|
||||
COUNT(cost) AS costCount,
|
||||
MIN(timestamp) AS minTs,
|
||||
MAX(timestamp) AS maxTs
|
||||
FROM messages
|
||||
""")
|
||||
\(whereSQL)
|
||||
"""
|
||||
|
||||
guard let row = try Row.fetchOne(db, sql: sql, arguments: StatementArguments(arguments))
|
||||
else {
|
||||
return UsageStats()
|
||||
}
|
||||
@@ -827,6 +887,7 @@ final class DatabaseService: Sendable {
|
||||
|
||||
return UsageStats(
|
||||
totalMessages: row["cnt"],
|
||||
totalQuestions: row["questions"],
|
||||
totalTokens: row["tokens"],
|
||||
totalCost: row["cost"],
|
||||
hasCostData: costCount > 0,
|
||||
@@ -836,19 +897,25 @@ final class DatabaseService: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func getUsageByModel() throws -> [ModelUsageStat] {
|
||||
/// Usage grouped by model. Pass `from`/`to` to restrict to a time window; omit both for all-time.
|
||||
nonisolated func getUsageByModel(from: Date? = nil, to: Date? = nil) throws -> [ModelUsageStat] {
|
||||
try dbQueue.read { db in
|
||||
let rows = try Row.fetchAll(db, sql: """
|
||||
let (whereSQL, arguments) = Self.timestampRangeClause(
|
||||
from: from, to: to, baseClauses: ["modelId IS NOT NULL"]
|
||||
)
|
||||
let sql = """
|
||||
SELECT modelId,
|
||||
COUNT(*) AS cnt,
|
||||
COALESCE(SUM(CASE WHEN role = 'user' THEN 1 ELSE 0 END), 0) AS questions,
|
||||
COALESCE(SUM(tokens), 0) AS tokens,
|
||||
COALESCE(SUM(cost), 0) AS cost,
|
||||
COUNT(cost) AS costCount,
|
||||
MAX(timestamp) AS lastUsed
|
||||
FROM messages
|
||||
WHERE modelId IS NOT NULL
|
||||
\(whereSQL)
|
||||
GROUP BY modelId
|
||||
""")
|
||||
"""
|
||||
let rows = try Row.fetchAll(db, sql: sql, arguments: StatementArguments(arguments))
|
||||
|
||||
let stats: [ModelUsageStat] = rows.compactMap { row in
|
||||
guard let modelId: String = row["modelId"],
|
||||
@@ -860,6 +927,7 @@ final class DatabaseService: Sendable {
|
||||
return ModelUsageStat(
|
||||
modelId: modelId,
|
||||
messageCount: row["cnt"],
|
||||
questionCount: row["questions"],
|
||||
totalTokens: row["tokens"],
|
||||
totalCost: row["cost"],
|
||||
hasCostData: costCount > 0,
|
||||
@@ -876,6 +944,252 @@ final class DatabaseService: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Usage bucketed by calendar day (in the local timezone the timestamps were stored in — GMT, see
|
||||
/// `isoStyle`), for the Analytics view's time-series chart. `from`/`to` bound the range; days with
|
||||
/// no messages are simply absent (the view fills gaps when rendering).
|
||||
nonisolated func getDailyUsage(from: Date, to: Date) throws -> [DailyUsageStat] {
|
||||
try dbQueue.read { db in
|
||||
let sql = """
|
||||
SELECT substr(timestamp, 1, 10) AS day,
|
||||
COUNT(*) AS cnt,
|
||||
COALESCE(SUM(CASE WHEN role = 'user' THEN 1 ELSE 0 END), 0) AS questions,
|
||||
COALESCE(SUM(tokens), 0) AS tokens,
|
||||
COALESCE(SUM(cost), 0) AS cost,
|
||||
COUNT(cost) AS costCount
|
||||
FROM messages
|
||||
WHERE timestamp >= ? AND timestamp <= ?
|
||||
GROUP BY day
|
||||
ORDER BY day
|
||||
"""
|
||||
let rows = try Row.fetchAll(
|
||||
db, sql: sql,
|
||||
arguments: [Self.isoString(from: from), Self.isoString(from: to)]
|
||||
)
|
||||
|
||||
var gmtCalendar = Calendar(identifier: .gregorian)
|
||||
gmtCalendar.timeZone = TimeZone(identifier: "GMT")!
|
||||
|
||||
return rows.compactMap { row -> DailyUsageStat? in
|
||||
guard let dayString: String = row["day"] else { return nil }
|
||||
let parts = dayString.split(separator: "-").compactMap { Int($0) }
|
||||
guard parts.count == 3 else { return nil }
|
||||
var components = DateComponents()
|
||||
components.year = parts[0]
|
||||
components.month = parts[1]
|
||||
components.day = parts[2]
|
||||
guard let day = gmtCalendar.date(from: components) else { return nil }
|
||||
|
||||
let costCount: Int = row["costCount"]
|
||||
return DailyUsageStat(
|
||||
day: day,
|
||||
messageCount: row["cnt"],
|
||||
questionCount: row["questions"],
|
||||
totalTokens: row["tokens"],
|
||||
totalCost: row["cost"],
|
||||
hasCostData: costCount > 0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Usage Events (save-independent usage tracking)
|
||||
|
||||
/// Records one completed AI generation's usage — independent of whether the conversation it
|
||||
/// belongs to is ever explicitly saved. `conversationId` is a best-effort link, set only if the
|
||||
/// conversation had already been saved at least once when this was logged.
|
||||
nonisolated func logUsageEvent(
|
||||
provider: String,
|
||||
modelId: String,
|
||||
promptTokens: Int?,
|
||||
completionTokens: Int?,
|
||||
cost: Double?,
|
||||
conversationId: UUID?
|
||||
) throws {
|
||||
let record = UsageEventRecord(
|
||||
id: UUID().uuidString,
|
||||
timestamp: Self.isoString(from: Date()),
|
||||
provider: provider,
|
||||
modelId: modelId,
|
||||
promptTokens: promptTokens,
|
||||
completionTokens: completionTokens,
|
||||
cost: cost,
|
||||
conversationId: conversationId?.uuidString
|
||||
)
|
||||
try dbQueue.write { db in
|
||||
try record.insert(db)
|
||||
}
|
||||
}
|
||||
|
||||
/// Overall usage-event totals (each row = one completed generation = one "question").
|
||||
nonisolated func getUsageEventTotals(from: Date? = nil, to: Date? = nil) throws -> UsageStats {
|
||||
try dbQueue.read { db in
|
||||
let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to)
|
||||
let sql = """
|
||||
SELECT COUNT(*) AS cnt,
|
||||
COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens,
|
||||
COALESCE(SUM(cost), 0) AS cost,
|
||||
COUNT(cost) AS costCount,
|
||||
MIN(timestamp) AS minTs,
|
||||
MAX(timestamp) AS maxTs
|
||||
FROM usage_events
|
||||
\(whereSQL)
|
||||
"""
|
||||
guard let row = try Row.fetchOne(db, sql: sql, arguments: StatementArguments(arguments))
|
||||
else {
|
||||
return UsageStats()
|
||||
}
|
||||
|
||||
let costCount: Int = row["costCount"]
|
||||
let cnt: Int = row["cnt"]
|
||||
let minTs: String? = row["minTs"]
|
||||
let maxTs: String? = row["maxTs"]
|
||||
|
||||
return UsageStats(
|
||||
totalMessages: cnt,
|
||||
totalQuestions: cnt,
|
||||
totalTokens: row["tokens"],
|
||||
totalCost: row["cost"],
|
||||
hasCostData: costCount > 0,
|
||||
firstMessageDate: minTs.flatMap { Self.isoDate(from: $0) },
|
||||
lastMessageDate: maxTs.flatMap { Self.isoDate(from: $0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Usage events grouped by model.
|
||||
nonisolated func getUsageEventsByModel(from: Date? = nil, to: Date? = nil) throws -> [ModelUsageStat] {
|
||||
try dbQueue.read { db in
|
||||
let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to)
|
||||
let sql = """
|
||||
SELECT modelId,
|
||||
COUNT(*) AS cnt,
|
||||
COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens,
|
||||
COALESCE(SUM(cost), 0) AS cost,
|
||||
COUNT(cost) AS costCount,
|
||||
MAX(timestamp) AS lastUsed
|
||||
FROM usage_events
|
||||
\(whereSQL)
|
||||
GROUP BY modelId
|
||||
"""
|
||||
let rows = try Row.fetchAll(db, sql: sql, arguments: StatementArguments(arguments))
|
||||
|
||||
let stats: [ModelUsageStat] = rows.compactMap { row in
|
||||
guard let modelId: String = row["modelId"],
|
||||
let lastUsedString: String = row["lastUsed"],
|
||||
let lastUsed = Self.isoDate(from: lastUsedString)
|
||||
else { return nil }
|
||||
|
||||
let costCount: Int = row["costCount"]
|
||||
let cnt: Int = row["cnt"]
|
||||
return ModelUsageStat(
|
||||
modelId: modelId,
|
||||
messageCount: cnt,
|
||||
questionCount: cnt,
|
||||
totalTokens: row["tokens"],
|
||||
totalCost: row["cost"],
|
||||
hasCostData: costCount > 0,
|
||||
lastUsed: lastUsed
|
||||
)
|
||||
}
|
||||
|
||||
return stats.sorted { lhs, rhs in
|
||||
if lhs.hasCostData || rhs.hasCostData {
|
||||
return lhs.totalCost > rhs.totalCost
|
||||
}
|
||||
return lhs.totalTokens > rhs.totalTokens
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Usage events grouped by provider.
|
||||
nonisolated func getUsageEventsByProvider(from: Date? = nil, to: Date? = nil) throws -> [ProviderUsageStat] {
|
||||
try dbQueue.read { db in
|
||||
let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to)
|
||||
let sql = """
|
||||
SELECT provider,
|
||||
COUNT(*) AS cnt,
|
||||
COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens,
|
||||
COALESCE(SUM(cost), 0) AS cost,
|
||||
COUNT(cost) AS costCount,
|
||||
MAX(timestamp) AS lastUsed
|
||||
FROM usage_events
|
||||
\(whereSQL)
|
||||
GROUP BY provider
|
||||
"""
|
||||
let rows = try Row.fetchAll(db, sql: sql, arguments: StatementArguments(arguments))
|
||||
|
||||
let stats: [ProviderUsageStat] = rows.compactMap { row in
|
||||
guard let provider: String = row["provider"],
|
||||
let lastUsedString: String = row["lastUsed"],
|
||||
let lastUsed = Self.isoDate(from: lastUsedString)
|
||||
else { return nil }
|
||||
|
||||
let costCount: Int = row["costCount"]
|
||||
return ProviderUsageStat(
|
||||
provider: provider,
|
||||
questionCount: row["cnt"],
|
||||
totalTokens: row["tokens"],
|
||||
totalCost: row["cost"],
|
||||
hasCostData: costCount > 0,
|
||||
lastUsed: lastUsed
|
||||
)
|
||||
}
|
||||
|
||||
return stats.sorted { lhs, rhs in
|
||||
if lhs.hasCostData || rhs.hasCostData {
|
||||
return lhs.totalCost > rhs.totalCost
|
||||
}
|
||||
return lhs.totalTokens > rhs.totalTokens
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Usage events bucketed by calendar day (GMT, matching `isoStyle`), for the Analytics view.
|
||||
nonisolated func getDailyUsageEvents(from: Date, to: Date) throws -> [DailyUsageStat] {
|
||||
try dbQueue.read { db in
|
||||
let sql = """
|
||||
SELECT substr(timestamp, 1, 10) AS day,
|
||||
COUNT(*) AS cnt,
|
||||
COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens,
|
||||
COALESCE(SUM(cost), 0) AS cost,
|
||||
COUNT(cost) AS costCount
|
||||
FROM usage_events
|
||||
WHERE timestamp >= ? AND timestamp <= ?
|
||||
GROUP BY day
|
||||
ORDER BY day
|
||||
"""
|
||||
let rows = try Row.fetchAll(
|
||||
db, sql: sql,
|
||||
arguments: [Self.isoString(from: from), Self.isoString(from: to)]
|
||||
)
|
||||
|
||||
var gmtCalendar = Calendar(identifier: .gregorian)
|
||||
gmtCalendar.timeZone = TimeZone(identifier: "GMT")!
|
||||
|
||||
return rows.compactMap { row -> DailyUsageStat? in
|
||||
guard let dayString: String = row["day"] else { return nil }
|
||||
let parts = dayString.split(separator: "-").compactMap { Int($0) }
|
||||
guard parts.count == 3 else { return nil }
|
||||
var components = DateComponents()
|
||||
components.year = parts[0]
|
||||
components.month = parts[1]
|
||||
components.day = parts[2]
|
||||
guard let day = gmtCalendar.date(from: components) else { return nil }
|
||||
|
||||
let costCount: Int = row["costCount"]
|
||||
let cnt: Int = row["cnt"]
|
||||
return DailyUsageStat(
|
||||
day: day,
|
||||
messageCount: cnt,
|
||||
questionCount: cnt,
|
||||
totalTokens: row["tokens"],
|
||||
totalCost: row["cost"],
|
||||
hasCostData: costCount > 0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func getUsageByConversation(limit: Int = 20) throws -> [ConversationUsageStat] {
|
||||
try dbQueue.read { db in
|
||||
let rows = try Row.fetchAll(db, sql: """
|
||||
|
||||
@@ -5,22 +5,18 @@ import Foundation
|
||||
|
||||
// MARK: - ExternalMCPClient
|
||||
|
||||
/// Manages one MCP stdio server process. All state is MainActor-isolated
|
||||
/// (consistent with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor project setting).
|
||||
/// Background I/O runs in Task.detached; state mutations hop back to MainActor.
|
||||
/// Owns one MCP server connection's lifecycle and JSON-RPC message framing. Delivery (stdio
|
||||
/// subprocess vs Streamable HTTP) is delegated to a `MCPTransport` — this class only builds
|
||||
/// envelopes, decodes typed results, and tracks connection state.
|
||||
/// All state is MainActor-isolated (consistent with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor
|
||||
/// project setting).
|
||||
@MainActor
|
||||
final class ExternalMCPClient {
|
||||
let server: ExternalMCPServer
|
||||
weak var stateDelegate: (any ExternalMCPStateDelegate)?
|
||||
|
||||
private var process: Process?
|
||||
private var stdinHandle: FileHandle?
|
||||
private var readTask: Task<Void, Never>?
|
||||
private var stderrTask: Task<Void, Never>?
|
||||
|
||||
private let transport: any MCPTransport
|
||||
private var nextRequestId: Int = 1
|
||||
private var pendingCalls: [Int: CheckedContinuation<Data, Error>] = [:]
|
||||
private var lineBuffer = Data()
|
||||
|
||||
private(set) var state: MCPClientState = .idle
|
||||
private(set) var discoveredTools: [MCPToolDefinition] = []
|
||||
@@ -28,6 +24,31 @@ final class ExternalMCPClient {
|
||||
init(server: ExternalMCPServer, stateDelegate: (any ExternalMCPStateDelegate)?) {
|
||||
self.server = server
|
||||
self.stateDelegate = stateDelegate
|
||||
let stdioTransport: StdioMCPTransport?
|
||||
switch server.transportKind {
|
||||
case .stdio:
|
||||
let t = StdioMCPTransport(server: server)
|
||||
stdioTransport = t
|
||||
self.transport = t
|
||||
case .http:
|
||||
stdioTransport = nil
|
||||
self.transport = HTTPMCPTransport(server: server)
|
||||
}
|
||||
// `self` is only safe to capture once every stored property above has a value —
|
||||
// wire the crash callback here, after `init` would otherwise be considered complete.
|
||||
stdioTransport?.onTerminated = { [weak self] in
|
||||
self?.handleTransportTerminatedUnexpectedly()
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by a stdio transport whose subprocess died on its own — as opposed to a
|
||||
/// deliberate `stop()` call, or a failure already handled inline within `start()`.
|
||||
/// No HTTP equivalent: a Streamable HTTP connection has no persistent process to crash;
|
||||
/// its failures surface per-request instead (handled in `start()`/`callTool()` directly).
|
||||
private func handleTransportTerminatedUnexpectedly() {
|
||||
guard state != .stopped else { return }
|
||||
state = .crashed
|
||||
stateDelegate?.clientDidChangeState(id: server.id, state: .crashed)
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
@@ -37,54 +58,28 @@ final class ExternalMCPClient {
|
||||
state = .connecting
|
||||
stateDelegate?.clientDidChangeState(id: server.id, state: .connecting)
|
||||
|
||||
let proc = Process()
|
||||
if server.command.hasPrefix("/") {
|
||||
proc.executableURL = URL(fileURLWithPath: server.command)
|
||||
proc.arguments = server.args
|
||||
} else {
|
||||
proc.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
proc.arguments = [server.command] + server.args
|
||||
}
|
||||
proc.environment = ProcessInfo.processInfo.environment
|
||||
|
||||
let stdinPipe = Pipe()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
proc.standardInput = stdinPipe
|
||||
proc.standardOutput = stdoutPipe
|
||||
proc.standardError = stderrPipe
|
||||
|
||||
proc.terminationHandler = { [weak self] _ in
|
||||
Task { @MainActor [weak self] in self?.handleProcessTerminated() }
|
||||
}
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
} catch {
|
||||
state = .error(error.localizedDescription)
|
||||
stateDelegate?.clientDidChangeState(id: server.id, state: .error(error.localizedDescription))
|
||||
throw MCPClientError.processLaunchFailed(error.localizedDescription)
|
||||
}
|
||||
try await transport.prepare()
|
||||
|
||||
process = proc
|
||||
stdinHandle = stdinPipe.fileHandleForWriting
|
||||
startReadLoop(pipe: stdoutPipe)
|
||||
startStderrLoop(pipe: stderrPipe)
|
||||
|
||||
do {
|
||||
let _: MCPInitializeResult = try await timedRequest(seconds: 15, method: "initialize", params: [
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": [:] as [String: Any],
|
||||
"clientInfo": ["name": "Confab", "version": "1.0"] as [String: Any]
|
||||
])
|
||||
try sendNotification(method: "notifications/initialized")
|
||||
try await transport.sendNotification(["jsonrpc": "2.0", "method": "notifications/initialized"])
|
||||
|
||||
let toolsResult: MCPToolsListResult = try await timedRequest(seconds: 15, method: "tools/list", params: nil)
|
||||
discoveredTools = toolsResult.tools
|
||||
} catch {
|
||||
state = .error(error.localizedDescription)
|
||||
stateDelegate?.clientDidChangeState(id: server.id, state: .error(error.localizedDescription))
|
||||
proc.terminate()
|
||||
// Uniformly route every start() failure (bad config, launch failure, handshake
|
||||
// failure — for either transport) through .crashed, not .error, so
|
||||
// ExternalMCPManager's restart-with-backoff drives from exactly one place.
|
||||
// (Previously, stdio relied on the subprocess's termination handler firing
|
||||
// asynchronously to reach .crashed; that path doesn't exist for HTTP, so failures
|
||||
// there would otherwise get stuck at .error with no retry.)
|
||||
state = .crashed
|
||||
transport.stop()
|
||||
stateDelegate?.clientDidChangeState(id: server.id, state: .crashed)
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -94,14 +89,7 @@ final class ExternalMCPClient {
|
||||
|
||||
func stop() {
|
||||
state = .stopped
|
||||
readTask?.cancel()
|
||||
stderrTask?.cancel()
|
||||
process?.terminate()
|
||||
process = nil
|
||||
stdinHandle = nil
|
||||
lineBuffer = Data()
|
||||
for (_, cont) in pendingCalls { cont.resume(throwing: MCPClientError.notConnected) }
|
||||
pendingCalls.removeAll()
|
||||
transport.stop()
|
||||
}
|
||||
|
||||
// MARK: - Tool Execution
|
||||
@@ -128,125 +116,17 @@ final class ExternalMCPClient {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - I/O Loops (detached from MainActor)
|
||||
|
||||
private func startReadLoop(pipe: Pipe) {
|
||||
readTask = Task.detached { [weak self] in
|
||||
let handle = pipe.fileHandleForReading
|
||||
while true {
|
||||
let data = handle.availableData
|
||||
if data.isEmpty { break }
|
||||
await self?.receiveData(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startStderrLoop(pipe: Pipe) {
|
||||
let name = server.name
|
||||
stderrTask = Task.detached {
|
||||
let handle = pipe.fileHandleForReading
|
||||
var buf = Data()
|
||||
while true {
|
||||
let data = handle.availableData
|
||||
if data.isEmpty { break }
|
||||
buf.append(data)
|
||||
while let idx = buf.firstIndex(of: UInt8(ascii: "\n")) {
|
||||
let line = String(data: buf[buf.startIndex..<idx], encoding: .utf8) ?? ""
|
||||
buf = Data(buf[buf.index(after: idx)...])
|
||||
if !line.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
Log.extMcp.warning("[\(name)] \(line)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Processing (MainActor)
|
||||
|
||||
private func receiveData(_ data: Data) {
|
||||
lineBuffer.append(data)
|
||||
while let idx = lineBuffer.firstIndex(of: UInt8(ascii: "\n")) {
|
||||
let lineData = Data(lineBuffer[lineBuffer.startIndex..<idx])
|
||||
lineBuffer = Data(lineBuffer[lineBuffer.index(after: idx)...])
|
||||
processLine(lineData)
|
||||
}
|
||||
}
|
||||
|
||||
private func processLine(_ data: Data) {
|
||||
guard !data.isEmpty,
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let id = json["id"] as? Int,
|
||||
let cont = pendingCalls.removeValue(forKey: id) else { return }
|
||||
|
||||
if let err = json["error"] as? [String: Any] {
|
||||
cont.resume(throwing: MCPClientError.invalidResponse(err["message"] as? String ?? "Unknown error"))
|
||||
} else if let result = json["result"],
|
||||
let resultData = try? JSONSerialization.data(withJSONObject: result) {
|
||||
cont.resume(returning: resultData)
|
||||
} else {
|
||||
cont.resume(throwing: MCPClientError.invalidResponse("Missing result field"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - JSON-RPC
|
||||
|
||||
/// Send a JSON-RPC request with a per-call timeout. The timeout fires a cancellation
|
||||
/// directly into the pending-calls table rather than using a task group (which would
|
||||
/// pass the generic T through a @Sendable closure and trigger an isolated-conformance warning).
|
||||
private func timedRequest<T: Decodable>(seconds: Double, method: String, params: [String: Any]?) async throws -> T {
|
||||
let id = nextRequestId
|
||||
nextRequestId += 1
|
||||
var message: [String: Any] = ["jsonrpc": "2.0", "method": method, "id": id]
|
||||
if let params { message["params"] = params }
|
||||
try writeJSON(message)
|
||||
|
||||
// Schedule timeout: cancels the specific pending call by ID
|
||||
let timeoutId = id
|
||||
Task { [weak self, timeoutId] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
self?.cancelPendingCall(id: timeoutId, with: MCPClientError.timeout)
|
||||
}
|
||||
|
||||
// Await response data, then decode on MainActor
|
||||
let resultData: Data = try await withCheckedThrowingContinuation { cont in
|
||||
pendingCalls[id] = cont
|
||||
}
|
||||
let resultData = try await transport.sendRequest(message, id: id, timeoutSeconds: seconds)
|
||||
return try JSONDecoder().decode(T.self, from: resultData)
|
||||
}
|
||||
|
||||
private func cancelPendingCall(id: Int, with error: Error) {
|
||||
pendingCalls.removeValue(forKey: id)?.resume(throwing: error)
|
||||
}
|
||||
|
||||
private func sendNotification(method: String) throws {
|
||||
try writeJSON(["jsonrpc": "2.0", "method": method])
|
||||
}
|
||||
|
||||
private func writeJSON(_ message: [String: Any]) throws {
|
||||
guard let handle = stdinHandle, process?.isRunning == true else {
|
||||
throw MCPClientError.writeFailed
|
||||
}
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: message),
|
||||
let line = String(data: data, encoding: .utf8) else {
|
||||
throw MCPClientError.writeFailed
|
||||
}
|
||||
do {
|
||||
try handle.write(contentsOf: Data((line + "\n").utf8))
|
||||
} catch {
|
||||
throw MCPClientError.writeFailed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Process termination
|
||||
|
||||
private func handleProcessTerminated() {
|
||||
guard state != .stopped else { return }
|
||||
state = .crashed
|
||||
for (_, cont) in pendingCalls { cont.resume(throwing: MCPClientError.notConnected) }
|
||||
pendingCalls.removeAll()
|
||||
stateDelegate?.clientDidChangeState(id: server.id, state: .crashed)
|
||||
}
|
||||
|
||||
// MARK: - Result conversion
|
||||
|
||||
private func convertMCPResult(_ result: MCPToolCallResult) -> [String: Any] {
|
||||
|
||||
@@ -67,13 +67,10 @@ final class ExternalMCPManager {
|
||||
Task {
|
||||
do {
|
||||
try await client.start()
|
||||
} catch MCPClientError.processLaunchFailed(let msg) {
|
||||
// Process never started — termination handler won't fire, so manually trigger crashed
|
||||
Log.extMcp.error("Failed to launch '\(server.name)': \(msg)")
|
||||
clientDidChangeState(id: server.id, state: .crashed)
|
||||
} catch {
|
||||
// Handshake/other failure — proc.terminate() was called in start(), termination
|
||||
// handler will fire and set .crashed, which drives the restart from one place only.
|
||||
// start() already notified stateDelegate with .crashed (uniformly, for both
|
||||
// transports and every failure kind) before throwing — restart-with-backoff is
|
||||
// already scheduled via clientDidChangeState. Nothing further to do but log.
|
||||
Log.extMcp.warning("'\(server.name)' start failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
@@ -126,7 +123,7 @@ final class ExternalMCPManager {
|
||||
|
||||
private func rebuildCache(for server: ExternalMCPServer, tools: [MCPToolDefinition]) {
|
||||
removeCachedSchemas(for: server.id, slug: server.slug)
|
||||
let prefixed = tools.compactMap { convertToolDefinition($0, server: server) }
|
||||
let prefixed = tools.compactMap { Self.convertToolDefinition($0, server: server) }
|
||||
cachedToolSchemas.append(contentsOf: prefixed)
|
||||
Log.extMcp.info("[\(server.name)] cached \(prefixed.count) tools: \(prefixed.map { $0.function.name }.joined(separator: ", "))")
|
||||
}
|
||||
@@ -140,7 +137,7 @@ final class ExternalMCPManager {
|
||||
cachedToolSchemas.removeAll { $0.function.name.hasPrefix("\(slug)_") }
|
||||
}
|
||||
|
||||
private func convertToolDefinition(_ def: MCPToolDefinition, server: ExternalMCPServer) -> Tool? {
|
||||
nonisolated static func convertToolDefinition(_ def: MCPToolDefinition, server: ExternalMCPServer) -> Tool? {
|
||||
Tool(
|
||||
type: "function",
|
||||
function: Tool.Function(
|
||||
@@ -151,13 +148,18 @@ final class ExternalMCPManager {
|
||||
)
|
||||
}
|
||||
|
||||
private func convertInputSchema(_ schema: MCPInputSchema) -> Tool.Function.Parameters {
|
||||
/// A schema property with no `"type"` at all is valid JSON Schema (e.g. an `enum`-only or
|
||||
/// composed property) — not every MCP server's tool schemas set it, so this must not assume
|
||||
/// it's present. (Found via a real crash: Obsidian's Local REST API plugin sends at least one
|
||||
/// tool parameter with no `type`, which a `prop.type!` force-unwrap here used to crash on.)
|
||||
nonisolated static func convertInputSchema(_ schema: MCPInputSchema) -> Tool.Function.Parameters {
|
||||
var properties: [String: Tool.Function.Parameters.Property] = [:]
|
||||
for (key, prop) in schema.properties ?? [:] {
|
||||
let effectiveType = prop.type ?? "string"
|
||||
let normalized: String
|
||||
switch prop.type ?? "string" {
|
||||
switch effectiveType {
|
||||
case "integer": normalized = "number"
|
||||
case "string", "number", "boolean", "array", "object": normalized = prop.type!
|
||||
case "string", "number", "boolean", "array", "object": normalized = effectiveType
|
||||
default: normalized = "string"
|
||||
}
|
||||
var items: Tool.Function.Parameters.Property.Items? = nil
|
||||
|
||||
@@ -5,26 +5,79 @@ import Foundation
|
||||
|
||||
// MARK: - Server Configuration
|
||||
|
||||
struct ExternalMCPServer: Codable, Identifiable, Sendable {
|
||||
/// Which wire protocol an `ExternalMCPServer` uses. `.stdio` fields are `command`/`args`/`env`;
|
||||
/// `.http` fields are `url`/`bearerToken`/`headers`. Kept as one flat struct rather than an enum
|
||||
/// with associated values — simpler `Codable` and simpler settings-JSON storage, at the cost of
|
||||
/// each server config carrying some always-unused fields for its transport.
|
||||
nonisolated enum MCPTransportKind: String, Codable, Sendable, CaseIterable {
|
||||
case stdio
|
||||
case http
|
||||
}
|
||||
|
||||
nonisolated struct ExternalMCPServer: Codable, Identifiable, Sendable {
|
||||
var id: UUID
|
||||
var name: String
|
||||
var transportKind: MCPTransportKind
|
||||
var command: String
|
||||
var args: [String]
|
||||
var env: [String: String]
|
||||
var url: String
|
||||
var bearerToken: String
|
||||
var headers: [String: String]
|
||||
var isEnabled: Bool
|
||||
var timeout: TimeInterval
|
||||
var createdAt: Date
|
||||
|
||||
init(id: UUID = UUID(), name: String, command: String, args: [String] = [],
|
||||
isEnabled: Bool = true, timeout: TimeInterval = 30, createdAt: Date = Date()) {
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
transportKind: MCPTransportKind = .stdio,
|
||||
command: String = "",
|
||||
args: [String] = [],
|
||||
env: [String: String] = [:],
|
||||
url: String = "",
|
||||
bearerToken: String = "",
|
||||
headers: [String: String] = [:],
|
||||
isEnabled: Bool = true,
|
||||
timeout: TimeInterval = 30,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.transportKind = transportKind
|
||||
self.command = command
|
||||
self.args = args
|
||||
self.env = env
|
||||
self.url = url
|
||||
self.bearerToken = bearerToken
|
||||
self.headers = headers
|
||||
self.isEnabled = isEnabled
|
||||
self.timeout = timeout
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, transportKind, command, args, env, url, bearerToken, headers, isEnabled, timeout, createdAt
|
||||
}
|
||||
|
||||
/// Custom decoding so servers saved before `transportKind`/`env`/`url`/`bearerToken`/`headers`
|
||||
/// existed (plain stdio-only configs) still decode — those keys default rather than fail.
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(UUID.self, forKey: .id)
|
||||
name = try c.decode(String.self, forKey: .name)
|
||||
command = try c.decode(String.self, forKey: .command)
|
||||
args = try c.decode([String].self, forKey: .args)
|
||||
isEnabled = try c.decode(Bool.self, forKey: .isEnabled)
|
||||
timeout = try c.decode(TimeInterval.self, forKey: .timeout)
|
||||
createdAt = try c.decode(Date.self, forKey: .createdAt)
|
||||
transportKind = try c.decodeIfPresent(MCPTransportKind.self, forKey: .transportKind) ?? .stdio
|
||||
env = try c.decodeIfPresent([String: String].self, forKey: .env) ?? [:]
|
||||
url = try c.decodeIfPresent(String.self, forKey: .url) ?? ""
|
||||
bearerToken = try c.decodeIfPresent(String.self, forKey: .bearerToken) ?? ""
|
||||
headers = try c.decodeIfPresent([String: String].self, forKey: .headers) ?? [:]
|
||||
}
|
||||
|
||||
var slug: String { Self.makeSlug(from: name) }
|
||||
|
||||
static func makeSlug(from name: String) -> String {
|
||||
@@ -100,6 +153,7 @@ enum MCPClientError: LocalizedError {
|
||||
case processLaunchFailed(String)
|
||||
case handshakeFailed(String)
|
||||
case writeFailed
|
||||
case invalidConfiguration(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
@@ -109,6 +163,7 @@ enum MCPClientError: LocalizedError {
|
||||
case .processLaunchFailed(let s): return "Failed to launch MCP server: \(s)"
|
||||
case .handshakeFailed(let s): return "MCP handshake failed: \(s)"
|
||||
case .writeFailed: return "Failed to write to MCP server stdin"
|
||||
case .invalidConfiguration(let s): return "Invalid MCP server configuration: \(s)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,24 +191,27 @@ struct MCPToolsListResult: Decodable {
|
||||
let nextCursor: String?
|
||||
}
|
||||
|
||||
struct MCPToolDefinition: Decodable {
|
||||
// Plain DTOs read from ExternalMCPManager.convertToolDefinition/convertInputSchema — both
|
||||
// `nonisolated static func` (for direct unit testing) — so these must stay `nonisolated` too,
|
||||
// same reasoning as `Tool` in AIProvider.swift.
|
||||
nonisolated struct MCPToolDefinition: Decodable {
|
||||
let name: String
|
||||
let description: String?
|
||||
let inputSchema: MCPInputSchema
|
||||
}
|
||||
|
||||
struct MCPInputSchema: Decodable {
|
||||
nonisolated struct MCPInputSchema: Decodable {
|
||||
let type: String
|
||||
let properties: [String: MCPPropertySchema]?
|
||||
let required: [String]?
|
||||
}
|
||||
|
||||
struct MCPPropertySchema: Decodable {
|
||||
nonisolated struct MCPPropertySchema: Decodable {
|
||||
let type: String?
|
||||
let description: String?
|
||||
let `enum`: [String]?
|
||||
let items: MCPItemsSchema?
|
||||
struct MCPItemsSchema: Decodable { let type: String? }
|
||||
nonisolated struct MCPItemsSchema: Decodable { let type: String? }
|
||||
}
|
||||
|
||||
struct MCPToolCallResult: Decodable {
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Transport abstraction
|
||||
|
||||
/// One MCP server connection's wire protocol. `ExternalMCPClient` builds JSON-RPC envelopes and
|
||||
/// decodes typed results; the transport only owns *delivery* — how the bytes get to the server
|
||||
/// and back. A long-lived subprocess pipe for stdio, discrete HTTP requests for Streamable HTTP.
|
||||
@MainActor
|
||||
protocol MCPTransport: AnyObject {
|
||||
/// Prepares the transport for use — launches the subprocess for stdio; a no-op for HTTP,
|
||||
/// since there's no persistent connection to establish ahead of the first request.
|
||||
func prepare() async throws
|
||||
/// Sends a JSON-RPC *request* (`message` includes `"id"`) and returns its `result` payload.
|
||||
/// Throws `MCPClientError.invalidResponse` if the server returned a JSON-RPC error, or
|
||||
/// `.timeout` if `timeoutSeconds` elapses first.
|
||||
func sendRequest(_ message: [String: Any], id: Int, timeoutSeconds: Double) async throws -> Data
|
||||
/// Sends a JSON-RPC *notification* (no `"id"`, no response expected).
|
||||
func sendNotification(_ message: [String: Any]) async throws
|
||||
/// Tears down the transport — terminates the subprocess / drops HTTP session state.
|
||||
func stop()
|
||||
}
|
||||
|
||||
enum MCPTransportSupport {
|
||||
/// Extracts the `result` payload from a decoded top-level JSON-RPC response object, shared
|
||||
/// by every transport so error/result semantics stay identical regardless of wire protocol.
|
||||
static func extractResult(from json: [String: Any]) throws -> Data {
|
||||
if let err = json["error"] as? [String: Any] {
|
||||
throw MCPClientError.invalidResponse(err["message"] as? String ?? "Unknown error")
|
||||
}
|
||||
guard let result = json["result"],
|
||||
let resultData = try? JSONSerialization.data(withJSONObject: result)
|
||||
else {
|
||||
throw MCPClientError.invalidResponse("Missing result field")
|
||||
}
|
||||
return resultData
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stdio Transport
|
||||
|
||||
/// Launches the server as a subprocess and speaks newline-delimited JSON-RPC over its
|
||||
/// stdin/stdout, exactly as the original (pre-transport-split) `ExternalMCPClient` did.
|
||||
@MainActor
|
||||
final class StdioMCPTransport: MCPTransport {
|
||||
private let server: ExternalMCPServer
|
||||
/// Set by the owner after construction (not an init parameter) — the owner (`ExternalMCPClient`)
|
||||
/// needs to capture `self` weakly to wire this up, which Swift only allows once `self` is
|
||||
/// fully initialized, i.e. after its own `init` has finished assigning all stored properties.
|
||||
var onTerminated: (() -> Void)?
|
||||
|
||||
private var process: Process?
|
||||
private var stdinHandle: FileHandle?
|
||||
private var readTask: Task<Void, Never>?
|
||||
private var stderrTask: Task<Void, Never>?
|
||||
private var pendingCalls: [Int: CheckedContinuation<Data, Error>] = [:]
|
||||
private var lineBuffer = Data()
|
||||
|
||||
init(server: ExternalMCPServer) {
|
||||
self.server = server
|
||||
}
|
||||
|
||||
func prepare() async throws {
|
||||
let proc = Process()
|
||||
if server.command.hasPrefix("/") {
|
||||
proc.executableURL = URL(fileURLWithPath: server.command)
|
||||
proc.arguments = server.args
|
||||
} else {
|
||||
proc.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
proc.arguments = [server.command] + server.args
|
||||
}
|
||||
// Inherited environment first (so PATH etc. still resolves — e.g. `npx` needs PATH to
|
||||
// find node), then layer the user-configured vars over it so they can override.
|
||||
var environment = ProcessInfo.processInfo.environment
|
||||
for (key, value) in server.env { environment[key] = value }
|
||||
proc.environment = environment
|
||||
|
||||
let stdinPipe = Pipe()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
proc.standardInput = stdinPipe
|
||||
proc.standardOutput = stdoutPipe
|
||||
proc.standardError = stderrPipe
|
||||
|
||||
proc.terminationHandler = { [weak self] _ in
|
||||
Task { @MainActor [weak self] in self?.handleProcessTerminated() }
|
||||
}
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
} catch {
|
||||
throw MCPClientError.processLaunchFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
process = proc
|
||||
stdinHandle = stdinPipe.fileHandleForWriting
|
||||
startReadLoop(pipe: stdoutPipe)
|
||||
startStderrLoop(pipe: stderrPipe)
|
||||
}
|
||||
|
||||
func sendRequest(_ message: [String: Any], id: Int, timeoutSeconds: Double) async throws -> Data {
|
||||
try writeJSON(message)
|
||||
|
||||
// Schedule timeout: cancels the specific pending call by ID
|
||||
let timeoutId = id
|
||||
Task { [weak self, timeoutId] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
|
||||
self?.cancelPendingCall(id: timeoutId, with: MCPClientError.timeout)
|
||||
}
|
||||
|
||||
return try await withCheckedThrowingContinuation { cont in
|
||||
pendingCalls[id] = cont
|
||||
}
|
||||
}
|
||||
|
||||
func sendNotification(_ message: [String: Any]) async throws {
|
||||
try writeJSON(message)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
readTask?.cancel()
|
||||
stderrTask?.cancel()
|
||||
process?.terminate()
|
||||
process = nil
|
||||
stdinHandle = nil
|
||||
lineBuffer = Data()
|
||||
for (_, cont) in pendingCalls { cont.resume(throwing: MCPClientError.notConnected) }
|
||||
pendingCalls.removeAll()
|
||||
}
|
||||
|
||||
// MARK: I/O Loops (detached from MainActor)
|
||||
|
||||
private func startReadLoop(pipe: Pipe) {
|
||||
readTask = Task.detached { [weak self] in
|
||||
let handle = pipe.fileHandleForReading
|
||||
while true {
|
||||
let data = handle.availableData
|
||||
if data.isEmpty { break }
|
||||
await self?.receiveData(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startStderrLoop(pipe: Pipe) {
|
||||
let name = server.name
|
||||
stderrTask = Task.detached {
|
||||
let handle = pipe.fileHandleForReading
|
||||
var buf = Data()
|
||||
while true {
|
||||
let data = handle.availableData
|
||||
if data.isEmpty { break }
|
||||
buf.append(data)
|
||||
while let idx = buf.firstIndex(of: UInt8(ascii: "\n")) {
|
||||
let line = String(data: buf[buf.startIndex..<idx], encoding: .utf8) ?? ""
|
||||
buf = Data(buf[buf.index(after: idx)...])
|
||||
if !line.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
Log.extMcp.warning("[\(name)] \(line)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Data Processing (MainActor)
|
||||
|
||||
private func receiveData(_ data: Data) {
|
||||
lineBuffer.append(data)
|
||||
while let idx = lineBuffer.firstIndex(of: UInt8(ascii: "\n")) {
|
||||
let lineData = Data(lineBuffer[lineBuffer.startIndex..<idx])
|
||||
lineBuffer = Data(lineBuffer[lineBuffer.index(after: idx)...])
|
||||
processLine(lineData)
|
||||
}
|
||||
}
|
||||
|
||||
private func processLine(_ data: Data) {
|
||||
guard !data.isEmpty,
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let id = json["id"] as? Int,
|
||||
let cont = pendingCalls.removeValue(forKey: id) else { return }
|
||||
|
||||
do {
|
||||
let resultData = try MCPTransportSupport.extractResult(from: json)
|
||||
cont.resume(returning: resultData)
|
||||
} catch {
|
||||
cont.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelPendingCall(id: Int, with error: Error) {
|
||||
pendingCalls.removeValue(forKey: id)?.resume(throwing: error)
|
||||
}
|
||||
|
||||
private func writeJSON(_ message: [String: Any]) throws {
|
||||
guard let handle = stdinHandle, process?.isRunning == true else {
|
||||
throw MCPClientError.writeFailed
|
||||
}
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: message),
|
||||
let line = String(data: data, encoding: .utf8) else {
|
||||
throw MCPClientError.writeFailed
|
||||
}
|
||||
do {
|
||||
try handle.write(contentsOf: Data((line + "\n").utf8))
|
||||
} catch {
|
||||
throw MCPClientError.writeFailed
|
||||
}
|
||||
}
|
||||
|
||||
private func handleProcessTerminated() {
|
||||
for (_, cont) in pendingCalls { cont.resume(throwing: MCPClientError.notConnected) }
|
||||
pendingCalls.removeAll()
|
||||
onTerminated?()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HTTP (Streamable HTTP) Transport
|
||||
|
||||
/// Speaks MCP's Streamable HTTP transport (spec revision 2025-06-18): every JSON-RPC message is
|
||||
/// its own HTTP POST to the server's single MCP endpoint. The server may answer with a plain
|
||||
/// `application/json` body, or open a `text/event-stream` (SSE) response — this transport
|
||||
/// supports both, but (since Confab only needs request/response tool calls, not server-initiated
|
||||
/// push) does not open a standalone listening GET stream for unsolicited server messages.
|
||||
@MainActor
|
||||
final class HTTPMCPTransport: MCPTransport {
|
||||
private let server: ExternalMCPServer
|
||||
private let urlSession: URLSession
|
||||
private var sessionId: String?
|
||||
|
||||
init(server: ExternalMCPServer, urlSession: URLSession = .shared) {
|
||||
self.server = server
|
||||
self.urlSession = urlSession
|
||||
}
|
||||
|
||||
func prepare() async throws {
|
||||
guard URL(string: server.url) != nil else {
|
||||
throw MCPClientError.invalidConfiguration("Invalid server URL: \(server.url)")
|
||||
}
|
||||
// No persistent connection to establish ahead of time — the first request (`initialize`)
|
||||
// both opens the session and confirms the server is reachable.
|
||||
}
|
||||
|
||||
func sendRequest(_ message: [String: Any], id: Int, timeoutSeconds: Double) async throws -> Data {
|
||||
let request = try buildRequest(for: message, timeoutSeconds: timeoutSeconds)
|
||||
let (data, response) = try await urlSession.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw MCPClientError.invalidResponse("Non-HTTP response")
|
||||
}
|
||||
if let newSessionId = http.value(forHTTPHeaderField: "Mcp-Session-Id") {
|
||||
sessionId = newSessionId
|
||||
}
|
||||
guard (200...299).contains(http.statusCode) else {
|
||||
throw MCPClientError.invalidResponse("HTTP \(http.statusCode)")
|
||||
}
|
||||
let json = try Self.parseResponseBody(
|
||||
data, contentType: http.value(forHTTPHeaderField: "Content-Type"), expectedId: id
|
||||
)
|
||||
return try MCPTransportSupport.extractResult(from: json)
|
||||
}
|
||||
|
||||
func sendNotification(_ message: [String: Any]) async throws {
|
||||
// Notifications carry no "id", so per spec the server responds 202 Accepted, no body.
|
||||
let request = try buildRequest(for: message, timeoutSeconds: 15)
|
||||
let (_, response) = try await urlSession.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
|
||||
throw MCPClientError.invalidResponse("Notification not accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
sessionId = nil
|
||||
}
|
||||
|
||||
private func buildRequest(for message: [String: Any], timeoutSeconds: Double) throws -> URLRequest {
|
||||
guard let url = URL(string: server.url) else {
|
||||
throw MCPClientError.invalidConfiguration("Invalid server URL: \(server.url)")
|
||||
}
|
||||
var request = URLRequest(url: url, timeoutInterval: timeoutSeconds)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json, text/event-stream", forHTTPHeaderField: "Accept")
|
||||
request.setValue("2025-06-18", forHTTPHeaderField: "MCP-Protocol-Version")
|
||||
if !server.bearerToken.isEmpty {
|
||||
request.setValue("Bearer \(server.bearerToken)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
for (key, value) in server.headers {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
if let sessionId {
|
||||
request.setValue(sessionId, forHTTPHeaderField: "Mcp-Session-Id")
|
||||
}
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: message)
|
||||
return request
|
||||
}
|
||||
|
||||
/// Parses either a direct `application/json` body, or an SSE (`text/event-stream`) body —
|
||||
/// scanning its `data:` lines for the JSON-RPC message whose `id` matches `expectedId` (the
|
||||
/// server may send unrelated requests/notifications on the same stream first, per spec).
|
||||
nonisolated static func parseResponseBody(_ data: Data, contentType: String?, expectedId: Int) throws -> [String: Any] {
|
||||
if contentType?.contains("text/event-stream") == true {
|
||||
guard let text = String(data: data, encoding: .utf8) else {
|
||||
throw MCPClientError.invalidResponse("Non-UTF8 SSE body")
|
||||
}
|
||||
for line in text.components(separatedBy: "\n") {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
guard trimmed.hasPrefix("data:") else { continue }
|
||||
let payload = trimmed.dropFirst("data:".count).trimmingCharacters(in: .whitespaces)
|
||||
guard let payloadData = payload.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any],
|
||||
let id = json["id"] as? Int, id == expectedId
|
||||
else { continue }
|
||||
return json
|
||||
}
|
||||
throw MCPClientError.invalidResponse("No matching response in SSE stream")
|
||||
}
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw MCPClientError.invalidResponse("Malformed JSON response body")
|
||||
}
|
||||
return json
|
||||
}
|
||||
}
|
||||
@@ -1043,6 +1043,36 @@ class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CLI Server
|
||||
|
||||
/// Local Unix-socket server (CLIServerService) for one-shot, non-streaming shell access to a
|
||||
/// single fixed model — deliberately separate from the chat UI's defaultProvider/defaultModel
|
||||
/// so switching models in the GUI never changes shell-tool behavior, same reasoning as the
|
||||
/// email handler's own dedicated provider/model pair above.
|
||||
var cliServerEnabled: Bool {
|
||||
get { cache["cliServerEnabled"] == "true" }
|
||||
set {
|
||||
cache["cliServerEnabled"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "cliServerEnabled", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var cliServerProvider: String {
|
||||
get { cache["cliServerProvider"] ?? "openrouter" }
|
||||
set {
|
||||
cache["cliServerProvider"] = newValue
|
||||
DatabaseService.shared.setSetting(key: "cliServerProvider", value: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
var cliServerModel: String {
|
||||
get { cache["cliServerModel"] ?? "" }
|
||||
set {
|
||||
cache["cliServerModel"] = newValue
|
||||
DatabaseService.shared.setSetting(key: "cliServerModel", value: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
var emailSubjectIdentifier: String {
|
||||
get { cache["emailSubjectIdentifier"] ?? "[OAIBOT]" }
|
||||
set {
|
||||
|
||||
@@ -23,14 +23,44 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
struct ThinkingVerbs {
|
||||
/// Get a random thinking verb with ellipsis
|
||||
/// Marked `nonisolated` at the type level (not per-member) since this project builds with
|
||||
/// `-default-isolation=MainActor` — without it, every member (including the static `let` verb
|
||||
/// arrays) defaults to MainActor-isolated, which then conflicts with `nonisolated` funcs trying
|
||||
/// to reference them. See CLAUDE.md's Swift 6 Compatibility section / MEMORY.md's Common Gotchas.
|
||||
nonisolated struct ThinkingVerbs {
|
||||
/// Get a random thinking verb with ellipsis, in the app's active display language.
|
||||
/// Each language has its own hand-written set (not a translation of the English one) —
|
||||
/// literal translations of English wordplay ("Waking up the hamsters") often land flat
|
||||
/// or sound plain odd in another language, so every list was written to be funny/natural
|
||||
/// on its own terms while covering the same rough categories (classic, technical, mystical,
|
||||
/// quirky, etc).
|
||||
static func random() -> String {
|
||||
verbs.randomElement()! + "..."
|
||||
verbs(for: currentLanguageCode).randomElement()! + "..."
|
||||
}
|
||||
|
||||
/// Collection of fun thinking verbs and phrases
|
||||
private static let verbs = [
|
||||
/// Mirrors how Text()/String(localized:) resolve the active language: the first of the
|
||||
/// user's preferred languages that this bundle actually ships a localization for (falls
|
||||
/// back to English otherwise). See CLAUDE.md's supported-language list (en, nb, sv, da, de, fr).
|
||||
private static var currentLanguageCode: String {
|
||||
Bundle.main.preferredLocalizations.first ?? "en"
|
||||
}
|
||||
|
||||
/// Internal (not private) so tests can verify the language→list mapping without depending
|
||||
/// on Bundle.main's runtime localization state.
|
||||
static func verbs(for languageCode: String) -> [String] {
|
||||
switch languageCode {
|
||||
case "nb": return nbVerbs
|
||||
case "sv": return svVerbs
|
||||
case "da": return daVerbs
|
||||
case "de": return deVerbs
|
||||
case "fr": return frVerbs
|
||||
default: return enVerbs
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - English
|
||||
|
||||
private static let enVerbs = [
|
||||
// Classic thinking
|
||||
"Thinking",
|
||||
"Pondering",
|
||||
@@ -144,4 +174,589 @@ struct ThinkingVerbs {
|
||||
"Loading brilliance",
|
||||
"Unfurling wisdom"
|
||||
]
|
||||
|
||||
// MARK: - Norwegian Bokmål
|
||||
|
||||
private static let nbVerbs = [
|
||||
// Classic thinking
|
||||
"Tenker",
|
||||
"Grunner",
|
||||
"Fundere",
|
||||
"Reflekterer",
|
||||
"Mediterer",
|
||||
"Spekulerer",
|
||||
"Overveier",
|
||||
|
||||
// Fancy/sophisticated
|
||||
"Kontemplerer",
|
||||
"Filosoferer",
|
||||
"Resonnerer",
|
||||
"Analyserer dypt",
|
||||
"Bryner hjernen",
|
||||
|
||||
// Technical/AI themed
|
||||
"Beregner",
|
||||
"Prosesserer",
|
||||
"Analyserer",
|
||||
"Syntetiserer",
|
||||
"Kalkulerer",
|
||||
"Utleder",
|
||||
"Kompilerer tanker",
|
||||
"Kjører algoritmer",
|
||||
"Knuser data",
|
||||
"Parser nevroner",
|
||||
|
||||
// Creative/playful
|
||||
"Dagdrømmer",
|
||||
"Idémyldrer",
|
||||
"Koker sammen tanker",
|
||||
"Rører i tankegryta",
|
||||
"Lar det godgjøre seg",
|
||||
"Spinner nevroner",
|
||||
"Varmer opp knollen",
|
||||
"Klekker ut idéer",
|
||||
|
||||
// Mystical/fun
|
||||
"Rådfører orakelet",
|
||||
"Leser kaffegrut",
|
||||
"Maner frem visdom",
|
||||
"Trollbinder et svar",
|
||||
"Kaster nevrale garn",
|
||||
"Spår svaret",
|
||||
|
||||
// Quirky/silly
|
||||
"Gjør greia",
|
||||
"Trylle frem et svar",
|
||||
"Aktiverer hjerneceller",
|
||||
"Bøyer nevronene",
|
||||
"Varmer opp transistorene",
|
||||
"Rever opp synapsene",
|
||||
"Kiler hjernebarken",
|
||||
"Vekker hamsterne",
|
||||
"Rådfører tomrommet",
|
||||
"Spør den magiske 8-ballen",
|
||||
|
||||
// Self-aware/meta
|
||||
"Later som om jeg tenker",
|
||||
"Ser opptatt ut",
|
||||
"Trekker ut tiden",
|
||||
"Teller sauer",
|
||||
"Tvinner tomlene",
|
||||
"Ordner tankene",
|
||||
"Leter etter riktige ord",
|
||||
|
||||
// Speed variations
|
||||
"Tenker fort",
|
||||
"Lynraskt tankearbeid",
|
||||
"Dyptenkende",
|
||||
"Hypertenker",
|
||||
|
||||
// Action-oriented
|
||||
"Snekrer sammen et svar",
|
||||
"Vever ord",
|
||||
"Setter sammen tanker",
|
||||
"Konstruerer svar",
|
||||
"Formulerer idéer",
|
||||
"Dirigerer nevroner",
|
||||
"Koreograferer bits",
|
||||
|
||||
// Whimsical
|
||||
"Får en åpenbaring",
|
||||
"Kobler prikkene",
|
||||
"Følger tråden",
|
||||
"Jager tanker",
|
||||
"Gjeter idéer",
|
||||
"Reder ut nevronfloken",
|
||||
|
||||
// Time-based
|
||||
"Tar en tenkepause",
|
||||
"Puster rolig",
|
||||
"Tar fem",
|
||||
"Samler tankene",
|
||||
"Henter pusten",
|
||||
|
||||
// Just plain weird
|
||||
"Piper og beregner",
|
||||
"Aktiverer hjernemodus",
|
||||
"Slår på smartheten",
|
||||
"Laster ned tanker",
|
||||
"Bufrer intelligens",
|
||||
"Laster inn genialitet",
|
||||
"Folder ut visdom",
|
||||
"Roter i idébanken",
|
||||
"Sorterer tankene",
|
||||
"Venter på et lyn av genialitet",
|
||||
"Plager hjernen med det",
|
||||
"Kokende av idéer",
|
||||
"Grubler høyt",
|
||||
"Filosoferer over saken"
|
||||
]
|
||||
|
||||
// MARK: - Swedish
|
||||
|
||||
private static let svVerbs = [
|
||||
// Classic thinking
|
||||
"Tänker",
|
||||
"Funderar",
|
||||
"Grubblar",
|
||||
"Reflekterar",
|
||||
"Mediterar",
|
||||
"Spekulerar",
|
||||
"Överväger",
|
||||
|
||||
// Fancy/sophisticated
|
||||
"Kontemplerar",
|
||||
"Filosoferar",
|
||||
"Resonerar",
|
||||
"Djupanalyserar",
|
||||
"Vässar hjärnan",
|
||||
|
||||
// Technical/AI themed
|
||||
"Beräknar",
|
||||
"Processar",
|
||||
"Analyserar",
|
||||
"Syntetiserar",
|
||||
"Kalkylerar",
|
||||
"Härleder",
|
||||
"Kompilerar tankar",
|
||||
"Kör algoritmer",
|
||||
"Mosar data",
|
||||
"Parsar nervceller",
|
||||
|
||||
// Creative/playful
|
||||
"Dagdrömmer",
|
||||
"Idékläcker",
|
||||
"Kokar ihop tankar",
|
||||
"Rör i tankegrytan",
|
||||
"Låter det mogna",
|
||||
"Snurrar nervceller",
|
||||
"Värmer upp knoppen",
|
||||
"Ruvar på idéer",
|
||||
|
||||
// Mystical/fun
|
||||
"Rådfrågar oraklet",
|
||||
"Läser i kaffesumpen",
|
||||
"Frammanar visdom",
|
||||
"Trollbinder ett svar",
|
||||
"Kastar neurala nät",
|
||||
"Spår svaret",
|
||||
|
||||
// Quirky/silly
|
||||
"Gör grejen",
|
||||
"Trollar fram ett svar",
|
||||
"Aktiverar hjärnceller",
|
||||
"Böjer nervcellerna",
|
||||
"Värmer upp transistorerna",
|
||||
"Varvar upp synapserna",
|
||||
"Kittlar hjärnbarken",
|
||||
"Väcker hamstrarna",
|
||||
"Rådfrågar tomrummet",
|
||||
"Frågar magiska åttan",
|
||||
|
||||
// Self-aware/meta
|
||||
"Låtsas tänka",
|
||||
"Ser upptagen ut",
|
||||
"Drar ut på tiden",
|
||||
"Räknar får",
|
||||
"Tvinnar tummarna",
|
||||
"Ordnar tankarna",
|
||||
"Letar rätt ord",
|
||||
|
||||
// Speed variations
|
||||
"Tänker snabbt",
|
||||
"Blixtsnabb tankeverksamhet",
|
||||
"Djuptänkande",
|
||||
"Hypertänker",
|
||||
|
||||
// Action-oriented
|
||||
"Snickrar ihop ett svar",
|
||||
"Väver ord",
|
||||
"Sätter ihop tankar",
|
||||
"Konstruerar svar",
|
||||
"Formulerar idéer",
|
||||
"Dirigerar nervceller",
|
||||
"Koreograferar bitar",
|
||||
|
||||
// Whimsical
|
||||
"Får en aha-upplevelse",
|
||||
"Kopplar ihop punkterna",
|
||||
"Följer tråden",
|
||||
"Jagar tankar",
|
||||
"Vallar idéer",
|
||||
"Reder ut nervtrasslet",
|
||||
|
||||
// Time-based
|
||||
"Tar en tankepaus",
|
||||
"Andas lugnt",
|
||||
"Tar fem",
|
||||
"Samlar tankarna",
|
||||
"Hämtar andan",
|
||||
|
||||
// Just plain weird
|
||||
"Piper och beräknar",
|
||||
"Aktiverar hjärnläge",
|
||||
"Slår på smartheten",
|
||||
"Laddar ner tankar",
|
||||
"Buffrar intelligens",
|
||||
"Laddar in briljans",
|
||||
"Vecklar ut visdom",
|
||||
"Rotar i idébanken",
|
||||
"Sorterar tankarna",
|
||||
"Väntar på ett geniblixt",
|
||||
"Bryr hjärnan med det",
|
||||
"Kokar av idéer",
|
||||
"Grubblar högt",
|
||||
"Filosoferar över saken"
|
||||
]
|
||||
|
||||
// MARK: - Danish
|
||||
|
||||
private static let daVerbs = [
|
||||
// Classic thinking
|
||||
"Tænker",
|
||||
"Grunder",
|
||||
"Grubler",
|
||||
"Reflekterer",
|
||||
"Mediterer",
|
||||
"Spekulerer",
|
||||
"Overvejer",
|
||||
|
||||
// Fancy/sophisticated
|
||||
"Kontemplerer",
|
||||
"Filosoferer",
|
||||
"Ræsonnerer",
|
||||
"Dybdeanalyserer",
|
||||
"Skærper hjernen",
|
||||
|
||||
// Technical/AI themed
|
||||
"Beregner",
|
||||
"Processerer",
|
||||
"Analyserer",
|
||||
"Syntetiserer",
|
||||
"Kalkulerer",
|
||||
"Udleder",
|
||||
"Kompilerer tanker",
|
||||
"Kører algoritmer",
|
||||
"Knuser data",
|
||||
"Parser neuroner",
|
||||
|
||||
// Creative/playful
|
||||
"Dagdrømmer",
|
||||
"Idémylrer",
|
||||
"Koger tanker sammen",
|
||||
"Rører i tankegryden",
|
||||
"Lader det simre",
|
||||
"Snurrer neuroner",
|
||||
"Varmer knolden op",
|
||||
"Udruger idéer",
|
||||
|
||||
// Mystical/fun
|
||||
"Rådspørger orakelet",
|
||||
"Læser kaffegrums",
|
||||
"Fremmaner visdom",
|
||||
"Tryller et svar frem",
|
||||
"Kaster neurale net",
|
||||
"Spår svaret",
|
||||
|
||||
// Quirky/silly
|
||||
"Gør tingen",
|
||||
"Trylle-fremkalder et svar",
|
||||
"Aktiverer hjerneceller",
|
||||
"Bøjer neuronerne",
|
||||
"Varmer transistorerne op",
|
||||
"Ruller synapserne op",
|
||||
"Kilder hjernebarken",
|
||||
"Vækker hamsterne",
|
||||
"Rådspørger tomrummet",
|
||||
"Spørger den magiske 8-tal",
|
||||
|
||||
// Self-aware/meta
|
||||
"Lader som om jeg tænker",
|
||||
"Ser optaget ud",
|
||||
"Trækker tiden ud",
|
||||
"Tæller får",
|
||||
"Snor tommelfingrene",
|
||||
"Ordner tankerne",
|
||||
"Leder efter de rette ord",
|
||||
|
||||
// Speed variations
|
||||
"Tænker hurtigt",
|
||||
"Lynhurtig tænkning",
|
||||
"Dybttænkende",
|
||||
"Hypertænker",
|
||||
|
||||
// Action-oriented
|
||||
"Snedkererer et svar",
|
||||
"Væver ord",
|
||||
"Samler tanker",
|
||||
"Konstruerer svar",
|
||||
"Formulerer idéer",
|
||||
"Dirigerer neuroner",
|
||||
"Koreograferer bits",
|
||||
|
||||
// Whimsical
|
||||
"Får en åbenbaring",
|
||||
"Forbinder prikkerne",
|
||||
"Følger tråden",
|
||||
"Jagter tanker",
|
||||
"Vogter idéer",
|
||||
"Reder neurontrådene ud",
|
||||
|
||||
// Time-based
|
||||
"Tager en tænkepause",
|
||||
"Trækker vejret roligt",
|
||||
"Tager fem",
|
||||
"Samler tankerne",
|
||||
"Henter vejret",
|
||||
|
||||
// Just plain weird
|
||||
"Bipper og beregner",
|
||||
"Aktiverer hjernemodus",
|
||||
"Tænder for kløgt",
|
||||
"Downloader tanker",
|
||||
"Bufrer intelligens",
|
||||
"Indlæser genialitet",
|
||||
"Folder visdom ud",
|
||||
"Roder i idébanken",
|
||||
"Sorterer tankerne",
|
||||
"Venter på et lyn af genialitet",
|
||||
"Plager hjernen med det",
|
||||
"Kogende af idéer",
|
||||
"Grunder højt",
|
||||
"Filosoferer over sagen"
|
||||
]
|
||||
|
||||
// MARK: - German
|
||||
|
||||
private static let deVerbs = [
|
||||
// Classic thinking
|
||||
"Denkt nach",
|
||||
"Grübelt",
|
||||
"Sinniert",
|
||||
"Reflektiert",
|
||||
"Meditiert",
|
||||
"Spekuliert",
|
||||
"Überlegt",
|
||||
|
||||
// Fancy/sophisticated
|
||||
"Kontempliert",
|
||||
"Philosophiert",
|
||||
"Räsoniert",
|
||||
"Analysiert tiefgründig",
|
||||
"Schärft das Denkvermögen",
|
||||
|
||||
// Technical/AI themed
|
||||
"Berechnet",
|
||||
"Verarbeitet",
|
||||
"Analysiert",
|
||||
"Synthetisiert",
|
||||
"Kalkuliert",
|
||||
"Leitet ab",
|
||||
"Kompiliert Gedanken",
|
||||
"Lässt Algorithmen laufen",
|
||||
"Zermalmt Daten",
|
||||
"Parst Neuronen",
|
||||
|
||||
// Creative/playful
|
||||
"Tagträumt",
|
||||
"Brainstormt",
|
||||
"Braut Gedanken zusammen",
|
||||
"Rührt im Gedankentopf",
|
||||
"Lässt es köcheln",
|
||||
"Dreht Neuronen",
|
||||
"Wärmt die Denkerstube auf",
|
||||
"Brütet Ideen aus",
|
||||
|
||||
// Mystical/fun
|
||||
"Befragt das Orakel",
|
||||
"Liest im Kaffeesatz",
|
||||
"Beschwört Weisheit",
|
||||
"Zaubert eine Antwort",
|
||||
"Wirft neuronale Netze aus",
|
||||
"Weissagt die Antwort",
|
||||
|
||||
// Quirky/silly
|
||||
"Macht sein Ding",
|
||||
"Zaubert eine Antwort herbei",
|
||||
"Aktiviert Gehirnzellen",
|
||||
"Biegt Neuronen",
|
||||
"Wärmt die Transistoren auf",
|
||||
"Dreht die Synapsen hoch",
|
||||
"Kitzelt die Großhirnrinde",
|
||||
"Weckt die Hamster",
|
||||
"Befragt die Leere",
|
||||
"Fragt die magische Kugel",
|
||||
|
||||
// Self-aware/meta
|
||||
"Tut nur so, als würde es denken",
|
||||
"Sieht beschäftigt aus",
|
||||
"Zieht Zeit",
|
||||
"Zählt Schafe",
|
||||
"Dreht Däumchen",
|
||||
"Ordnet die Gedanken",
|
||||
"Sucht die richtigen Worte",
|
||||
|
||||
// Speed variations
|
||||
"Denkt schnell",
|
||||
"Blitzschnelles Denken",
|
||||
"Tiefes Nachdenken",
|
||||
"Hyperdenken",
|
||||
|
||||
// Action-oriented
|
||||
"Zimmert eine Antwort",
|
||||
"Webt Worte",
|
||||
"Fügt Gedanken zusammen",
|
||||
"Konstruiert Antworten",
|
||||
"Formuliert Ideen",
|
||||
"Dirigiert Neuronen",
|
||||
"Choreografiert Bits",
|
||||
|
||||
// Whimsical
|
||||
"Hat eine Eingebung",
|
||||
"Verbindet die Punkte",
|
||||
"Folgt dem roten Faden",
|
||||
"Jagt Gedanken",
|
||||
"Hütet Ideen",
|
||||
"Entwirrt die Neuronen",
|
||||
|
||||
// Time-based
|
||||
"Nimmt sich einen Moment",
|
||||
"Atmet tief durch",
|
||||
"Macht kurz Pause",
|
||||
"Sammelt die Gedanken",
|
||||
"Holt Luft",
|
||||
|
||||
// Just plain weird
|
||||
"Piept und rechnet",
|
||||
"Aktiviert den Gehirnmodus",
|
||||
"Schaltet auf Schlaumodus",
|
||||
"Lädt Gedanken herunter",
|
||||
"Puffert Intelligenz",
|
||||
"Lädt Genialität",
|
||||
"Entfaltet Weisheit",
|
||||
"Wühlt in der Ideenkiste",
|
||||
"Sortiert die Gedanken",
|
||||
"Wartet auf einen Geistesblitz",
|
||||
"Quält das Gehirn damit",
|
||||
"Kocht vor Ideen",
|
||||
"Grübelt laut",
|
||||
"Philosophiert über die Sache"
|
||||
]
|
||||
|
||||
// MARK: - French
|
||||
|
||||
private static let frVerbs = [
|
||||
// Classic thinking
|
||||
"Réfléchit",
|
||||
"Songe",
|
||||
"Médite",
|
||||
"Contemple",
|
||||
"Spécule",
|
||||
"Délibère",
|
||||
"Rumine",
|
||||
|
||||
// Fancy/sophisticated
|
||||
"Philosophise",
|
||||
"Raisonne",
|
||||
"Cogite",
|
||||
"Analyse en profondeur",
|
||||
"Aiguise ses neurones",
|
||||
|
||||
// Technical/AI themed
|
||||
"Calcule",
|
||||
"Traite",
|
||||
"Analyse",
|
||||
"Synthétise",
|
||||
"Déduit",
|
||||
"Compile des pensées",
|
||||
"Fait tourner des algorithmes",
|
||||
"Broie des données",
|
||||
"Parse des neurones",
|
||||
"Chiffre les possibilités",
|
||||
|
||||
// Creative/playful
|
||||
"Rêvasse",
|
||||
"Brainstorme",
|
||||
"Mijote des idées",
|
||||
"Remue la marmite à idées",
|
||||
"Laisse mijoter",
|
||||
"Fait tourner ses neurones",
|
||||
"Chauffe la matière grise",
|
||||
"Couve une idée",
|
||||
|
||||
// Mystical/fun
|
||||
"Consulte l'oracle",
|
||||
"Lit dans le marc de café",
|
||||
"Invoque la sagesse",
|
||||
"Conjure une réponse",
|
||||
"Lance des filets neuronaux",
|
||||
"Prédit la réponse",
|
||||
|
||||
// Quirky/silly
|
||||
"Fait son truc",
|
||||
"Fait apparaître une réponse par magie",
|
||||
"Active ses neurones",
|
||||
"Étire ses neurones",
|
||||
"Chauffe les transistors",
|
||||
"Emballe les synapses",
|
||||
"Chatouille le cortex",
|
||||
"Réveille les hamsters",
|
||||
"Consulte le vide",
|
||||
"Interroge la boule magique",
|
||||
|
||||
// Self-aware/meta
|
||||
"Fait semblant de réfléchir",
|
||||
"A l'air occupé",
|
||||
"Fait durer le suspense",
|
||||
"Compte les moutons",
|
||||
"Se tourne les pouces",
|
||||
"Met de l'ordre dans ses pensées",
|
||||
"Cherche les mots justes",
|
||||
|
||||
// Speed variations
|
||||
"Réfléchit vite",
|
||||
"Pensée éclair",
|
||||
"Réflexion profonde",
|
||||
"Hyper-réflexion",
|
||||
|
||||
// Action-oriented
|
||||
"Bricole une réponse",
|
||||
"Tisse des mots",
|
||||
"Assemble des idées",
|
||||
"Construit une réponse",
|
||||
"Formule des idées",
|
||||
"Dirige ses neurones",
|
||||
"Chorégraphie des bits",
|
||||
|
||||
// Whimsical
|
||||
"A une révélation",
|
||||
"Relie les points",
|
||||
"Suit le fil",
|
||||
"Traque une pensée",
|
||||
"Rassemble ses idées",
|
||||
"Démêle ses neurones",
|
||||
|
||||
// Time-based
|
||||
"Prend un instant",
|
||||
"Respire un grand coup",
|
||||
"Fait une pause",
|
||||
"Rassemble ses pensées",
|
||||
"Reprend son souffle",
|
||||
|
||||
// Just plain weird
|
||||
"Bip bip, ça calcule",
|
||||
"Active le mode cerveau",
|
||||
"Passe en mode intelligent",
|
||||
"Télécharge des pensées",
|
||||
"Met l'intelligence en cache",
|
||||
"Charge du génie",
|
||||
"Déploie sa sagesse",
|
||||
"Fouille sa boîte à idées",
|
||||
"Trie ses pensées",
|
||||
"Attend un éclair de génie",
|
||||
"Torture son cerveau",
|
||||
"Bouillonne d'idées",
|
||||
"Rumine à voix haute",
|
||||
"Philosophise sur la question"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -152,4 +152,5 @@ enum Log {
|
||||
nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui")
|
||||
nonisolated static let general = AppLogger(subsystem: subsystem, category: "general")
|
||||
nonisolated static let extMcp = AppLogger(subsystem: subsystem, category: "ext-mcp")
|
||||
nonisolated static let cli = AppLogger(subsystem: subsystem, category: "cli")
|
||||
}
|
||||
|
||||
@@ -120,6 +120,13 @@ class ChatViewModel {
|
||||
var messages: [Message] = []
|
||||
var inputText: String = ""
|
||||
var isGenerating: Bool = false
|
||||
/// Live "what's happening right now" line shown under ProcessingIndicator's thinking verb
|
||||
/// while a tool-calling loop is running — e.g. "🔧 Calling: read_file". Replaced in place each
|
||||
/// round rather than appending a new chat message, so a long multi-round tool chain doesn't
|
||||
/// stack up a growing list of rows. Not persisted; nil whenever no tool round is in flight. The
|
||||
/// full chain is still recorded, just collapsed into one expandable summary message once the
|
||||
/// loop finishes — see generateAIResponseWithTools's use of allToolCallDetails.
|
||||
var currentToolActivity: String? = nil
|
||||
var sessionStats = SessionStats()
|
||||
var selectedModel: ModelInfo?
|
||||
var currentProvider: Settings.Provider = .openrouter
|
||||
@@ -1143,10 +1150,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
if let usage = response.usage {
|
||||
messages[index].tokens = usage.completionTokens
|
||||
if let model = selectedModel {
|
||||
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
|
||||
let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||
let cost = Self.resolveCost(usage: usage, pricing: model.pricing)
|
||||
messages[index].cost = cost
|
||||
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
||||
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1207,10 +1214,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
if let usage = totalTokens {
|
||||
messages[index].tokens = usage.completionTokens
|
||||
if let model = selectedModel {
|
||||
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
|
||||
let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||
let cost = Self.resolveCost(usage: usage, pricing: model.pricing)
|
||||
messages[index].cost = cost
|
||||
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
||||
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1567,6 +1574,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
let cost = usage.rawCostUSD
|
||||
messages[index].cost = cost
|
||||
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
||||
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -1589,6 +1597,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
streamingTask = Task {
|
||||
let startTime = Date()
|
||||
var wasCancelled = false
|
||||
// Accumulates ToolCallDetail entries across every round of this tool-calling loop —
|
||||
// collapsed into a single expandable summary message once the loop exits (success,
|
||||
// cancellation, or error), instead of one persisted message per round.
|
||||
var allToolCallDetails: [ToolCallDetail] = []
|
||||
do {
|
||||
// Include web_search tool when online mode is on (not needed for OpenRouter — it handles search via :online suffix)
|
||||
let tools = mcp.getToolSchemas(onlineMode: onlineMode && currentProvider != .openrouter)
|
||||
@@ -1741,15 +1753,16 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
break
|
||||
}
|
||||
|
||||
// Show what tools the model is calling
|
||||
// Show what tools the model is calling as a transient status line rather than
|
||||
// a new chat message — see currentToolActivity's doc comment.
|
||||
let toolNames = toolCalls.map { $0.functionName }.joined(separator: ", ")
|
||||
let toolMsgId = showSystemMessage("🔧 Calling: \(toolNames)")
|
||||
currentToolActivity = String(localized: "🔧 Calling: \(toolNames)")
|
||||
|
||||
// Initialise detail entries with inputs (results fill in below)
|
||||
// Initialise detail entries with inputs (results fill in below); appended to
|
||||
// allToolCallDetails once this round finishes executing.
|
||||
var toolDetails: [ToolCallDetail] = toolCalls.map { tc in
|
||||
ToolCallDetail(name: tc.functionName, input: tc.arguments, result: nil)
|
||||
}
|
||||
updateToolCallMessage(id: toolMsgId, details: toolDetails)
|
||||
|
||||
let usingTextCalls = !textCalls.isEmpty
|
||||
|
||||
@@ -1800,9 +1813,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
resultJSON = "{\"error\": \"Failed to serialize result\"}"
|
||||
}
|
||||
|
||||
// Update the detail entry with the result so the UI can show it
|
||||
// Record the result so the collapsed summary message can show it once
|
||||
// the whole tool-calling loop finishes.
|
||||
toolDetails[i].result = resultJSON
|
||||
updateToolCallMessage(id: toolMsgId, details: toolDetails)
|
||||
|
||||
if usingTextCalls {
|
||||
// Inject results as a user message for text-call models
|
||||
@@ -1822,6 +1835,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
apiMessages.append(["role": "user", "content": combined])
|
||||
}
|
||||
|
||||
allToolCallDetails.append(contentsOf: toolDetails)
|
||||
|
||||
// If this was the last iteration, note it
|
||||
if iteration == maxIterations - 1 {
|
||||
hitIterationLimit = true // We're exiting with pending tool calls
|
||||
@@ -1834,6 +1849,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
wasCancelled = true
|
||||
}
|
||||
|
||||
flushToolCallSummary(allToolCallDetails)
|
||||
|
||||
// If we hit the iteration limit or the model returned no text at all, silently
|
||||
// nudge a follow-up turn instead of showing a placeholder/blank bubble.
|
||||
let willAutoContinue = (hitIterationLimit || finishedWithEmptyContent) && !wasCancelled
|
||||
@@ -1843,13 +1860,13 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
if willAutoContinue && finalContent.isEmpty {
|
||||
// Nothing worth showing yet — still record usage/cost for this turn.
|
||||
if let usage = totalUsage, let model = selectedModel {
|
||||
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
|
||||
let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||
let cost = Self.resolveCost(usage: usage, pricing: model.pricing)
|
||||
sessionStats.addMessage(
|
||||
inputTokens: usage.promptTokens,
|
||||
outputTokens: usage.completionTokens,
|
||||
cost: cost
|
||||
)
|
||||
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
|
||||
}
|
||||
} else {
|
||||
let assistantMessage = Message(
|
||||
@@ -1868,8 +1885,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
|
||||
// Calculate cost
|
||||
if let usage = totalUsage, let model = selectedModel {
|
||||
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
|
||||
let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||
let cost = Self.resolveCost(usage: usage, pricing: model.pricing)
|
||||
if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) {
|
||||
messages[index].cost = cost
|
||||
}
|
||||
@@ -1878,6 +1894,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
outputTokens: usage.completionTokens,
|
||||
cost: cost
|
||||
)
|
||||
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1891,6 +1908,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
} catch {
|
||||
let responseTime = Date().timeIntervalSince(startTime)
|
||||
|
||||
// Same collapse as the success path — any tool rounds that completed before the
|
||||
// error/cancellation are still worth keeping a record of.
|
||||
flushToolCallSummary(allToolCallDetails)
|
||||
|
||||
// Check if this was a cancellation
|
||||
let isCancellation = Task.isCancelled || wasCancelled || error is CancellationError
|
||||
|
||||
@@ -1935,7 +1956,17 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
messages[idx].toolCalls = details
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Clears the live tool-activity status line and, if any tool calls actually ran, collapses
|
||||
/// them into a single persisted, expandable summary message — used on every exit path of
|
||||
/// generateAIResponseWithTools's loop (success, cancellation, or error).
|
||||
private func flushToolCallSummary(_ details: [ToolCallDetail]) {
|
||||
currentToolActivity = nil
|
||||
guard !details.isEmpty else { return }
|
||||
let summaryId = showSystemMessage("🔧 Used ^[\(details.count) tool call](inflect: true)")
|
||||
updateToolCallMessage(id: summaryId, details: details)
|
||||
}
|
||||
|
||||
// MARK: - Error Helpers
|
||||
|
||||
private func friendlyErrorMessage(from error: Error) -> String {
|
||||
@@ -2574,6 +2605,34 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
return inputCost + cacheReadCost + cacheWriteCost + outputCost
|
||||
}
|
||||
|
||||
/// Resolves a response's cost, preferring the provider's actual billed amount
|
||||
/// (`usage.rawCostUSD` — e.g. OpenRouter's `usage.include` cost, needed for models priced
|
||||
/// outside plain per-token rates like per-image generation) over token-based calculation.
|
||||
/// Falls back to `nil` when neither the raw cost nor per-token pricing is available.
|
||||
nonisolated static func resolveCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double? {
|
||||
if let raw = usage.rawCostUSD { return raw }
|
||||
guard pricing.prompt > 0 || pricing.completion > 0 else { return nil }
|
||||
return calculateCost(usage: usage, pricing: pricing)
|
||||
}
|
||||
|
||||
/// Records usage independent of whether this conversation ever gets explicitly saved (⌘S) —
|
||||
/// conversation *text* is opt-in, but tokens/cost/model/provider are tracked for every
|
||||
/// completed generation regardless, so the Analytics view reflects real usage either way.
|
||||
private func logUsageEvent(modelId: String, promptTokens: Int?, completionTokens: Int?, cost: Double?) {
|
||||
do {
|
||||
try DatabaseService.shared.logUsageEvent(
|
||||
provider: currentProvider.rawValue,
|
||||
modelId: modelId,
|
||||
promptTokens: promptTokens,
|
||||
completionTokens: completionTokens,
|
||||
cost: cost,
|
||||
conversationId: currentConversationId
|
||||
)
|
||||
} catch {
|
||||
Log.db.error("Failed to log usage event: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Summarize a chunk of messages into a concise summary
|
||||
private func summarizeMessageChunk(_ messages: [Message]) async -> String? {
|
||||
guard let provider = providerRegistry.getProvider(for: currentProvider),
|
||||
|
||||
@@ -53,7 +53,7 @@ struct ChatView: View {
|
||||
|
||||
// Processing indicator
|
||||
if viewModel.isGenerating && viewModel.messages.last?.isStreaming != true {
|
||||
ProcessingIndicator()
|
||||
ProcessingIndicator(toolActivity: viewModel.currentToolActivity)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
@@ -156,30 +156,43 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
struct ProcessingIndicator: View {
|
||||
/// Current tool round's status (e.g. "🔧 Calling: read_file"), replaced in place each round
|
||||
/// rather than the chat accumulating a new row per round — see ChatViewModel.currentToolActivity.
|
||||
let toolActivity: String?
|
||||
@State private var animating = false
|
||||
@State private var thinkingText = ThinkingVerbs.random()
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text(thinkingText)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.confabSecondary)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Text(thinkingText)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.confabSecondary)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
ForEach(0..<3) { index in
|
||||
Circle()
|
||||
.fill(Color.confabSecondary)
|
||||
.frame(width: 6, height: 6)
|
||||
.scaleEffect(animating ? 1.0 : 0.5)
|
||||
.animation(
|
||||
.easeInOut(duration: 0.6)
|
||||
.repeatForever()
|
||||
.delay(Double(index) * 0.2),
|
||||
value: animating
|
||||
)
|
||||
HStack(spacing: 4) {
|
||||
ForEach(0..<3) { index in
|
||||
Circle()
|
||||
.fill(Color.confabSecondary)
|
||||
.frame(width: 6, height: 6)
|
||||
.scaleEffect(animating ? 1.0 : 0.5)
|
||||
.animation(
|
||||
.easeInOut(duration: 0.6)
|
||||
.repeatForever()
|
||||
.delay(Double(index) * 0.2),
|
||||
value: animating
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let toolActivity {
|
||||
Text(toolActivity)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.confabSecondary.opacity(0.75))
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.15), value: toolActivity)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.confabSecondary.opacity(0.05))
|
||||
|
||||
@@ -47,7 +47,7 @@ enum SyncState {
|
||||
}
|
||||
}
|
||||
|
||||
var tooltipText: String {
|
||||
var tooltipText: LocalizedStringKey {
|
||||
switch self {
|
||||
case .disabled:
|
||||
return "Auto-sync disabled"
|
||||
|
||||
@@ -30,6 +30,7 @@ struct JarvisView: View {
|
||||
@State private var isLoadingUsage = false
|
||||
@State private var selectedAgent: JarvisAgent? = nil
|
||||
@State private var editContext: AgentEditContext? = nil
|
||||
@State private var selectedRun: JarvisAgentRun? = nil
|
||||
@State private var errorMessage: String? = nil
|
||||
@State private var actionInProgress: Set<String> = []
|
||||
|
||||
@@ -91,6 +92,9 @@ struct JarvisView: View {
|
||||
await saveAgent(existing: ctx.agent, input: input)
|
||||
})
|
||||
}
|
||||
.sheet(item: $selectedRun) { run in
|
||||
JarvisRunDetailSheet(run: run)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Agents Tab
|
||||
@@ -323,6 +327,8 @@ struct JarvisView: View {
|
||||
} else {
|
||||
List(agentRuns) { run in
|
||||
RunHistoryRow(run: run)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { selectedRun = run }
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
@@ -628,69 +634,41 @@ struct JarvisView: View {
|
||||
|
||||
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)
|
||||
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))
|
||||
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)
|
||||
}
|
||||
.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")
|
||||
HStack(spacing: 8) {
|
||||
if run.totalTokens > 0 {
|
||||
Text("^[\(run.totalTokens) token](inflect: true)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if let cost = run.costUsd, cost > 0 {
|
||||
Text(String(format: "$%.5f", cost))
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
Spacer()
|
||||
// Tapping the row (wired by the caller) opens JarvisRunDetailSheet with the full,
|
||||
// untruncated output/error — this used to be an inline chevron-expand capped at
|
||||
// lineLimit(20), which isn't "complete" for long output.
|
||||
if run.output != nil || run.error != nil {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
@@ -698,29 +676,175 @@ private struct RunHistoryRow: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var statusIcon: some View {
|
||||
switch run.status {
|
||||
JarvisRunStatusIcon(status: run.status, size: 14)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared between RunHistoryRow and JarvisRunDetailSheet so the two views can't drift apart.
|
||||
fileprivate struct JarvisRunStatusIcon: View {
|
||||
let status: String
|
||||
var size: CGFloat = 14
|
||||
|
||||
var body: some View {
|
||||
switch status {
|
||||
case "running":
|
||||
ProgressView().scaleEffect(0.6).frame(width: 14, height: 14)
|
||||
case "completed":
|
||||
ProgressView().scaleEffect(size / 24).frame(width: size, height: size)
|
||||
case "completed", "success":
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 14))
|
||||
.font(.system(size: size))
|
||||
.foregroundStyle(.green)
|
||||
case "failed":
|
||||
case "failed", "error":
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 14))
|
||||
.font(.system(size: size))
|
||||
.foregroundStyle(.red)
|
||||
case "stopped":
|
||||
Image(systemName: "stop.circle.fill")
|
||||
.font(.system(size: 14))
|
||||
.font(.system(size: size))
|
||||
.foregroundStyle(.orange)
|
||||
default:
|
||||
Image(systemName: "circle")
|
||||
.font(.system(size: 14))
|
||||
.font(.system(size: size))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Run Detail Sheet
|
||||
|
||||
/// Full, untruncated view of a single run — opened by tapping a row in the run history list.
|
||||
/// Output/error use an explicit Copy button rather than .textSelection(.enabled): a multi-line
|
||||
/// selectable Text can grab real AppKit first-responder status, and Escape then gets consumed by
|
||||
/// its own cancelOperation: handling instead of dismissing the sheet (silent beep, no log) — see
|
||||
/// the identical bug fixed in ModelInfoView.
|
||||
struct JarvisRunDetailSheet: View {
|
||||
let run: JarvisAgentRun
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var showOutputCopied = false
|
||||
@State private var showErrorCopied = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Run Details")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
Spacer()
|
||||
JarvisRunStatusIcon(status: run.status, size: 16)
|
||||
Text(run.status.capitalized)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
.keyboardShortcut(.escape, modifiers: [])
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
detailRow("Started", run.formattedStarted)
|
||||
if let dur = run.formattedDuration {
|
||||
detailRow("Duration", dur)
|
||||
}
|
||||
if let trigger = run.triggerType {
|
||||
detailRow("Trigger", trigger.capitalized)
|
||||
}
|
||||
if run.totalTokens > 0 {
|
||||
detailRow("Tokens", "\(run.totalTokens.formatted()) (\((run.inputTokens ?? 0).formatted()) in / \((run.outputTokens ?? 0).formatted()) out)")
|
||||
}
|
||||
if let cost = run.costUsd, cost > 0 {
|
||||
detailRow("Cost", String(format: "$%.5f", cost))
|
||||
}
|
||||
}
|
||||
|
||||
if let err = run.error {
|
||||
Divider()
|
||||
outputBlock(title: "Error", text: err, color: .red, showCopied: $showErrorCopied)
|
||||
}
|
||||
if let out = run.output {
|
||||
Divider()
|
||||
outputBlock(title: "Output", text: out, color: .primary, showCopied: $showOutputCopied)
|
||||
}
|
||||
if run.output == nil && run.error == nil {
|
||||
Text("No output for this run.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 600, idealWidth: 700, minHeight: 450, idealHeight: 620)
|
||||
.onExitCommand { dismiss() }
|
||||
}
|
||||
|
||||
private func detailRow(_ label: LocalizedStringKey, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 90, alignment: .leading)
|
||||
Text(value)
|
||||
.font(.system(size: 13))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func outputBlock(title: LocalizedStringKey, text: String, color: Color, showCopied: Binding<Bool>) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Button(action: { copy(text, showCopied: showCopied) }) {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: showCopied.wrappedValue ? "checkmark" : "doc.on.doc")
|
||||
.font(.system(size: 11))
|
||||
if showCopied.wrappedValue {
|
||||
Text("Copied!")
|
||||
.font(.system(size: 11))
|
||||
}
|
||||
}
|
||||
.foregroundColor(showCopied.wrappedValue ? .green : .secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Text(text)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundStyle(color)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(color.opacity(0.06))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
}
|
||||
|
||||
private func copy(_ text: String, showCopied: Binding<Bool>) {
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(text, forType: .string)
|
||||
withAnimation {
|
||||
showCopied.wrappedValue = true
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||
withAnimation {
|
||||
showCopied.wrappedValue = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Agent Editor Sheet
|
||||
|
||||
struct JarvisAgentEditorSheet: View {
|
||||
|
||||
@@ -28,7 +28,7 @@ struct ModelInfoView: View {
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Bindable private var settings = SettingsService.shared
|
||||
@State private var isDescriptionExpanded = false
|
||||
@State private var showDescriptionCopied = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -73,25 +73,42 @@ struct ModelInfoView: View {
|
||||
infoRow("Released", releaseDate.formatted(date: .abbreviated, time: .omitted))
|
||||
}
|
||||
if let desc = model.description {
|
||||
// Always shown in full, no truncate/expand toggle — Text with a lineLimit
|
||||
// nested inside this view's ScrollView doesn't reliably compute wrapping/
|
||||
// truncation (a well-documented SwiftUI/AppKit quirk: without a fixedSize
|
||||
// hint it hard-clips mid-word with no ellipsis; with one, sibling views in
|
||||
// the same VStack — like the former "More…" button — can silently fail to
|
||||
// lay out). The modal itself already scrolls, so a long description just
|
||||
// means more scrolling, which sidesteps the whole bug class.
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Description")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.secondary)
|
||||
HStack(spacing: 6) {
|
||||
Text("Description")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
// A Copy button instead of .textSelection(.enabled): clicking into
|
||||
// a multi-line selectable Text hands it real AppKit first-responder
|
||||
// status, and Escape then gets consumed by that text view's own
|
||||
// cancelOperation: handling before it ever reaches this modal's
|
||||
// onExitCommand — the beep-instead-of-dismiss bug Rune reported.
|
||||
// infoRow's single-line values keep .textSelection(.enabled); only
|
||||
// this multi-line block reproduced the bug.
|
||||
Button(action: copyDescription) {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: showDescriptionCopied ? "checkmark" : "doc.on.doc")
|
||||
.font(.system(size: 11))
|
||||
if showDescriptionCopied {
|
||||
Text("Copied!")
|
||||
.font(.system(size: 11))
|
||||
}
|
||||
}
|
||||
.foregroundColor(showDescriptionCopied ? .green : .secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Text(desc)
|
||||
.font(.body)
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(isDescriptionExpanded ? nil : 4)
|
||||
.textSelection(.enabled)
|
||||
if desc.count > 250 {
|
||||
Button(isDescriptionExpanded ? "Less" : "More…") {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isDescriptionExpanded.toggle()
|
||||
}
|
||||
}
|
||||
.font(.callout)
|
||||
.foregroundStyle(.blue)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
@@ -217,6 +234,29 @@ struct ModelInfoView: View {
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.frame(minWidth: 550, idealWidth: 650, minHeight: 550, idealHeight: 750)
|
||||
// Nearly every value in this modal has .textSelection(.enabled) (infoRow's value text,
|
||||
// the description). Once one of those has text-selection focus, Escape can get
|
||||
// intercepted by AppKit's text-selection machinery instead of reaching the close
|
||||
// button's .keyboardShortcut(.escape) — no action is bound there, so it just beeps
|
||||
// instead of dismissing. onExitCommand is macOS's dedicated hook for the "Escape/Cancel"
|
||||
// user command and fires regardless of which child currently holds focus, so it's a more
|
||||
// reliable place to handle this than a single button's keyboardShortcut alone.
|
||||
.onExitCommand { dismiss() }
|
||||
}
|
||||
|
||||
private func copyDescription() {
|
||||
guard let desc = model.description else { return }
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(desc, forType: .string)
|
||||
withAnimation {
|
||||
showDescriptionCopied = true
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||
withAnimation {
|
||||
showDescriptionCopied = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layout Helpers
|
||||
|
||||
@@ -25,6 +25,13 @@ import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import FoundationModels
|
||||
|
||||
/// One editable row in the External MCP Servers "add" sheet's env var / custom header lists.
|
||||
private struct MCPKeyValuePair: Identifiable {
|
||||
let id = UUID()
|
||||
var key: String = ""
|
||||
var value: String = ""
|
||||
}
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Bindable private var settingsService = SettingsService.shared
|
||||
@@ -77,8 +84,13 @@ struct SettingsView: View {
|
||||
// External MCP Servers state
|
||||
@State private var showAddExternalMCPServer = false
|
||||
@State private var newMCPServerName = ""
|
||||
@State private var newMCPServerTransportKind: MCPTransportKind = .stdio
|
||||
@State private var newMCPServerCommand = ""
|
||||
@State private var newMCPServerArgs = ""
|
||||
@State private var newMCPServerEnvPairs: [MCPKeyValuePair] = []
|
||||
@State private var newMCPServerURL = ""
|
||||
@State private var newMCPServerBearerToken = ""
|
||||
@State private var newMCPServerHeaderPairs: [MCPKeyValuePair] = []
|
||||
@State private var newMCPServerTimeout: Double = 30
|
||||
private var externalMCPManager = ExternalMCPManager.shared
|
||||
|
||||
@@ -113,6 +125,11 @@ struct SettingsView: View {
|
||||
@State private var isTestingEmailConnection = false
|
||||
@State private var emailConnectionTestResult: String?
|
||||
|
||||
// CLI server state
|
||||
@State private var showCLIModelSelector = false
|
||||
@State private var cliAvailableModels: [ModelInfo] = []
|
||||
@State private var isLoadingCLIModels = false
|
||||
|
||||
private let labelWidth: CGFloat = 160
|
||||
|
||||
// Default system prompt - generic for all models
|
||||
@@ -468,17 +485,28 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
}
|
||||
// Always visible (not gated behind Search Provider == .google): this key is also
|
||||
// used for Google embeddings in Semantic Search (Advanced tab), which has nothing
|
||||
// to do with which web search provider is selected. Search Engine ID is web-search
|
||||
// specific, so that one stays conditional.
|
||||
rowDivider()
|
||||
row("Google API Key") {
|
||||
SecureField("", text: $googleKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 300)
|
||||
.onAppear { googleKey = settingsService.googleAPIKey ?? "" }
|
||||
.onChange(of: googleKey) {
|
||||
settingsService.googleAPIKey = googleKey.isEmpty ? nil : googleKey
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Used for Google web search (if selected above) and for Google embeddings in Settings → Advanced → Semantic Search.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 4)
|
||||
if settingsService.searchProvider == .google {
|
||||
rowDivider()
|
||||
row("Google API Key") {
|
||||
SecureField("", text: $googleKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 300)
|
||||
.onAppear { googleKey = settingsService.googleAPIKey ?? "" }
|
||||
.onChange(of: googleKey) {
|
||||
settingsService.googleAPIKey = googleKey.isEmpty ? nil : googleKey
|
||||
}
|
||||
}
|
||||
rowDivider()
|
||||
row("Search Engine ID") {
|
||||
TextField("", text: $googleEngineID)
|
||||
@@ -911,6 +939,10 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
Divider()
|
||||
externalMCPSection
|
||||
|
||||
// MARK: CLI Access
|
||||
Divider()
|
||||
cliServerSection
|
||||
|
||||
// MARK: Personal Data
|
||||
if !PersonalDataTools.isHiddenPendingAppleFix {
|
||||
Divider()
|
||||
@@ -1040,7 +1072,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(server.name)
|
||||
.font(.system(size: 14))
|
||||
Text(([server.command] + server.args).joined(separator: " "))
|
||||
Text(server.transportKind == .http ? server.url : ([server.command] + server.args).joined(separator: " "))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
@@ -1075,8 +1107,13 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
|
||||
Button {
|
||||
newMCPServerName = ""
|
||||
newMCPServerTransportKind = .stdio
|
||||
newMCPServerCommand = ""
|
||||
newMCPServerArgs = ""
|
||||
newMCPServerEnvPairs = []
|
||||
newMCPServerURL = ""
|
||||
newMCPServerBearerToken = ""
|
||||
newMCPServerHeaderPairs = []
|
||||
newMCPServerTimeout = 30
|
||||
showAddExternalMCPServer = true
|
||||
} label: {
|
||||
@@ -1095,6 +1132,169 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CLI Access Section
|
||||
|
||||
@ViewBuilder
|
||||
private var cliServerSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "terminal")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.green)
|
||||
Text("CLI Access")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
}
|
||||
Text("Expose a local socket so a shell command (like a zsh \"ai\" function) can get a one-shot text reply from a single fixed model, without opening the app window. Confab must be running.")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
.onAppear {
|
||||
Task { await loadCLIModels() }
|
||||
}
|
||||
.sheet(isPresented: $showCLIModelSelector) {
|
||||
ModelSelectorView(
|
||||
models: cliAvailableModels.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending },
|
||||
selectedModel: cliAvailableModels.first(where: { $0.id == settingsService.cliServerModel })
|
||||
) { selectedModel in
|
||||
settingsService.cliServerModel = selectedModel.id
|
||||
showCLIModelSelector = false
|
||||
CLIServerService.shared.restart()
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Status")
|
||||
formSection {
|
||||
row("Enable CLI Access") {
|
||||
Toggle("", isOn: $settingsService.cliServerEnabled)
|
||||
.toggleStyle(.switch)
|
||||
.onChange(of: settingsService.cliServerEnabled) {
|
||||
CLIServerService.shared.restart()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if settingsService.cliServerEnabled {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Model")
|
||||
formSection {
|
||||
row("Provider") {
|
||||
Picker("", selection: $settingsService.cliServerProvider) {
|
||||
ForEach(ProviderRegistry.shared.configuredProviders, id: \.self) { provider in
|
||||
Text(provider.displayName).tag(provider.rawValue)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 250)
|
||||
.onChange(of: settingsService.cliServerProvider) {
|
||||
Task { await loadCLIModels() }
|
||||
CLIServerService.shared.restart()
|
||||
}
|
||||
}
|
||||
rowDivider()
|
||||
row("Model") {
|
||||
if isLoadingCLIModels {
|
||||
ProgressView().scaleEffect(0.7).frame(width: 250, alignment: .leading)
|
||||
} else if cliAvailableModels.isEmpty {
|
||||
Text("No models available")
|
||||
.font(.system(size: settingsService.guiTextSize))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 250, alignment: .leading)
|
||||
} else {
|
||||
Button(action: { showCLIModelSelector = true }) {
|
||||
HStack {
|
||||
Text(cliAvailableModels.first(where: { $0.id == settingsService.cliServerModel })?.name ?? "Select model...")
|
||||
.font(.system(size: settingsService.guiTextSize))
|
||||
.foregroundColor(.primary)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.frame(width: 250)
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Shell Function")
|
||||
Text("Add this to your ~/.zshrc, then run `ai \"your prompt\"` in Terminal:")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(Self.cliShellFunctionSnippet)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(.primary)
|
||||
.textSelection(.enabled)
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.secondary.opacity(0.08))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
Text("Requires jq (brew install jq).")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `noglob` (applied via the alias, not inside the function) stops zsh from trying to glob-expand
|
||||
// an unquoted prompt containing `?`/`*`/`[...]` — e.g. `ai Who are you?` — before `_ai_impl` ever
|
||||
// runs. A plain `ai() { ... }` function can't protect its own call site this way: filename
|
||||
// generation happens during command-line parsing, before the shell has even decided this is a
|
||||
// function call. Quoting the prompt is still the fully robust habit for other shell metacharacters
|
||||
// (`;`, `|`, backticks, `$()`), but this covers the specific class of crash users are most likely
|
||||
// to hit by accident (an unquoted question ending in "?").
|
||||
// Deliberately one line, no backslash continuations: a `\` line-continuation is silently
|
||||
// broken by a trailing space or a dropped backslash from copy/pasting out of a chat UI or
|
||||
// browser — each following line then gets parsed as its own bogus command ("command not
|
||||
// found: -H", etc.). A long single line has no continuation character to mangle.
|
||||
private static let cliShellFunctionSnippet = """
|
||||
_ai_impl() {
|
||||
curl -s --unix-socket "$HOME/Library/Application Support/oAI/cli.sock" -H "Content-Type: application/json" -d "$(jq -n --arg p "$*" '{prompt: $p}')" http://localhost/ | jq -r 'if .error then "Error: " + .error else .response end'
|
||||
}
|
||||
alias ai='noglob _ai_impl'
|
||||
"""
|
||||
|
||||
private func loadCLIModels() async {
|
||||
guard settingsService.cliServerEnabled else {
|
||||
cliAvailableModels = []
|
||||
return
|
||||
}
|
||||
|
||||
let providerRawValue = settingsService.cliServerProvider
|
||||
guard let providerType = Settings.Provider(rawValue: providerRawValue),
|
||||
let provider = ProviderRegistry.shared.getProvider(for: providerType) else {
|
||||
cliAvailableModels = []
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingCLIModels = true
|
||||
defer { isLoadingCLIModels = false }
|
||||
|
||||
do {
|
||||
let models = try await provider.listModels()
|
||||
cliAvailableModels = models
|
||||
|
||||
if !models.contains(where: { $0.id == settingsService.cliServerModel }) {
|
||||
if let firstModel = models.first {
|
||||
settingsService.cliServerModel = firstModel.id
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Log.ui.error("Failed to load CLI server models: \(error.localizedDescription)")
|
||||
cliAvailableModels = []
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var addExternalMCPServerSheet: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
@@ -1109,19 +1309,47 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
.frame(width: 240)
|
||||
}
|
||||
rowDivider()
|
||||
row("Command") {
|
||||
TextField("safaridriver", text: $newMCPServerCommand)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.frame(width: 240)
|
||||
row("Type") {
|
||||
Picker("", selection: $newMCPServerTransportKind) {
|
||||
Text("Command").tag(MCPTransportKind.stdio)
|
||||
Text("Remote (HTTP)").tag(MCPTransportKind.http)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.frame(width: 240)
|
||||
.help("Command: a local program Confab launches itself. Remote (HTTP): an already-running MCP server reachable by URL.")
|
||||
}
|
||||
rowDivider()
|
||||
row("Arguments") {
|
||||
TextField("--mcp", text: $newMCPServerArgs)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.frame(width: 240)
|
||||
.help("Space-separated arguments")
|
||||
switch newMCPServerTransportKind {
|
||||
case .stdio:
|
||||
row("Command") {
|
||||
TextField("safaridriver", text: $newMCPServerCommand)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.frame(width: 240)
|
||||
}
|
||||
rowDivider()
|
||||
row("Arguments") {
|
||||
TextField("--mcp", text: $newMCPServerArgs)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.frame(width: 240)
|
||||
.help("Space-separated arguments")
|
||||
}
|
||||
case .http:
|
||||
row("Server URL") {
|
||||
TextField("http://127.0.0.1:27123/mcp/", text: $newMCPServerURL)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.frame(width: 240)
|
||||
}
|
||||
rowDivider()
|
||||
row("Bearer Token") {
|
||||
SecureField("Optional", text: $newMCPServerBearerToken)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.frame(width: 240)
|
||||
}
|
||||
}
|
||||
rowDivider()
|
||||
row("Timeout") {
|
||||
@@ -1136,6 +1364,15 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
}
|
||||
}
|
||||
|
||||
formSection {
|
||||
switch newMCPServerTransportKind {
|
||||
case .stdio:
|
||||
mcpKeyValueEditor(title: "Environment Variables", pairs: $newMCPServerEnvPairs)
|
||||
case .http:
|
||||
mcpKeyValueEditor(title: "Extra Headers", pairs: $newMCPServerHeaderPairs)
|
||||
}
|
||||
}
|
||||
|
||||
if !newMCPServerName.isEmpty {
|
||||
let slug = ExternalMCPServer.makeSlug(from: newMCPServerName)
|
||||
HStack(spacing: 6) {
|
||||
@@ -1157,25 +1394,93 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
Button("Cancel") { showAddExternalMCPServer = false }
|
||||
Spacer()
|
||||
Button("Add") {
|
||||
let args = ExternalMCPServer.parseArguments(newMCPServerArgs)
|
||||
let server = ExternalMCPServer(
|
||||
name: newMCPServerName,
|
||||
command: newMCPServerCommand,
|
||||
args: args,
|
||||
timeout: newMCPServerTimeout
|
||||
)
|
||||
let server: ExternalMCPServer
|
||||
switch newMCPServerTransportKind {
|
||||
case .stdio:
|
||||
server = ExternalMCPServer(
|
||||
name: newMCPServerName,
|
||||
transportKind: .stdio,
|
||||
command: newMCPServerCommand,
|
||||
args: ExternalMCPServer.parseArguments(newMCPServerArgs),
|
||||
env: mcpDictionary(from: newMCPServerEnvPairs),
|
||||
timeout: newMCPServerTimeout
|
||||
)
|
||||
case .http:
|
||||
server = ExternalMCPServer(
|
||||
name: newMCPServerName,
|
||||
transportKind: .http,
|
||||
url: newMCPServerURL,
|
||||
bearerToken: newMCPServerBearerToken,
|
||||
headers: mcpDictionary(from: newMCPServerHeaderPairs),
|
||||
timeout: newMCPServerTimeout
|
||||
)
|
||||
}
|
||||
settingsService.addExternalMCPServer(server)
|
||||
showAddExternalMCPServer = false
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(newMCPServerName.isEmpty || newMCPServerCommand.isEmpty ||
|
||||
ExternalMCPServer.reservedSlugs.contains(ExternalMCPServer.makeSlug(from: newMCPServerName)))
|
||||
.disabled(isAddExternalMCPServerDisabled)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(minWidth: 460, minHeight: 320)
|
||||
}
|
||||
|
||||
private var isAddExternalMCPServerDisabled: Bool {
|
||||
if newMCPServerName.isEmpty { return true }
|
||||
if ExternalMCPServer.reservedSlugs.contains(ExternalMCPServer.makeSlug(from: newMCPServerName)) { return true }
|
||||
switch newMCPServerTransportKind {
|
||||
case .stdio: return newMCPServerCommand.isEmpty
|
||||
case .http: return newMCPServerURL.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
private func mcpDictionary(from pairs: [MCPKeyValuePair]) -> [String: String] {
|
||||
Dictionary(uniqueKeysWithValues: pairs.filter { !$0.key.isEmpty }.map { ($0.key, $0.value) })
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func mcpKeyValueEditor(title: LocalizedStringKey, pairs: Binding<[MCPKeyValuePair]>) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button {
|
||||
pairs.wrappedValue.append(MCPKeyValuePair())
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
ForEach(pairs) { $pair in
|
||||
HStack(spacing: 6) {
|
||||
TextField("Key", text: $pair.key)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
SecureField("Value", text: $pair.value)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
Button {
|
||||
pairs.wrappedValue.removeAll { $0.id == pair.id }
|
||||
} label: {
|
||||
Image(systemName: "minus.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
if pairs.wrappedValue.isEmpty {
|
||||
Text("None configured")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func mcpStatusColor(_ state: MCPClientState?) -> Color {
|
||||
switch state {
|
||||
case .ready: return .green
|
||||
@@ -1500,7 +1805,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal, 4)
|
||||
} else {
|
||||
Text("⚠️ No embedding providers available. Configure an API key for OpenAI, OpenRouter, or Google in the General tab.")
|
||||
Text("⚠️ No embedding providers available. Configure an API key for OpenAI, OpenRouter, or Google (under General → Web Search) in the General tab.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.orange)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
@@ -38,16 +38,26 @@ struct StatsView: View {
|
||||
@State private var overallStats = UsageStats()
|
||||
@State private var modelStats: [ModelUsageStat] = []
|
||||
@State private var conversationStats: [ConversationUsageStat] = []
|
||||
@State private var showAnalytics = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
Picker("", selection: $selectedTab) {
|
||||
Text("Session").tag(StatsTab.session)
|
||||
Text("All-Time").tag(StatsTab.allTime)
|
||||
HStack(spacing: 8) {
|
||||
Picker("", selection: $selectedTab) {
|
||||
Text("Session").tag(StatsTab.session)
|
||||
Text("All-Time").tag(StatsTab.allTime)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
|
||||
Button {
|
||||
showAnalytics = true
|
||||
} label: {
|
||||
Image(systemName: "chart.bar.xaxis")
|
||||
}
|
||||
.help("View detailed usage analytics")
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 4)
|
||||
@@ -74,6 +84,9 @@ struct StatsView: View {
|
||||
.task {
|
||||
loadAllTimeStats()
|
||||
}
|
||||
.sheet(isPresented: $showAnalytics) {
|
||||
UsageAnalyticsView()
|
||||
}
|
||||
}
|
||||
|
||||
private var sessionList: some View {
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
//
|
||||
// UsageAnalyticsView.swift
|
||||
// Confab
|
||||
//
|
||||
// Usage analytics: tokens / questions / money, over time and by model
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
//
|
||||
// This file is part of Confab.
|
||||
//
|
||||
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||
// You may use, study, modify, and share it for any noncommercial
|
||||
// purpose. Commercial use — including selling Confab or any part of
|
||||
// it, standalone or bundled into another product or service —
|
||||
// requires a separate commercial license from the copyright holder.
|
||||
//
|
||||
// See the LICENSE file or
|
||||
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||
// the full license text. For commercial licensing, contact Rune
|
||||
// Olsen via <https://confab.no>.
|
||||
|
||||
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
private enum AnalyticsMetric: String, CaseIterable, Identifiable {
|
||||
case tokens
|
||||
case questions
|
||||
case cost
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .tokens: return "number"
|
||||
case .questions: return "bubble.left.and.bubble.right"
|
||||
case .cost: return "dollarsign.circle"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .tokens: return .confabAccent
|
||||
case .questions: return .confabSuccess
|
||||
case .cost: return .confabWarning
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum AnalyticsChartMode: String, CaseIterable, Identifiable {
|
||||
case overTime
|
||||
case byModel
|
||||
case byProvider
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
struct UsageAnalyticsView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
@State private var timeframe: AnalyticsTimeframe = .last7Days
|
||||
@State private var metric: AnalyticsMetric = .tokens
|
||||
@State private var chartMode: AnalyticsChartMode = .overTime
|
||||
|
||||
@State private var overallStats = UsageStats()
|
||||
@State private var modelStats: [ModelUsageStat] = []
|
||||
@State private var providerStats: [ProviderUsageStat] = []
|
||||
@State private var dailyStats: [DailyUsageStat] = []
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
timeframePicker
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
summaryTiles
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
chartModePicker
|
||||
chartSection
|
||||
}
|
||||
|
||||
if chartMode == .byModel && !modelStats.isEmpty {
|
||||
modelBreakdownList
|
||||
}
|
||||
|
||||
if chartMode == .byProvider && !providerStats.isEmpty {
|
||||
providerBreakdownList
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Analytics")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 700, idealWidth: 780, minHeight: 620, idealHeight: 680)
|
||||
}
|
||||
.task(id: timeframe) {
|
||||
loadStats()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timeframe
|
||||
|
||||
private var timeframePicker: some View {
|
||||
Picker("", selection: $timeframe) {
|
||||
Text("Today").tag(AnalyticsTimeframe.today)
|
||||
Text("7 Days").tag(AnalyticsTimeframe.last7Days)
|
||||
Text("Week").tag(AnalyticsTimeframe.week)
|
||||
Text("Month").tag(AnalyticsTimeframe.month)
|
||||
Text("Year").tag(AnalyticsTimeframe.year)
|
||||
Text("Total").tag(AnalyticsTimeframe.total)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
|
||||
// MARK: - Summary Tiles
|
||||
|
||||
private var summaryTiles: some View {
|
||||
HStack(spacing: 12) {
|
||||
MetricTile(
|
||||
title: "Tokens",
|
||||
value: overallStats.totalTokensDisplay,
|
||||
icon: AnalyticsMetric.tokens.icon,
|
||||
color: AnalyticsMetric.tokens.color,
|
||||
isSelected: metric == .tokens
|
||||
) { metric = .tokens }
|
||||
|
||||
MetricTile(
|
||||
title: "Questions",
|
||||
value: "\(overallStats.totalQuestions)",
|
||||
icon: AnalyticsMetric.questions.icon,
|
||||
color: AnalyticsMetric.questions.color,
|
||||
isSelected: metric == .questions
|
||||
) { metric = .questions }
|
||||
|
||||
MetricTile(
|
||||
title: "Money Used",
|
||||
value: overallStats.totalCostDisplay,
|
||||
icon: AnalyticsMetric.cost.icon,
|
||||
color: AnalyticsMetric.cost.color,
|
||||
isSelected: metric == .cost
|
||||
) { metric = .cost }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chart
|
||||
|
||||
private var chartModePicker: some View {
|
||||
Picker("", selection: $chartMode) {
|
||||
Text("Over Time").tag(AnalyticsChartMode.overTime)
|
||||
Text("By Model").tag(AnalyticsChartMode.byModel)
|
||||
Text("By Provider").tag(AnalyticsChartMode.byProvider)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 400)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var chartSection: some View {
|
||||
switch chartMode {
|
||||
case .overTime:
|
||||
timeSeriesChart
|
||||
case .byModel:
|
||||
byModelChart
|
||||
case .byProvider:
|
||||
byProviderChart
|
||||
}
|
||||
}
|
||||
|
||||
private var timeSeriesChart: some View {
|
||||
Group {
|
||||
if dailyStats.isEmpty {
|
||||
emptyChartPlaceholder
|
||||
} else {
|
||||
Chart(dailyStats) { stat in
|
||||
BarMark(
|
||||
x: .value("Day", stat.day, unit: .day),
|
||||
y: .value("Value", dailyValue(for: stat))
|
||||
)
|
||||
.foregroundStyle(metric.color)
|
||||
}
|
||||
.frame(height: 220)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var byModelChart: some View {
|
||||
Group {
|
||||
if modelStats.isEmpty {
|
||||
emptyChartPlaceholder
|
||||
} else {
|
||||
Chart(modelStats) { stat in
|
||||
SectorMark(
|
||||
angle: .value("Value", modelValue(for: stat)),
|
||||
innerRadius: .ratio(0.55),
|
||||
angularInset: 1.5
|
||||
)
|
||||
.foregroundStyle(by: .value("Model", stat.modelId))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
.frame(height: 240)
|
||||
.chartLegend(position: .bottom, alignment: .center, spacing: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var byProviderChart: some View {
|
||||
Group {
|
||||
if providerStats.isEmpty {
|
||||
emptyChartPlaceholder
|
||||
} else {
|
||||
Chart(providerStats) { stat in
|
||||
SectorMark(
|
||||
angle: .value("Value", providerValue(for: stat)),
|
||||
innerRadius: .ratio(0.55),
|
||||
angularInset: 1.5
|
||||
)
|
||||
.foregroundStyle(by: .value("Provider", providerDisplayName(stat.provider)))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
.frame(height: 240)
|
||||
.chartLegend(position: .bottom, alignment: .center, spacing: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyChartPlaceholder: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "chart.bar.xaxis")
|
||||
.font(.system(size: 32))
|
||||
.foregroundColor(.secondary)
|
||||
Text("No usage data for this timeframe")
|
||||
.font(.callout)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 220)
|
||||
}
|
||||
|
||||
// MARK: - By Model List
|
||||
|
||||
private var modelBreakdownList: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("By Model")
|
||||
.font(.headline)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
ForEach(modelStats) { stat in
|
||||
HStack {
|
||||
Text(stat.modelId)
|
||||
.font(.body)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text(modelValueDisplay(for: stat))
|
||||
.font(.body.monospacedDigit())
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
if stat.id != modelStats.last?.id {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.confabSurface.opacity(0.5))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
// MARK: - By Provider List
|
||||
|
||||
private var providerBreakdownList: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("By Provider")
|
||||
.font(.headline)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
ForEach(providerStats) { stat in
|
||||
HStack {
|
||||
Text(providerDisplayName(stat.provider))
|
||||
.font(.body)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text(providerValueDisplay(for: stat))
|
||||
.font(.body.monospacedDigit())
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
if stat.id != providerStats.last?.id {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.confabSurface.opacity(0.5))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
private func providerDisplayName(_ rawProvider: String) -> String {
|
||||
Settings.Provider(rawValue: rawProvider)?.displayName ?? rawProvider
|
||||
}
|
||||
|
||||
// MARK: - Value helpers
|
||||
|
||||
private func dailyValue(for stat: DailyUsageStat) -> Double {
|
||||
switch metric {
|
||||
case .tokens: return Double(stat.totalTokens)
|
||||
case .questions: return Double(stat.questionCount)
|
||||
case .cost: return stat.totalCost
|
||||
}
|
||||
}
|
||||
|
||||
private func modelValue(for stat: ModelUsageStat) -> Double {
|
||||
switch metric {
|
||||
case .tokens: return Double(stat.totalTokens)
|
||||
case .questions: return Double(stat.questionCount)
|
||||
case .cost: return stat.totalCost
|
||||
}
|
||||
}
|
||||
|
||||
private func modelValueDisplay(for stat: ModelUsageStat) -> String {
|
||||
switch metric {
|
||||
case .tokens: return stat.totalTokensDisplay
|
||||
case .questions: return "\(stat.questionCount)"
|
||||
case .cost: return stat.totalCostDisplay
|
||||
}
|
||||
}
|
||||
|
||||
private func providerValue(for stat: ProviderUsageStat) -> Double {
|
||||
switch metric {
|
||||
case .tokens: return Double(stat.totalTokens)
|
||||
case .questions: return Double(stat.questionCount)
|
||||
case .cost: return stat.totalCost
|
||||
}
|
||||
}
|
||||
|
||||
private func providerValueDisplay(for stat: ProviderUsageStat) -> String {
|
||||
switch metric {
|
||||
case .tokens: return stat.totalTokensDisplay
|
||||
case .questions: return "\(stat.questionCount)"
|
||||
case .cost: return stat.totalCostDisplay
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Loading
|
||||
|
||||
/// Sourced from `usage_events`, not `messages` — reflects usage for every completed
|
||||
/// generation regardless of whether the conversation it belongs to was ever saved.
|
||||
private func loadStats() {
|
||||
let range = timeframe.dateRange()
|
||||
overallStats = (try? DatabaseService.shared.getUsageEventTotals(from: range.start, to: range.end)) ?? UsageStats()
|
||||
modelStats = (try? DatabaseService.shared.getUsageEventsByModel(from: range.start, to: range.end)) ?? []
|
||||
providerStats = (try? DatabaseService.shared.getUsageEventsByProvider(from: range.start, to: range.end)) ?? []
|
||||
|
||||
let dailyFrom = range.start ?? overallStats.firstMessageDate ?? range.end
|
||||
dailyStats = (try? DatabaseService.shared.getDailyUsageEvents(from: dailyFrom, to: range.end)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Metric Tile
|
||||
|
||||
private struct MetricTile: View {
|
||||
let title: LocalizedStringKey
|
||||
let value: String
|
||||
let icon: String
|
||||
let color: Color
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(color)
|
||||
Spacer()
|
||||
}
|
||||
Text(value)
|
||||
.font(.title2.monospacedDigit())
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(isSelected ? color.opacity(0.15) : Color.confabSurface.opacity(0.5))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(isSelected ? color : Color.clear, lineWidth: 1.5)
|
||||
)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
UsageAnalyticsView()
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"strings" : {
|
||||
"CFBundleName" : {
|
||||
"comment" : "Bundle name",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"da" : {
|
||||
"stringUnit" : {
|
||||
@@ -36,54 +36,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSCalendarsFullAccessUsageDescription" : {
|
||||
"comment" : "Privacy - Calendars Full Access Usage Description",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Confab can read and create calendar events when you ask it to, if you enable Calendar access in Settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSContactsUsageDescription" : {
|
||||
"comment" : "Privacy - Contacts Usage Description",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Confab can search your contacts when you ask it to, if you enable Contacts access in Settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSLocationWhenInUseUsageDescription" : {
|
||||
"comment" : "Privacy - Location When In Use Usage Description",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Confab can use your current location to answer questions, if you enable Location & Maps access in Settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSRemindersFullAccessUsageDescription" : {
|
||||
"comment" : "Privacy - Reminders Full Access Usage Description",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Confab can read and create reminders when you ask it to, if you enable Reminders access in Settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.1"
|
||||
|
||||
@@ -73,6 +73,9 @@ struct oAIApp: App {
|
||||
// Start email handler on app launch
|
||||
EmailHandlerService.shared.start()
|
||||
|
||||
// Start the local CLI server (Settings > Advanced > CLI Access) — no-op if disabled
|
||||
CLIServerService.shared.start()
|
||||
|
||||
// Start external MCP servers
|
||||
Task { @MainActor in ExternalMCPManager.shared.startAll() }
|
||||
|
||||
@@ -88,6 +91,17 @@ struct oAIApp: App {
|
||||
|
||||
// Check for updates in the background
|
||||
UpdateCheckService.shared.checkForUpdates()
|
||||
|
||||
// Periodic heartbeat: on its own, a quiet Confab.log is ambiguous — it could mean the
|
||||
// user just wasn't chatting, or that the app silently froze (confirmed possible on
|
||||
// 2026-08-14: a stuck process left the CLI socket open per `lsof` yet refused every
|
||||
// connection, with the log not written to at all for the rest of that session — no
|
||||
// crash, no error, nothing to distinguish "idle" from "wedged" after the fact). A
|
||||
// regular, otherwise-meaningless log line turns that ambiguity into a plain read: if the
|
||||
// *next* freeze happens, the gap since the last heartbeat pins down roughly when.
|
||||
Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { _ in
|
||||
Log.general.info("heartbeat")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
@@ -106,6 +120,7 @@ struct oAIApp: App {
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
|
||||
Task { @MainActor in ExternalMCPManager.shared.stopAll() }
|
||||
CLIServerService.shared.stop()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//
|
||||
// CLIServerServiceTests.swift
|
||||
// oAITests
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import Confab
|
||||
|
||||
@Suite("CLIServerService pure HTTP helpers")
|
||||
struct CLIServerServiceTests {
|
||||
|
||||
// MARK: - parseRequestBody
|
||||
|
||||
@Test("Returns the body once headers and full Content-Length body have arrived")
|
||||
func parsesCompleteRequest() {
|
||||
let body = "{\"prompt\":\"hi\"}"
|
||||
let raw = "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)"
|
||||
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
|
||||
#expect(result.map { String(data: $0, encoding: .utf8) } == body)
|
||||
}
|
||||
|
||||
@Test("Returns nil when the header block hasn't fully arrived yet")
|
||||
func returnsNilForIncompleteHeaders() {
|
||||
let raw = "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5"
|
||||
#expect(CLIServerService.parseRequestBody(from: Data(raw.utf8)) == nil)
|
||||
}
|
||||
|
||||
@Test("Returns nil when the body hasn't fully arrived yet")
|
||||
func returnsNilForIncompleteBody() {
|
||||
let raw = "POST / HTTP/1.1\r\nContent-Length: 20\r\n\r\n{\"prompt\":\"hi\"}"
|
||||
#expect(CLIServerService.parseRequestBody(from: Data(raw.utf8)) == nil)
|
||||
}
|
||||
|
||||
@Test("Content-Length header name match is case-insensitive")
|
||||
func headerNameIsCaseInsensitive() {
|
||||
let body = "abc"
|
||||
let raw = "POST / HTTP/1.1\r\ncontent-length: 3\r\n\r\n\(body)"
|
||||
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
|
||||
#expect(result.map { String(data: $0, encoding: .utf8) } == body)
|
||||
}
|
||||
|
||||
@Test("Missing Content-Length is treated as a zero-length body")
|
||||
func missingContentLengthIsEmptyBody() {
|
||||
let raw = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"
|
||||
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
|
||||
#expect(result?.isEmpty == true)
|
||||
}
|
||||
|
||||
@Test("Extra trailing bytes beyond Content-Length don't prevent extraction (pipelined data ignored)")
|
||||
func extraTrailingBytesStillExtractsBody() {
|
||||
let body = "abc"
|
||||
let raw = "POST / HTTP/1.1\r\nContent-Length: 3\r\n\r\n\(body)EXTRA"
|
||||
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
|
||||
#expect(result.map { String(data: $0, encoding: .utf8) } == body)
|
||||
}
|
||||
|
||||
// MARK: - Response encoding
|
||||
|
||||
@Test("successResponse produces a 200 with the response field set")
|
||||
func successResponseShape() throws {
|
||||
let data = CLIServerService.successResponse("hello world")
|
||||
let text = String(data: data, encoding: .utf8)!
|
||||
#expect(text.hasPrefix("HTTP/1.1 200 OK\r\n"))
|
||||
#expect(text.contains("Content-Type: application/json"))
|
||||
|
||||
let bodyStart = text.range(of: "\r\n\r\n")!.upperBound
|
||||
let bodyJSON = Data(text[bodyStart...].utf8)
|
||||
let decoded = try JSONDecoder().decode(CLIServerService.AskResponseBody.self, from: bodyJSON)
|
||||
#expect(decoded.response == "hello world")
|
||||
#expect(decoded.error == nil)
|
||||
}
|
||||
|
||||
@Test("errorResponse defaults to 400 Bad Request")
|
||||
func errorResponseDefaultStatus() throws {
|
||||
let data = CLIServerService.errorResponse("bad input")
|
||||
let text = String(data: data, encoding: .utf8)!
|
||||
#expect(text.hasPrefix("HTTP/1.1 400 Bad Request\r\n"))
|
||||
|
||||
let bodyStart = text.range(of: "\r\n\r\n")!.upperBound
|
||||
let bodyJSON = Data(text[bodyStart...].utf8)
|
||||
let decoded = try JSONDecoder().decode(CLIServerService.AskResponseBody.self, from: bodyJSON)
|
||||
#expect(decoded.error == "bad input")
|
||||
#expect(decoded.response == nil)
|
||||
}
|
||||
|
||||
@Test("errorResponse supports a custom status code")
|
||||
func errorResponseCustomStatus() {
|
||||
let data = CLIServerService.errorResponse("provider failed", statusCode: 500)
|
||||
let text = String(data: data, encoding: .utf8)!
|
||||
#expect(text.hasPrefix("HTTP/1.1 500 Internal Server Error\r\n"))
|
||||
}
|
||||
|
||||
@Test("Content-Length in the response header matches the actual JSON body byte count")
|
||||
func responseContentLengthMatchesBody() {
|
||||
let data = CLIServerService.successResponse("hello")
|
||||
let text = String(data: data, encoding: .utf8)!
|
||||
let headerEnd = text.range(of: "\r\n\r\n")!.upperBound
|
||||
let bodyByteCount = Data(text[headerEnd...].utf8).count
|
||||
|
||||
let lengthLine = text.split(separator: "\r\n").first { $0.hasPrefix("Content-Length:") }!
|
||||
let declaredLength = Int(lengthLine.split(separator: " ")[1])!
|
||||
#expect(declaredLength == bodyByteCount)
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,36 @@ struct ChatViewModelPureLogicTests {
|
||||
#expect(ChatViewModel.calculateCost(usage: usage, pricing: pricing) == 0.0)
|
||||
}
|
||||
|
||||
// MARK: - resolveCost
|
||||
|
||||
@Test("Raw provider-billed cost wins over token-based pricing when present")
|
||||
func resolveCostPrefersRawCostUSD() {
|
||||
let usage = ChatResponse.Usage(promptTokens: 1_000_000, completionTokens: 1_000_000, totalTokens: 2_000_000, rawCostUSD: 0.19)
|
||||
let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0)
|
||||
#expect(ChatViewModel.resolveCost(usage: usage, pricing: pricing) == 0.19)
|
||||
}
|
||||
|
||||
@Test("Raw cost of zero is trusted, not treated as missing")
|
||||
func resolveCostTrustsZeroRawCost() {
|
||||
let usage = ChatResponse.Usage(promptTokens: 0, completionTokens: 0, totalTokens: 0, rawCostUSD: 0.0)
|
||||
let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0)
|
||||
#expect(ChatViewModel.resolveCost(usage: usage, pricing: pricing) == 0.0)
|
||||
}
|
||||
|
||||
@Test("Falls back to token-based pricing when no raw cost is reported")
|
||||
func resolveCostFallsBackToCalculation() {
|
||||
let usage = ChatResponse.Usage(promptTokens: 1_000_000, completionTokens: 1_000_000, totalTokens: 2_000_000)
|
||||
let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0)
|
||||
#expect(ChatViewModel.resolveCost(usage: usage, pricing: pricing) == 18.0)
|
||||
}
|
||||
|
||||
@Test("Returns nil when neither raw cost nor per-token pricing is available")
|
||||
func resolveCostNilWhenNoDataAvailable() {
|
||||
let usage = ChatResponse.Usage(promptTokens: 1_000_000, completionTokens: 1_000_000, totalTokens: 2_000_000)
|
||||
let pricing = ModelInfo.Pricing(prompt: 0, completion: 0)
|
||||
#expect(ChatViewModel.resolveCost(usage: usage, pricing: pricing) == nil)
|
||||
}
|
||||
|
||||
// MARK: - draftFingerprint
|
||||
|
||||
@Test("Identical message content produces the same fingerprint")
|
||||
|
||||
@@ -25,6 +25,17 @@ struct DatabaseServiceMigrationTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test("v13 adds the usage_events table with the expected columns")
|
||||
func v13AddsUsageEventsTable() {
|
||||
let db = DatabaseService.makeInMemory()
|
||||
#expect(db.tableExists("usage_events"))
|
||||
let columns = Set(db.columnNames(in: "usage_events"))
|
||||
let expected: Set<String> = [
|
||||
"id", "timestamp", "provider", "modelId", "promptTokens", "completionTokens", "cost", "conversationId",
|
||||
]
|
||||
#expect(expected.isSubset(of: columns))
|
||||
}
|
||||
|
||||
@Test("v4 adds modelId to messages and primaryModel to conversations")
|
||||
func v4AddsModelColumns() {
|
||||
let db = DatabaseService.makeInMemory()
|
||||
@@ -192,6 +203,84 @@ struct DatabaseServiceUsageStatsTests {
|
||||
#expect(byModel.first?.modelId == "claude-sonnet")
|
||||
}
|
||||
|
||||
@Test("Overall stats respect an optional date range and count user questions")
|
||||
func overallStatsRespectsDateRangeAndQuestions() throws {
|
||||
let db = DatabaseService.makeInMemory()
|
||||
let now = Date()
|
||||
let old = now.addingTimeInterval(-10 * 24 * 3600)
|
||||
|
||||
_ = try db.saveConversation(name: "Old Chat", messages: [
|
||||
Message(role: .user, content: "old question", tokens: 10, timestamp: old),
|
||||
Message(role: .assistant, content: "old answer", tokens: 20, cost: 0.01, timestamp: old),
|
||||
])
|
||||
_ = try db.saveConversation(name: "Recent Chat", messages: [
|
||||
Message(role: .user, content: "recent question", tokens: 5, timestamp: now),
|
||||
Message(role: .assistant, content: "recent answer", tokens: 15, cost: 0.02, timestamp: now),
|
||||
])
|
||||
|
||||
let rangeStats = try db.getOverallUsageStats(
|
||||
from: now.addingTimeInterval(-3600), to: now.addingTimeInterval(3600)
|
||||
)
|
||||
#expect(rangeStats.totalMessages == 2)
|
||||
#expect(rangeStats.totalQuestions == 1)
|
||||
#expect(rangeStats.totalTokens == 20)
|
||||
|
||||
let allStats = try db.getOverallUsageStats()
|
||||
#expect(allStats.totalMessages == 4)
|
||||
#expect(allStats.totalQuestions == 2)
|
||||
}
|
||||
|
||||
@Test("Usage by model respects an optional date range and reports question counts")
|
||||
func usageByModelRespectsDateRangeAndQuestions() throws {
|
||||
let db = DatabaseService.makeInMemory()
|
||||
let now = Date()
|
||||
let old = now.addingTimeInterval(-10 * 24 * 3600)
|
||||
|
||||
_ = try db.saveConversation(name: "Old Chat", messages: [
|
||||
Message(role: .user, content: "hi", tokens: 5, timestamp: old, modelId: "claude-sonnet"),
|
||||
Message(role: .assistant, content: "hello", tokens: 10, cost: 0.01, timestamp: old, modelId: "claude-sonnet"),
|
||||
])
|
||||
_ = try db.saveConversation(name: "Recent Chat", messages: [
|
||||
Message(role: .user, content: "hey", tokens: 5, timestamp: now, modelId: "gpt-4"),
|
||||
Message(role: .assistant, content: "hi", tokens: 15, cost: 0.02, timestamp: now, modelId: "gpt-4"),
|
||||
])
|
||||
|
||||
let recent = try db.getUsageByModel(
|
||||
from: now.addingTimeInterval(-3600), to: now.addingTimeInterval(3600)
|
||||
)
|
||||
#expect(recent.count == 1)
|
||||
#expect(recent.first?.modelId == "gpt-4")
|
||||
#expect(recent.first?.questionCount == 1)
|
||||
}
|
||||
|
||||
@Test("Daily usage buckets messages by calendar day (GMT, matching stored timestamps)")
|
||||
func dailyUsageBucketsByDay() throws {
|
||||
let db = DatabaseService.makeInMemory()
|
||||
var gmtCalendar = Calendar(identifier: .gregorian)
|
||||
gmtCalendar.timeZone = TimeZone(identifier: "GMT")!
|
||||
|
||||
let day1 = gmtCalendar.date(from: DateComponents(year: 2026, month: 8, day: 1, hour: 10))!
|
||||
let day2 = gmtCalendar.date(from: DateComponents(year: 2026, month: 8, day: 2, hour: 10))!
|
||||
|
||||
_ = try db.saveConversation(name: "Chat", messages: [
|
||||
Message(role: .user, content: "q1", tokens: 5, timestamp: day1),
|
||||
Message(role: .assistant, content: "a1", tokens: 10, cost: 0.01, timestamp: day1),
|
||||
Message(role: .user, content: "q2", tokens: 5, timestamp: day2),
|
||||
Message(role: .assistant, content: "a2", tokens: 20, cost: 0.02, timestamp: day2),
|
||||
])
|
||||
|
||||
let daily = try db.getDailyUsage(
|
||||
from: gmtCalendar.date(from: DateComponents(year: 2026, month: 8, day: 1))!,
|
||||
to: gmtCalendar.date(from: DateComponents(year: 2026, month: 8, day: 3))!
|
||||
)
|
||||
#expect(daily.count == 2)
|
||||
#expect(daily[0].totalTokens == 15)
|
||||
#expect(daily[0].questionCount == 1)
|
||||
#expect(daily[0].hasCostData == true)
|
||||
#expect(daily[1].totalTokens == 25)
|
||||
#expect(daily[1].questionCount == 1)
|
||||
}
|
||||
|
||||
@Test("Usage by conversation joins conversation names and sorts by cost descending")
|
||||
func usageByConversationSortsByCost() throws {
|
||||
let db = DatabaseService.makeInMemory()
|
||||
@@ -221,6 +310,80 @@ struct DatabaseServiceUsageStatsTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("DatabaseService usage events, against a throwaway in-memory queue")
|
||||
struct DatabaseServiceUsageEventsTests {
|
||||
|
||||
@Test("Logged usage events are readable regardless of any saved conversation")
|
||||
func logUsageEventPersistsWithNoConversation() throws {
|
||||
let db = DatabaseService.makeInMemory()
|
||||
try db.logUsageEvent(
|
||||
provider: "openrouter", modelId: "gpt-5", promptTokens: 100, completionTokens: 50,
|
||||
cost: 0.02, conversationId: nil
|
||||
)
|
||||
|
||||
let totals = try db.getUsageEventTotals()
|
||||
#expect(totals.totalQuestions == 1)
|
||||
#expect(totals.totalTokens == 150)
|
||||
#expect(totals.hasCostData == true)
|
||||
#expect(abs(totals.totalCost - 0.02) < 0.0001)
|
||||
}
|
||||
|
||||
@Test("Usage events respect an optional date range")
|
||||
func usageEventTotalsRespectsDateRange() throws {
|
||||
let db = DatabaseService.makeInMemory()
|
||||
let now = Date()
|
||||
let old = now.addingTimeInterval(-10 * 24 * 3600)
|
||||
|
||||
try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil)
|
||||
// Directly insert an old-dated row via the private record path isn't exposed, so approximate
|
||||
// "old" coverage by asserting the fresh row is excluded when the range is set entirely in the past.
|
||||
let rangeExcludingNow = try db.getUsageEventTotals(from: old, to: old.addingTimeInterval(3600))
|
||||
#expect(rangeExcludingNow.totalQuestions == 0)
|
||||
|
||||
let rangeIncludingNow = try db.getUsageEventTotals(from: now.addingTimeInterval(-3600), to: now.addingTimeInterval(3600))
|
||||
#expect(rangeIncludingNow.totalQuestions == 1)
|
||||
}
|
||||
|
||||
@Test("Usage events group by model and by provider independently")
|
||||
func usageEventsGroupByModelAndProvider() throws {
|
||||
let db = DatabaseService.makeInMemory()
|
||||
try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil)
|
||||
try db.logUsageEvent(provider: "openrouter", modelId: "claude-sonnet", promptTokens: 5, completionTokens: 5, cost: 0.02, conversationId: nil)
|
||||
try db.logUsageEvent(provider: "anthropic", modelId: "claude-sonnet", promptTokens: 20, completionTokens: 20, cost: 0.03, conversationId: nil)
|
||||
|
||||
let byModel = try db.getUsageEventsByModel()
|
||||
#expect(byModel.count == 2)
|
||||
let sonnet = byModel.first { $0.modelId == "claude-sonnet" }
|
||||
#expect(sonnet?.questionCount == 2)
|
||||
#expect(sonnet?.totalTokens == 50)
|
||||
|
||||
let byProvider = try db.getUsageEventsByProvider()
|
||||
#expect(byProvider.count == 2)
|
||||
let openrouter = byProvider.first { $0.provider == "openrouter" }
|
||||
#expect(openrouter?.questionCount == 2)
|
||||
#expect(openrouter?.totalTokens == 30)
|
||||
let anthropic = byProvider.first { $0.provider == "anthropic" }
|
||||
#expect(anthropic?.questionCount == 1)
|
||||
#expect(anthropic?.totalTokens == 40)
|
||||
}
|
||||
|
||||
@Test("Daily usage events bucket by calendar day (GMT, matching stored timestamps)")
|
||||
func dailyUsageEventsBucketByDay() throws {
|
||||
let db = DatabaseService.makeInMemory()
|
||||
try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil)
|
||||
|
||||
var gmtCalendar = Calendar(identifier: .gregorian)
|
||||
gmtCalendar.timeZone = TimeZone(identifier: "GMT")!
|
||||
let farPast = gmtCalendar.date(from: DateComponents(year: 2020, month: 1, day: 1))!
|
||||
let farFuture = gmtCalendar.date(from: DateComponents(year: 2030, month: 1, day: 1))!
|
||||
|
||||
let daily = try db.getDailyUsageEvents(from: farPast, to: farFuture)
|
||||
#expect(daily.count == 1)
|
||||
#expect(daily.first?.totalTokens == 20)
|
||||
#expect(daily.first?.questionCount == 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("DatabaseService folders, against a throwaway in-memory queue")
|
||||
struct DatabaseServiceFolderTests {
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// ExternalMCPManagerConversionTests.swift
|
||||
// ConfabTests
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import Confab
|
||||
|
||||
@Suite("ExternalMCPManager.convertInputSchema")
|
||||
struct ExternalMCPManagerConversionTests {
|
||||
|
||||
/// Regression test for a real crash: Obsidian's Local REST API plugin's MCP tool schemas
|
||||
/// include at least one property with no "type" key at all — valid JSON Schema (e.g. an
|
||||
/// enum-only or composed property) — which a `prop.type!` force-unwrap used to crash on the
|
||||
/// moment a live HTTP MCP server's tools/list response reached this code.
|
||||
@Test("A property with no type at all defaults to string instead of crashing")
|
||||
func propertyWithNoTypeDefaultsToString() {
|
||||
let prop = MCPPropertySchema(type: nil, description: "no explicit type", enum: nil, items: nil)
|
||||
let schema = MCPInputSchema(type: "object", properties: ["mode": prop], required: nil)
|
||||
|
||||
let parameters = ExternalMCPManager.convertInputSchema(schema)
|
||||
|
||||
#expect(parameters.properties["mode"]?.type == "string")
|
||||
#expect(parameters.properties["mode"]?.description == "no explicit type")
|
||||
}
|
||||
|
||||
@Test("integer is normalized to number")
|
||||
func integerNormalizesToNumber() {
|
||||
let prop = MCPPropertySchema(type: "integer", description: nil, enum: nil, items: nil)
|
||||
let schema = MCPInputSchema(type: "object", properties: ["count": prop], required: nil)
|
||||
|
||||
let parameters = ExternalMCPManager.convertInputSchema(schema)
|
||||
|
||||
#expect(parameters.properties["count"]?.type == "number")
|
||||
}
|
||||
|
||||
@Test("An unrecognized type string falls back to string")
|
||||
func unrecognizedTypeFallsBackToString() {
|
||||
let prop = MCPPropertySchema(type: "something-unusual", description: nil, enum: nil, items: nil)
|
||||
let schema = MCPInputSchema(type: "object", properties: ["weird": prop], required: nil)
|
||||
|
||||
let parameters = ExternalMCPManager.convertInputSchema(schema)
|
||||
|
||||
#expect(parameters.properties["weird"]?.type == "string")
|
||||
}
|
||||
|
||||
@Test("Recognized types (string/number/boolean/array/object) pass through unchanged")
|
||||
func recognizedTypesPassThrough() {
|
||||
let types = ["string", "number", "boolean", "array", "object"]
|
||||
var properties: [String: MCPPropertySchema] = [:]
|
||||
for t in types {
|
||||
properties[t] = MCPPropertySchema(type: t, description: nil, enum: nil, items: nil)
|
||||
}
|
||||
let schema = MCPInputSchema(type: "object", properties: properties, required: nil)
|
||||
|
||||
let parameters = ExternalMCPManager.convertInputSchema(schema)
|
||||
|
||||
for t in types {
|
||||
#expect(parameters.properties[t]?.type == t)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//
|
||||
// ExternalMCPModelsTests.swift
|
||||
// ConfabTests
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import Confab
|
||||
|
||||
@Suite("ExternalMCPServer Codable")
|
||||
struct ExternalMCPServerCodableTests {
|
||||
|
||||
private static func makeCoders() -> (JSONEncoder, JSONDecoder) {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return (encoder, decoder)
|
||||
}
|
||||
|
||||
@Test("Decodes old-shape JSON (saved before env/HTTP fields existed) with safe defaults")
|
||||
func decodesOldShapeJSON() throws {
|
||||
// Exactly the 7 keys ExternalMCPServer had before transportKind/env/url/bearerToken/headers
|
||||
// were added — this is what's actually sitting in existing users' settings DB right now.
|
||||
let oldJSON = """
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"name": "Safari",
|
||||
"command": "safaridriver",
|
||||
"args": ["--mcp"],
|
||||
"isEnabled": true,
|
||||
"timeout": 30,
|
||||
"createdAt": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
"""
|
||||
let (_, decoder) = Self.makeCoders()
|
||||
let server = try decoder.decode(ExternalMCPServer.self, from: Data(oldJSON.utf8))
|
||||
|
||||
#expect(server.name == "Safari")
|
||||
#expect(server.command == "safaridriver")
|
||||
#expect(server.args == ["--mcp"])
|
||||
#expect(server.transportKind == .stdio)
|
||||
#expect(server.env.isEmpty)
|
||||
#expect(server.url.isEmpty)
|
||||
#expect(server.bearerToken.isEmpty)
|
||||
#expect(server.headers.isEmpty)
|
||||
}
|
||||
|
||||
@Test("An array of old-shape servers (the real settings-JSON shape) decodes without dropping any")
|
||||
func decodesOldShapeArray() throws {
|
||||
let oldJSON = """
|
||||
[
|
||||
{"id": "00000000-0000-0000-0000-000000000001", "name": "Safari", "command": "safaridriver",
|
||||
"args": ["--mcp"], "isEnabled": true, "timeout": 30, "createdAt": "2026-01-01T00:00:00Z"},
|
||||
{"id": "00000000-0000-0000-0000-000000000002", "name": "Other", "command": "some-tool",
|
||||
"args": [], "isEnabled": false, "timeout": 15, "createdAt": "2026-02-01T00:00:00Z"}
|
||||
]
|
||||
"""
|
||||
let (_, decoder) = Self.makeCoders()
|
||||
let servers = try decoder.decode([ExternalMCPServer].self, from: Data(oldJSON.utf8))
|
||||
#expect(servers.count == 2)
|
||||
}
|
||||
|
||||
@Test("Round-trips a full HTTP-transport server through encode/decode")
|
||||
func roundTripsHTTPServer() throws {
|
||||
let original = ExternalMCPServer(
|
||||
name: "Obsidian",
|
||||
transportKind: .http,
|
||||
url: "http://127.0.0.1:27123/mcp/",
|
||||
bearerToken: "secret-token",
|
||||
headers: ["X-Extra": "value"],
|
||||
timeout: 45
|
||||
)
|
||||
let (encoder, decoder) = Self.makeCoders()
|
||||
let data = try encoder.encode(original)
|
||||
let decoded = try decoder.decode(ExternalMCPServer.self, from: data)
|
||||
|
||||
#expect(decoded.id == original.id)
|
||||
#expect(decoded.transportKind == .http)
|
||||
#expect(decoded.url == "http://127.0.0.1:27123/mcp/")
|
||||
#expect(decoded.bearerToken == "secret-token")
|
||||
#expect(decoded.headers == ["X-Extra": "value"])
|
||||
}
|
||||
|
||||
@Test("Round-trips a stdio-transport server with env vars")
|
||||
func roundTripsStdioServerWithEnv() throws {
|
||||
let original = ExternalMCPServer(
|
||||
name: "Custom",
|
||||
command: "npx",
|
||||
args: ["-y", "some-tool"],
|
||||
env: ["API_KEY": "abc123"],
|
||||
timeout: 30
|
||||
)
|
||||
let (encoder, decoder) = Self.makeCoders()
|
||||
let data = try encoder.encode(original)
|
||||
let decoded = try decoder.decode(ExternalMCPServer.self, from: data)
|
||||
|
||||
#expect(decoded.transportKind == .stdio)
|
||||
#expect(decoded.env == ["API_KEY": "abc123"])
|
||||
#expect(decoded.command == "npx")
|
||||
#expect(decoded.args == ["-y", "some-tool"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// JarvisModelsTests.swift
|
||||
// oAITests
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import Confab
|
||||
|
||||
@Suite("JarvisAgentRun decoding")
|
||||
struct JarvisModelsTests {
|
||||
|
||||
// Real shape returned by the oAI-Web /api/agents/{id}/runs endpoint — confirmed by Rune
|
||||
// against a live run. Guards against the "result"/"output" and "ended_at"/"finished_at"
|
||||
// field-name mismatch that shipped in the initial Run Details modal (output always showed
|
||||
// "No output for this run" even when the API had real content).
|
||||
private static let sampleJSON = """
|
||||
{
|
||||
"id": "8628fa26-8ff1-4053-b106-8ba84a0e10e0",
|
||||
"agent_id": "531c884b-a401-4985-8717-bfa6bcc1d148",
|
||||
"started_at": "2026-08-07T10:00:00.135999+00:00",
|
||||
"ended_at": "2026-08-07T10:00:13.815956+00:00",
|
||||
"status": "success",
|
||||
"input_tokens": 25615,
|
||||
"output_tokens": 1233,
|
||||
"cost_usd": 0.031780000776052475,
|
||||
"result": "Infrastructure Status: HEALTHY",
|
||||
"error": null,
|
||||
"model": "anthropic:claude-haiku-4-5-20251001"
|
||||
}
|
||||
"""
|
||||
|
||||
@Test("Decodes the API's 'result' field into .output")
|
||||
func decodesResultIntoOutput() throws {
|
||||
let run = try JSONDecoder().decode(JarvisAgentRun.self, from: Data(Self.sampleJSON.utf8))
|
||||
#expect(run.output == "Infrastructure Status: HEALTHY")
|
||||
}
|
||||
|
||||
@Test("Decodes the API's 'ended_at' field into .finishedAt")
|
||||
func decodesEndedAtIntoFinishedAt() throws {
|
||||
let run = try JSONDecoder().decode(JarvisAgentRun.self, from: Data(Self.sampleJSON.utf8))
|
||||
#expect(run.finishedAt == "2026-08-07T10:00:13.815956+00:00")
|
||||
}
|
||||
|
||||
@Test("Decodes a full real run without losing any field")
|
||||
func decodesAllFields() throws {
|
||||
let run = try JSONDecoder().decode(JarvisAgentRun.self, from: Data(Self.sampleJSON.utf8))
|
||||
#expect(run.id == "8628fa26-8ff1-4053-b106-8ba84a0e10e0")
|
||||
#expect(run.agentId == "531c884b-a401-4985-8717-bfa6bcc1d148")
|
||||
#expect(run.status == "success")
|
||||
#expect(run.startedAt == "2026-08-07T10:00:00.135999+00:00")
|
||||
#expect(run.inputTokens == 25615)
|
||||
#expect(run.outputTokens == 1233)
|
||||
#expect(run.totalTokens == 26848)
|
||||
#expect(run.costUsd == 0.031780000776052475)
|
||||
#expect(run.error == nil)
|
||||
}
|
||||
|
||||
@Test("A finished run with both started_at and ended_at produces a non-nil duration")
|
||||
func computesDurationFromRealFieldNames() throws {
|
||||
let run = try JSONDecoder().decode(JarvisAgentRun.self, from: Data(Self.sampleJSON.utf8))
|
||||
#expect(run.formattedDuration != nil)
|
||||
#expect(run.formattedDuration == "13s")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//
|
||||
// MCPTransportTests.swift
|
||||
// ConfabTests
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import Confab
|
||||
|
||||
@Suite("MCPTransportSupport.extractResult")
|
||||
struct MCPTransportSupportTests {
|
||||
|
||||
@Test("Extracts the result payload from a successful JSON-RPC response")
|
||||
func extractsResult() throws {
|
||||
let json: [String: Any] = ["jsonrpc": "2.0", "id": 1, "result": ["tools": []]]
|
||||
let data = try MCPTransportSupport.extractResult(from: json)
|
||||
let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
#expect(decoded?["tools"] != nil)
|
||||
}
|
||||
|
||||
@Test("Throws with the server's message when the response is a JSON-RPC error")
|
||||
func throwsOnJSONRPCError() {
|
||||
let json: [String: Any] = ["jsonrpc": "2.0", "id": 1, "error": ["code": -32601, "message": "Method not found"]]
|
||||
do {
|
||||
_ = try MCPTransportSupport.extractResult(from: json)
|
||||
Issue.record("Expected extractResult to throw")
|
||||
} catch let error as MCPClientError {
|
||||
switch error {
|
||||
case .invalidResponse(let message): #expect(message == "Method not found")
|
||||
default: Issue.record("Expected .invalidResponse, got \(error)")
|
||||
}
|
||||
} catch {
|
||||
Issue.record("Expected MCPClientError, got \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Throws when the response has neither result nor error")
|
||||
func throwsOnMissingResult() {
|
||||
let json: [String: Any] = ["jsonrpc": "2.0", "id": 1]
|
||||
do {
|
||||
_ = try MCPTransportSupport.extractResult(from: json)
|
||||
Issue.record("Expected extractResult to throw")
|
||||
} catch let error as MCPClientError {
|
||||
switch error {
|
||||
case .invalidResponse(let message): #expect(message == "Missing result field")
|
||||
default: Issue.record("Expected .invalidResponse, got \(error)")
|
||||
}
|
||||
} catch {
|
||||
Issue.record("Expected MCPClientError, got \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("HTTPMCPTransport.parseResponseBody")
|
||||
struct HTTPMCPTransportParseTests {
|
||||
|
||||
@Test("Parses a plain application/json response body directly")
|
||||
func parsesPlainJSON() throws {
|
||||
let body = Data(#"{"jsonrpc":"2.0","id":7,"result":{"ok":true}}"#.utf8)
|
||||
let json = try HTTPMCPTransport.parseResponseBody(body, contentType: "application/json", expectedId: 7)
|
||||
#expect(json["id"] as? Int == 7)
|
||||
}
|
||||
|
||||
@Test("Parses a text/event-stream body, finding the data: line matching the expected id")
|
||||
func parsesSSEMatchingId() throws {
|
||||
let sse = """
|
||||
event: message
|
||||
data: {"jsonrpc":"2.0","id":7,"result":{"ok":true}}
|
||||
|
||||
"""
|
||||
let json = try HTTPMCPTransport.parseResponseBody(
|
||||
Data(sse.utf8), contentType: "text/event-stream", expectedId: 7
|
||||
)
|
||||
#expect(json["id"] as? Int == 7)
|
||||
}
|
||||
|
||||
@Test("Skips unrelated server-sent messages before the matching response, per the Streamable HTTP spec")
|
||||
func skipsUnrelatedMessagesInSSE() throws {
|
||||
// Spec: "The server MAY send JSON-RPC requests and notifications before sending the
|
||||
// JSON-RPC response." Simulated here as an unrelated id=99 message before the real id=7 one.
|
||||
let sse = """
|
||||
data: {"jsonrpc":"2.0","id":99,"method":"unrelated/notification"}
|
||||
|
||||
data: {"jsonrpc":"2.0","id":7,"result":{"ok":true}}
|
||||
|
||||
"""
|
||||
let json = try HTTPMCPTransport.parseResponseBody(
|
||||
Data(sse.utf8), contentType: "text/event-stream", expectedId: 7
|
||||
)
|
||||
#expect(json["id"] as? Int == 7)
|
||||
}
|
||||
|
||||
@Test("Throws when no SSE data: line matches the expected id")
|
||||
func throwsWhenNoMatchInSSE() {
|
||||
let sse = """
|
||||
data: {"jsonrpc":"2.0","id":99,"result":{}}
|
||||
|
||||
"""
|
||||
do {
|
||||
_ = try HTTPMCPTransport.parseResponseBody(
|
||||
Data(sse.utf8), contentType: "text/event-stream", expectedId: 7
|
||||
)
|
||||
Issue.record("Expected parseResponseBody to throw")
|
||||
} catch let error as MCPClientError {
|
||||
switch error {
|
||||
case .invalidResponse: break
|
||||
default: Issue.record("Expected .invalidResponse, got \(error)")
|
||||
}
|
||||
} catch {
|
||||
Issue.record("Expected MCPClientError, got \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Throws on malformed JSON in a plain application/json body")
|
||||
func throwsOnMalformedJSON() {
|
||||
let body = Data("not json".utf8)
|
||||
do {
|
||||
_ = try HTTPMCPTransport.parseResponseBody(body, contentType: "application/json", expectedId: 1)
|
||||
Issue.record("Expected parseResponseBody to throw")
|
||||
} catch let error as MCPClientError {
|
||||
switch error {
|
||||
case .invalidResponse: break
|
||||
default: Issue.record("Expected .invalidResponse, got \(error)")
|
||||
}
|
||||
} catch {
|
||||
Issue.record("Expected MCPClientError, got \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// ThinkingVerbsTests.swift
|
||||
// oAITests
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import Confab
|
||||
|
||||
@Suite("ThinkingVerbs language selection")
|
||||
struct ThinkingVerbsTests {
|
||||
|
||||
private let supportedCodes = ["en", "nb", "sv", "da", "de", "fr"]
|
||||
|
||||
@Test("Every supported language has its own non-empty verb list")
|
||||
func perLanguageListsAreNonEmpty() {
|
||||
for code in supportedCodes {
|
||||
#expect(!ThinkingVerbs.verbs(for: code).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Unknown language codes fall back to English")
|
||||
func unknownCodeFallsBackToEnglish() {
|
||||
#expect(ThinkingVerbs.verbs(for: "xx") == ThinkingVerbs.verbs(for: "en"))
|
||||
#expect(ThinkingVerbs.verbs(for: "") == ThinkingVerbs.verbs(for: "en"))
|
||||
}
|
||||
|
||||
@Test("Each supported language's list is genuinely distinct, not a fallback copy of English")
|
||||
func perLanguageListsAreDistinctFromEnglish() {
|
||||
let english = ThinkingVerbs.verbs(for: "en")
|
||||
for code in supportedCodes where code != "en" {
|
||||
#expect(ThinkingVerbs.verbs(for: code) != english)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Language lists are comparable in size — none is a token stub")
|
||||
func perLanguageListsAreComparablyMore() {
|
||||
let minimumCount = 40
|
||||
for code in supportedCodes {
|
||||
#expect(ThinkingVerbs.verbs(for: code).count >= minimumCount)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("No duplicate entries within a single language's list")
|
||||
func noDuplicatesWithinLanguage() {
|
||||
for code in supportedCodes {
|
||||
let list = ThinkingVerbs.verbs(for: code)
|
||||
#expect(Set(list).count == list.count)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("random() returns a non-empty string ending in an ellipsis")
|
||||
func randomProducesEllipsisSuffixedString() {
|
||||
let result = ThinkingVerbs.random()
|
||||
#expect(!result.isEmpty)
|
||||
#expect(result.hasSuffix("..."))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user