Initial commit

This commit is contained in:
2026-02-11 22:22:55 +01:00
commit 42f54954c1
58 changed files with 10639 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
{
"images" : [
{
"filename" : "icon_1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"filename" : "icon_16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "icon_32.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "icon_32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "icon_64.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "icon_128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "icon_256.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "icon_256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "icon_512.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "icon_512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "icon_1024.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 KiB

View File

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "AppLogo.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,38 @@
//
// Conversation.swift
// oAI
//
// Model for saved conversations
//
import Foundation
struct Conversation: Identifiable, Codable {
let id: UUID
var name: String
var messages: [Message]
let createdAt: Date
var updatedAt: Date
init(
id: UUID = UUID(),
name: String,
messages: [Message] = [],
createdAt: Date = Date(),
updatedAt: Date = Date()
) {
self.id = id
self.name = name
self.messages = messages
self.createdAt = createdAt
self.updatedAt = updatedAt
}
var messageCount: Int {
messages.count
}
var lastMessageDate: Date {
messages.last?.timestamp ?? updatedAt
}
}

125
oAI/Models/Message.swift Normal file
View File

@@ -0,0 +1,125 @@
//
// Message.swift
// oAI
//
// Core message model for chat conversations
//
import Foundation
enum MessageRole: String, Codable {
case user
case assistant
case system
}
struct Message: Identifiable, Codable, Equatable {
let id: UUID
let role: MessageRole
var content: String
var tokens: Int?
var cost: Double?
let timestamp: Date
let attachments: [FileAttachment]?
// Streaming state (not persisted)
var isStreaming: Bool = false
// Generated images from image-output models (base64-decoded PNG/JPEG data)
var generatedImages: [Data]? = nil
init(
id: UUID = UUID(),
role: MessageRole,
content: String,
tokens: Int? = nil,
cost: Double? = nil,
timestamp: Date = Date(),
attachments: [FileAttachment]? = nil,
isStreaming: Bool = false,
generatedImages: [Data]? = nil
) {
self.id = id
self.role = role
self.content = content
self.tokens = tokens
self.cost = cost
self.timestamp = timestamp
self.attachments = attachments
self.isStreaming = isStreaming
self.generatedImages = generatedImages
}
enum CodingKeys: String, CodingKey {
case id, role, content, tokens, cost, timestamp, attachments
}
static func == (lhs: Message, rhs: Message) -> Bool {
lhs.id == rhs.id &&
lhs.content == rhs.content &&
lhs.tokens == rhs.tokens &&
lhs.cost == rhs.cost &&
lhs.isStreaming == rhs.isStreaming &&
lhs.generatedImages == rhs.generatedImages
}
}
struct FileAttachment: Codable, Equatable {
let path: String
let type: AttachmentType
let data: Data? // file contents: raw bytes for images/PDFs, UTF-8 for text
enum AttachmentType: String, Codable {
case image
case pdf
case text
}
/// Detect attachment type from file extension
static func typeFromExtension(_ path: String) -> AttachmentType {
let ext = (path as NSString).pathExtension.lowercased()
switch ext {
case "png", "jpg", "jpeg", "gif", "webp", "bmp", "svg":
return .image
case "pdf":
return .pdf
default:
return .text
}
}
/// MIME type string for the file (used in base64 data URLs)
var mimeType: String {
let ext = (path as NSString).pathExtension.lowercased()
switch ext {
case "png": return "image/png"
case "jpg", "jpeg": return "image/jpeg"
case "gif": return "image/gif"
case "webp": return "image/webp"
case "bmp": return "image/bmp"
case "svg": return "image/svg+xml"
case "pdf": return "application/pdf"
default: return "text/plain"
}
}
}
// MARK: - Display Helpers
extension MessageRole {
var displayName: String {
switch self {
case .user: return "You"
case .assistant: return "Assistant"
case .system: return "System"
}
}
var iconName: String {
switch self {
case .user: return "person.circle.fill"
case .assistant: return "cpu"
case .system: return "info.circle.fill"
}
}
}

240
oAI/Models/MockData.swift Normal file
View File

@@ -0,0 +1,240 @@
//
// MockData.swift
// oAI
//
// Mock data for Phase 1 testing
//
import Foundation
extension Message {
static let mockUser1 = Message(
role: .user,
content: "What is SwiftUI and how does it compare to UIKit?",
tokens: 12,
cost: 0.00002
)
static let mockAssistant1 = Message(
role: .assistant,
content: """
SwiftUI is Apple's modern declarative framework for building user interfaces across all Apple platforms. Here's how it compares to UIKit:
## Key Differences
**SwiftUI (Declarative)**
- Describe WHAT the UI should look like
- State-driven updates
- Less boilerplate code
- Cross-platform by default
**UIKit (Imperative)**
- Describe HOW to build the UI step-by-step
- Manual state management
- More verbose
- iOS/tvOS focused
## Example
```swift
// SwiftUI
struct ContentView: View {
@State private var count = 0
var body: some View {
VStack {
Text("Count: \\(count)")
Button("Increment") {
count += 1
}
}
}
}
```
```swift
// UIKit equivalent
class ViewController: UIViewController {
var count = 0
let label = UILabel()
let button = UIButton()
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}
@objc func incrementTapped() {
count += 1
label.text = "Count: \\(count)"
}
func setupUI() {
// ... lots more setup code
}
}
```
SwiftUI is recommended for new projects, especially if targeting iOS 14+ and multiple platforms.
""",
tokens: 245,
cost: 0.00049
)
static let mockUser2 = Message(
role: .user,
content: "Can you explain async/await in Swift?",
tokens: 8,
cost: 0.00001
)
static let mockAssistant2 = Message(
role: .assistant,
content: """
Async/await is Swift's modern concurrency model introduced in Swift 5.5. It makes asynchronous code look and behave like synchronous code.
## Basic Example
```swift
// Old way with completion handlers
func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(error))
} else if let data = data {
completion(.success(data))
}
}.resume()
}
// New way with async/await
func fetchData() async throws -> Data {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
```
## Key Benefits
1. **Readability**: No callback pyramids
2. **Error handling**: Standard try/catch
3. **Sequential logic**: Reads top-to-bottom
4. **Cancellation**: Built-in with Task
## Usage
```swift
Task {
do {
let data = try await fetchData()
await MainActor.run {
// Update UI
}
} catch {
print("Error: \\(error)")
}
}
```
Much cleaner than completion handlers!
""",
tokens: 189,
cost: 0.00038
)
static let mockSystem = Message(
role: .system,
content: "Conversation cleared. Starting fresh.",
tokens: nil,
cost: nil
)
static let mockMessages = [mockUser1, mockAssistant1, mockUser2, mockAssistant2]
}
extension ModelInfo {
static let mockModels = [
ModelInfo(
id: "anthropic/claude-sonnet-4",
name: "Claude Sonnet 4",
description: "Balanced intelligence and speed for most tasks",
contextLength: 200_000,
pricing: Pricing(prompt: 3.0, completion: 15.0),
capabilities: ModelCapabilities(vision: true, tools: true, online: true)
),
ModelInfo(
id: "anthropic/claude-opus-4",
name: "Claude Opus 4",
description: "Most capable model for complex tasks",
contextLength: 200_000,
pricing: Pricing(prompt: 15.0, completion: 75.0),
capabilities: ModelCapabilities(vision: true, tools: true, online: true)
),
ModelInfo(
id: "anthropic/claude-haiku-4",
name: "Claude Haiku 4",
description: "Fast and efficient for simple tasks",
contextLength: 200_000,
pricing: Pricing(prompt: 0.8, completion: 4.0),
capabilities: ModelCapabilities(vision: true, tools: true, online: true)
),
ModelInfo(
id: "openai/gpt-4o",
name: "GPT-4o",
description: "OpenAI's flagship multimodal model",
contextLength: 128_000,
pricing: Pricing(prompt: 2.5, completion: 10.0),
capabilities: ModelCapabilities(vision: true, tools: true, online: false)
),
ModelInfo(
id: "openai/gpt-4o-mini",
name: "GPT-4o Mini",
description: "Faster and cheaper GPT-4o variant",
contextLength: 128_000,
pricing: Pricing(prompt: 0.15, completion: 0.6),
capabilities: ModelCapabilities(vision: true, tools: true, online: false)
),
ModelInfo(
id: "openai/o1",
name: "o1",
description: "Advanced reasoning model for complex problems",
contextLength: 200_000,
pricing: Pricing(prompt: 15.0, completion: 60.0),
capabilities: ModelCapabilities(vision: false, tools: false, online: false)
),
ModelInfo(
id: "google/gemini-pro-1.5",
name: "Gemini Pro 1.5",
description: "Google's advanced multimodal model",
contextLength: 2_000_000,
pricing: Pricing(prompt: 1.25, completion: 5.0),
capabilities: ModelCapabilities(vision: true, tools: true, online: false)
),
ModelInfo(
id: "meta-llama/llama-3.1-405b",
name: "Llama 3.1 405B",
description: "Meta's largest open source model",
contextLength: 128_000,
pricing: Pricing(prompt: 2.7, completion: 2.7),
capabilities: ModelCapabilities(vision: false, tools: true, online: false)
)
]
}
extension Conversation {
static let mockConversation1 = Conversation(
name: "SwiftUI Discussion",
messages: [Message.mockUser1, Message.mockAssistant1],
createdAt: Date().addingTimeInterval(-86400), // 1 day ago
updatedAt: Date().addingTimeInterval(-3600) // 1 hour ago
)
static let mockConversation2 = Conversation(
name: "Async/Await Tutorial",
messages: [Message.mockUser2, Message.mockAssistant2],
createdAt: Date().addingTimeInterval(-172800), // 2 days ago
updatedAt: Date().addingTimeInterval(-7200) // 2 hours ago
)
static let mockConversations = [mockConversation1, mockConversation2]
}

View File

@@ -0,0 +1,56 @@
//
// ModelInfo.swift
// oAI
//
// Model information and capabilities
//
import Foundation
struct ModelInfo: Identifiable, Codable, Hashable {
let id: String
let name: String
let description: String?
let contextLength: Int
let pricing: Pricing
let capabilities: ModelCapabilities
var architecture: Architecture? = nil
var topProvider: String? = nil
struct Pricing: Codable, Hashable {
let prompt: Double // per 1M tokens
let completion: Double
}
struct ModelCapabilities: Codable, Hashable {
let vision: Bool // Images/PDFs
let tools: Bool // Function calling
let online: Bool // Web search
var imageGeneration: Bool = false // Image output
}
struct Architecture: Codable, Hashable {
let tokenizer: String?
let instructType: String?
let modality: String?
}
// Computed properties
var contextLengthDisplay: String {
if contextLength >= 1_000_000 {
return "\(contextLength / 1_000_000)M"
} else if contextLength >= 1000 {
return "\(contextLength / 1000)K"
} else {
return "\(contextLength)"
}
}
var promptPriceDisplay: String {
String(format: "$%.2f", pricing.prompt)
}
var completionPriceDisplay: String {
String(format: "$%.2f", pricing.completion)
}
}

View File

@@ -0,0 +1,58 @@
//
// SessionStats.swift
// oAI
//
// Session statistics tracking
//
import Foundation
struct SessionStats {
var totalInputTokens: Int = 0
var totalOutputTokens: Int = 0
var totalCost: Double = 0.0
var messageCount: Int = 0
var totalTokens: Int {
totalInputTokens + totalOutputTokens
}
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 {
String(format: "$%.4f", totalCost)
}
var averageCostPerMessage: Double {
guard messageCount > 0 else { return 0.0 }
return totalCost / Double(messageCount)
}
mutating func addMessage(inputTokens: Int?, outputTokens: Int?, cost: Double?) {
if let input = inputTokens {
totalInputTokens += input
}
if let output = outputTokens {
totalOutputTokens += output
}
if let messageCost = cost {
totalCost += messageCost
}
messageCount += 1
}
mutating func reset() {
totalInputTokens = 0
totalOutputTokens = 0
totalCost = 0.0
messageCount = 0
}
}

90
oAI/Models/Settings.swift Normal file
View File

@@ -0,0 +1,90 @@
//
// Settings.swift
// oAI
//
// Application settings and configuration
//
import Foundation
struct Settings: Codable {
// Provider settings
var defaultProvider: Provider
var openrouterAPIKey: String?
var anthropicAPIKey: String?
var openaiAPIKey: String?
var ollamaBaseURL: String
// Model settings
var defaultModel: String?
var streamEnabled: Bool
var maxTokens: Int
var systemPrompt: String?
// Feature flags
var onlineMode: Bool
var memoryEnabled: Bool
var mcpEnabled: Bool
// Web search
var searchProvider: SearchProvider
var googleAPIKey: String?
var googleSearchEngineID: String?
// UI
var costWarningThreshold: Double
enum Provider: String, Codable, CaseIterable {
case openrouter
case anthropic
case openai
case ollama
var displayName: String {
rawValue.capitalized
}
var iconName: String {
switch self {
case .openrouter: return "network"
case .anthropic: return "brain"
case .openai: return "sparkles"
case .ollama: return "server.rack"
}
}
}
enum SearchProvider: String, Codable, CaseIterable {
case anthropicNative = "anthropic_native"
case duckduckgo
case google
var displayName: String {
switch self {
case .anthropicNative: return "Anthropic Native"
case .duckduckgo: return "DuckDuckGo"
case .google: return "Google"
}
}
}
// Default settings
static let `default` = Settings(
defaultProvider: .openrouter,
openrouterAPIKey: nil,
anthropicAPIKey: nil,
openaiAPIKey: nil,
ollamaBaseURL: "http://localhost:11434",
defaultModel: nil,
streamEnabled: true,
maxTokens: 4096,
systemPrompt: nil,
onlineMode: false,
memoryEnabled: true,
mcpEnabled: false,
searchProvider: .duckduckgo,
googleAPIKey: nil,
googleSearchEngineID: nil,
costWarningThreshold: 1.0
)
}

View File

@@ -0,0 +1,241 @@
//
// AIProvider.swift
// oAI
//
// Protocol for AI provider implementations
//
import Foundation
// MARK: - Provider Protocol
protocol AIProvider {
var name: String { get }
var capabilities: ProviderCapabilities { get }
func listModels() async throws -> [ModelInfo]
func getModel(_ id: String) async throws -> ModelInfo?
func chat(request: ChatRequest) async throws -> ChatResponse
func streamChat(request: ChatRequest) -> AsyncThrowingStream<StreamChunk, Error>
func getCredits() async throws -> Credits?
/// Chat completion with pre-encoded messages for the MCP tool call loop.
func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse
}
// MARK: - Provider Capabilities
struct ProviderCapabilities: Codable {
let supportsStreaming: Bool
let supportsVision: Bool
let supportsTools: Bool
let supportsOnlineSearch: Bool
let maxContextLength: Int?
static let `default` = ProviderCapabilities(
supportsStreaming: true,
supportsVision: false,
supportsTools: false,
supportsOnlineSearch: false,
maxContextLength: nil
)
}
// MARK: - Chat Request
struct ChatRequest {
let messages: [Message]
let model: String
let stream: Bool
let maxTokens: Int?
let temperature: Double?
let topP: Double?
let systemPrompt: String?
let tools: [Tool]?
let onlineMode: Bool
let imageGeneration: Bool
init(
messages: [Message],
model: String,
stream: Bool = true,
maxTokens: Int? = nil,
temperature: Double? = nil,
topP: Double? = nil,
systemPrompt: String? = nil,
tools: [Tool]? = nil,
onlineMode: Bool = false,
imageGeneration: Bool = false
) {
self.messages = messages
self.model = model
self.stream = stream
self.maxTokens = maxTokens
self.temperature = temperature
self.topP = topP
self.systemPrompt = systemPrompt
self.tools = tools
self.onlineMode = onlineMode
self.imageGeneration = imageGeneration
}
}
// MARK: - Chat Response
struct ToolCallInfo {
let id: String
let type: String
let functionName: String
let arguments: String
}
struct ChatResponse: Codable {
let id: String
let model: String
let content: String
let role: String
let finishReason: String?
let usage: Usage?
let created: Date
let toolCalls: [ToolCallInfo]?
let generatedImages: [Data]?
struct Usage: Codable {
let promptTokens: Int
let completionTokens: Int
let totalTokens: Int
enum CodingKeys: String, CodingKey {
case promptTokens = "prompt_tokens"
case completionTokens = "completion_tokens"
case totalTokens = "total_tokens"
}
}
// Custom Codable since ToolCallInfo/generatedImages are not from API directly
enum CodingKeys: String, CodingKey {
case id, model, content, role, finishReason, usage, created
}
init(id: String, model: String, content: String, role: String, finishReason: String?, usage: Usage?, created: Date, toolCalls: [ToolCallInfo]? = nil, generatedImages: [Data]? = nil) {
self.id = id
self.model = model
self.content = content
self.role = role
self.finishReason = finishReason
self.usage = usage
self.created = created
self.toolCalls = toolCalls
self.generatedImages = generatedImages
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
model = try container.decode(String.self, forKey: .model)
content = try container.decode(String.self, forKey: .content)
role = try container.decode(String.self, forKey: .role)
finishReason = try container.decodeIfPresent(String.self, forKey: .finishReason)
usage = try container.decodeIfPresent(Usage.self, forKey: .usage)
created = try container.decode(Date.self, forKey: .created)
toolCalls = nil
generatedImages = nil
}
}
// MARK: - Stream Chunk
struct StreamChunk {
let id: String
let model: String
let delta: Delta
let finishReason: String?
let usage: ChatResponse.Usage?
struct Delta {
let content: String?
let role: String?
let images: [Data]?
}
var deltaContent: String? {
delta.content
}
}
// MARK: - Tool Definition
struct Tool: Codable {
let type: String
let function: Function
struct Function: Codable {
let name: String
let description: String
let parameters: Parameters
struct Parameters: Codable {
let type: String
let properties: [String: Property]
let required: [String]?
struct Property: Codable {
let type: String
let description: String
let `enum`: [String]?
}
}
}
}
// MARK: - Credits
struct Credits: Codable {
let balance: Double
let currency: String
let usage: Double?
let limit: Double?
var balanceDisplay: String {
String(format: "$%.2f", balance)
}
var usageDisplay: String? {
guard let usage = usage else { return nil }
return String(format: "$%.2f", usage)
}
}
// MARK: - Provider Errors
enum ProviderError: LocalizedError {
case invalidAPIKey
case networkError(Error)
case invalidResponse
case rateLimitExceeded
case modelNotFound(String)
case insufficientCredits
case timeout
case unknown(String)
var errorDescription: String? {
switch self {
case .invalidAPIKey:
return "Invalid API key. Please check your settings."
case .networkError(let error):
return "Network error: \(error.localizedDescription)"
case .invalidResponse:
return "Received invalid response from API"
case .rateLimitExceeded:
return "Rate limit exceeded. Please try again later."
case .modelNotFound(let model):
return "Model '\(model)' not found"
case .insufficientCredits:
return "Insufficient credits"
case .timeout:
return "Request timed out"
case .unknown(let message):
return "Unknown error: \(message)"
}
}
}

View File

@@ -0,0 +1,534 @@
//
// AnthropicProvider.swift
// oAI
//
// Anthropic Messages API provider with SSE streaming and tool support
//
import Foundation
import os
class AnthropicProvider: AIProvider {
let name = "Anthropic"
let capabilities = ProviderCapabilities(
supportsStreaming: true,
supportsVision: true,
supportsTools: true,
supportsOnlineSearch: false,
maxContextLength: nil
)
enum AuthMode {
case apiKey(String)
case oauth
}
private let authMode: AuthMode
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)
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 60
config.timeoutIntervalForResource = 300
self.session = URLSession(configuration: config)
}
/// Create with OAuth (Pro/Max subscription)
init(oauth: Bool) {
self.authMode = .oauth
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 60
config.timeoutIntervalForResource = 300
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 (hardcoded Anthropic has no public models list endpoint)
private static let knownModels: [ModelInfo] = [
ModelInfo(
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
description: "Most capable and intelligent model",
contextLength: 200_000,
pricing: .init(prompt: 15.0, completion: 75.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-opus-4-5-20251101",
name: "Claude Opus 4.5",
description: "Previous generation Opus",
contextLength: 200_000,
pricing: .init(prompt: 15.0, completion: 75.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-sonnet-4-5-20250929",
name: "Claude Sonnet 4.5",
description: "Best balance of speed and capability",
contextLength: 200_000,
pricing: .init(prompt: 3.0, completion: 15.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-haiku-4-5-20251001",
name: "Claude Haiku 4.5",
description: "Fastest and most affordable",
contextLength: 200_000,
pricing: .init(prompt: 0.80, completion: 4.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-3-7-sonnet-20250219",
name: "Claude 3.7 Sonnet",
description: "Previous generation Sonnet",
contextLength: 200_000,
pricing: .init(prompt: 3.0, completion: 15.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-3-haiku-20240307",
name: "Claude 3 Haiku",
description: "Previous generation Haiku",
contextLength: 200_000,
pricing: .init(prompt: 0.25, completion: 1.25),
capabilities: .init(vision: true, tools: true, online: true)
),
]
func listModels() async throws -> [ModelInfo] {
return Self.knownModels
}
func getModel(_ id: String) async throws -> ModelInfo? {
return Self.knownModels.first { $0.id == id }
}
// MARK: - Chat Completion
func chat(request: ChatRequest) async throws -> ChatResponse {
Log.api.info("Anthropic chat request: model=\(request.model), messages=\(request.messages.count)")
var (urlRequest, _) = try buildURLRequest(from: request, stream: false)
try await applyAuth(to: &urlRequest)
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("Anthropic chat: invalid response (not HTTP)")
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let error = errorObj["error"] as? [String: Any],
let message = error["message"] as? String {
Log.api.error("Anthropic chat HTTP \(httpResponse.statusCode): \(message)")
throw ProviderError.unknown(message)
}
Log.api.error("Anthropic chat HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
return try parseResponse(data: data)
}
// MARK: - Chat with raw tool messages
func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse {
Log.api.info("Anthropic tool chat: model=\(model), messages=\(messages.count)")
let url = messagesURL
// Separate system message from conversation messages
var systemText: String? = nil
var conversationMessages: [[String: Any]] = []
for msg in messages {
let role = msg["role"] as? String ?? ""
if role == "system" {
systemText = msg["content"] as? String
} else if role == "tool" {
// Convert OpenAI tool result format to Anthropic tool_result format
let toolCallId = msg["tool_call_id"] as? String ?? ""
let content = msg["content"] as? String ?? ""
conversationMessages.append([
"role": "user",
"content": [
["type": "tool_result", "tool_use_id": toolCallId, "content": content]
]
])
} else if role == "assistant" {
// Check for tool_calls convert to Anthropic content blocks
if let toolCalls = msg["tool_calls"] as? [[String: Any]], !toolCalls.isEmpty {
var contentBlocks: [[String: Any]] = []
if let text = msg["content"] as? String, !text.isEmpty {
contentBlocks.append(["type": "text", "text": text])
}
for tc in toolCalls {
let fn = tc["function"] as? [String: Any] ?? [:]
let name = fn["name"] as? String ?? ""
let argsStr = fn["arguments"] as? String ?? "{}"
let argsObj = (try? JSONSerialization.jsonObject(with: Data(argsStr.utf8))) ?? [:]
contentBlocks.append([
"type": "tool_use",
"id": tc["id"] as? String ?? UUID().uuidString,
"name": name,
"input": argsObj
])
}
conversationMessages.append(["role": "assistant", "content": contentBlocks])
} else {
conversationMessages.append(["role": "assistant", "content": msg["content"] as? String ?? ""])
}
} else {
conversationMessages.append(["role": role, "content": msg["content"] as? String ?? ""])
}
}
var body: [String: Any] = [
"model": model,
"messages": conversationMessages,
"max_tokens": maxTokens ?? 4096,
"stream": false
]
if let systemText = systemText {
body["system"] = systemText
}
if let temperature = temperature {
body["temperature"] = temperature
}
if let tools = tools {
body["tools"] = tools.map { tool -> [String: Any] in
[
"name": tool.function.name,
"description": tool.function.description,
"input_schema": convertParametersToDict(tool.function.parameters)
]
}
body["tool_choice"] = ["type": "auto"]
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body)
try await applyAuth(to: &urlRequest)
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("Anthropic tool chat: invalid response (not HTTP)")
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let error = errorObj["error"] as? [String: Any],
let message = error["message"] as? String {
Log.api.error("Anthropic tool chat HTTP \(httpResponse.statusCode): \(message)")
throw ProviderError.unknown(message)
}
Log.api.error("Anthropic tool chat HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
return try parseResponse(data: data)
}
// MARK: - Streaming Chat
func streamChat(request: ChatRequest) -> AsyncThrowingStream<StreamChunk, Error> {
Log.api.info("Anthropic stream request: model=\(request.model), messages=\(request.messages.count)")
return AsyncThrowingStream { continuation in
Task {
do {
var (urlRequest, _) = try buildURLRequest(from: request, stream: true)
try await self.applyAuth(to: &urlRequest)
let (bytes, response) = try await session.bytes(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("Anthropic stream: invalid response (not HTTP)")
continuation.finish(throwing: ProviderError.invalidResponse)
return
}
guard httpResponse.statusCode == 200 else {
Log.api.error("Anthropic stream HTTP \(httpResponse.statusCode)")
continuation.finish(throwing: ProviderError.unknown("HTTP \(httpResponse.statusCode)"))
return
}
var currentId = ""
var currentModel = request.model
for try await line in bytes.lines {
// Anthropic SSE: "event: ..." and "data: {...}"
guard line.hasPrefix("data: ") else { continue }
let jsonString = String(line.dropFirst(6))
guard let jsonData = jsonString.data(using: .utf8),
let event = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
let eventType = event["type"] as? String else {
continue
}
switch eventType {
case "message_start":
if let message = event["message"] as? [String: Any] {
currentId = message["id"] as? String ?? ""
currentModel = message["model"] as? String ?? request.model
}
case "content_block_delta":
if let delta = event["delta"] as? [String: Any],
let deltaType = delta["type"] as? String,
deltaType == "text_delta",
let text = delta["text"] as? String {
continuation.yield(StreamChunk(
id: currentId,
model: currentModel,
delta: .init(content: text, role: nil, images: nil),
finishReason: nil,
usage: nil
))
}
case "message_delta":
let delta = event["delta"] as? [String: Any]
let stopReason = delta?["stop_reason"] as? String
var usage: ChatResponse.Usage? = nil
if let usageDict = event["usage"] as? [String: Any] {
let outputTokens = usageDict["output_tokens"] as? Int ?? 0
usage = ChatResponse.Usage(promptTokens: 0, completionTokens: outputTokens, totalTokens: outputTokens)
}
continuation.yield(StreamChunk(
id: currentId,
model: currentModel,
delta: .init(content: nil, role: nil, images: nil),
finishReason: stopReason,
usage: usage
))
case "message_stop":
continuation.finish()
return
default:
break
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
// MARK: - Credits
func getCredits() async throws -> Credits? {
// Anthropic doesn't have a public credits API
return nil
}
// MARK: - Auth Helpers
/// Apply auth headers based on mode (API key or OAuth Bearer)
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")
}
}
/// 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")!
}
}
// MARK: - Request Building
private func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> (URLRequest, Data) {
let url = messagesURL
// Separate system message
var systemText: String? = request.systemPrompt
var apiMessages: [[String: Any]] = []
for msg in request.messages {
if msg.role == .system {
systemText = msg.content
continue
}
let hasAttachments = msg.attachments?.contains(where: { $0.data != nil }) ?? false
if hasAttachments, let attachments = msg.attachments {
var contentBlocks: [[String: Any]] = []
contentBlocks.append(["type": "text", "text": msg.content])
for attachment in attachments {
guard let data = attachment.data else { continue }
switch attachment.type {
case .image, .pdf:
let base64 = data.base64EncodedString()
contentBlocks.append([
"type": "image",
"source": [
"type": "base64",
"media_type": attachment.mimeType,
"data": base64
]
])
case .text:
let filename = (attachment.path as NSString).lastPathComponent
let textContent = String(data: data, encoding: .utf8) ?? ""
contentBlocks.append(["type": "text", "text": "File: \(filename)\n\n\(textContent)"])
}
}
apiMessages.append(["role": msg.role.rawValue, "content": contentBlocks])
} else {
apiMessages.append(["role": msg.role.rawValue, "content": msg.content])
}
}
var body: [String: Any] = [
"model": request.model,
"messages": apiMessages,
"max_tokens": request.maxTokens ?? 4096,
"stream": stream
]
if let systemText = systemText {
body["system"] = systemText
}
if let temperature = request.temperature {
body["temperature"] = temperature
}
var toolsArray: [[String: Any]] = []
if let tools = request.tools {
toolsArray += tools.map { tool -> [String: Any] in
[
"name": tool.function.name,
"description": tool.function.description,
"input_schema": convertParametersToDict(tool.function.parameters)
]
}
}
if request.onlineMode {
toolsArray.append([
"type": "web_search_20250305",
"name": "web_search",
"max_uses": 5
])
}
if !toolsArray.isEmpty {
body["tools"] = toolsArray
if request.tools != nil {
body["tool_choice"] = ["type": "auto"]
}
}
let bodyData = try JSONSerialization.data(withJSONObject: body)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
if stream {
urlRequest.addValue("text/event-stream", forHTTPHeaderField: "Accept")
}
urlRequest.httpBody = bodyData
// Auth is applied async in the caller (chat/streamChat)
return (urlRequest, bodyData)
}
private func parseResponse(data: Data) throws -> ChatResponse {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw ProviderError.invalidResponse
}
let id = json["id"] as? String ?? ""
let model = json["model"] as? String ?? ""
let contentBlocks = json["content"] as? [[String: Any]] ?? []
var textContent = ""
var toolCalls: [ToolCallInfo] = []
for block in contentBlocks {
let blockType = block["type"] as? String ?? ""
switch blockType {
case "text":
textContent += block["text"] as? String ?? ""
case "tool_use":
let tcId = block["id"] as? String ?? UUID().uuidString
let tcName = block["name"] as? String ?? ""
let tcInput = block["input"] ?? [:]
let argsData = try JSONSerialization.data(withJSONObject: tcInput)
let argsStr = String(data: argsData, encoding: .utf8) ?? "{}"
toolCalls.append(ToolCallInfo(id: tcId, type: "function", functionName: tcName, arguments: argsStr))
default:
break
}
}
let usageDict = json["usage"] as? [String: Any]
let inputTokens = usageDict?["input_tokens"] as? Int ?? 0
let outputTokens = usageDict?["output_tokens"] as? Int ?? 0
return ChatResponse(
id: id,
model: model,
content: textContent,
role: "assistant",
finishReason: json["stop_reason"] as? String,
usage: ChatResponse.Usage(
promptTokens: inputTokens,
completionTokens: outputTokens,
totalTokens: inputTokens + outputTokens
),
created: Date(),
toolCalls: toolCalls.isEmpty ? nil : toolCalls
)
}
private func convertParametersToDict(_ params: Tool.Function.Parameters) -> [String: Any] {
var props: [String: Any] = [:]
for (key, prop) in params.properties {
var propDict: [String: Any] = [
"type": prop.type,
"description": prop.description
]
if let enumVals = prop.enum {
propDict["enum"] = enumVals
}
props[key] = propDict
}
var dict: [String: Any] = [
"type": params.type,
"properties": props
]
if let required = params.required {
dict["required"] = required
}
return dict
}
}

View File

@@ -0,0 +1,308 @@
//
// OllamaProvider.swift
// oAI
//
// Ollama local AI provider with JSON-lines streaming
//
import Foundation
import os
class OllamaProvider: AIProvider {
let name = "Ollama"
let capabilities = ProviderCapabilities(
supportsStreaming: true,
supportsVision: false,
supportsTools: false,
supportsOnlineSearch: false,
maxContextLength: nil
)
private let baseURL: String
private let session: URLSession
init(baseURL: String = "http://localhost:11434") {
self.baseURL = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 120
config.timeoutIntervalForResource = 600
self.session = URLSession(configuration: config)
}
// MARK: - Models
func listModels() async throws -> [ModelInfo] {
Log.api.info("Fetching model list from Ollama at \(self.baseURL)")
let url = URL(string: "\(baseURL)/api/tags")!
var request = URLRequest(url: url)
request.timeoutInterval = 5
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch {
Log.api.warning("Cannot connect to Ollama at \(self.baseURL). Is Ollama running?")
throw ProviderError.unknown("Cannot connect to Ollama at \(baseURL). Is Ollama running? Start it with: ollama serve")
}
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw ProviderError.unknown("Ollama returned an error. Is it running?")
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let modelsArray = json["models"] as? [[String: Any]] else {
return []
}
return modelsArray.compactMap { model -> ModelInfo? in
guard let name = model["name"] as? String else { return nil }
let sizeBytes = model["size"] as? Int64 ?? 0
let sizeGB = String(format: "%.1f GB", Double(sizeBytes) / 1_073_741_824)
return ModelInfo(
id: name,
name: name,
description: "Local model (\(sizeGB))",
contextLength: 0,
pricing: .init(prompt: 0, completion: 0),
capabilities: .init(vision: false, tools: false, online: false)
)
}
}
func getModel(_ id: String) async throws -> ModelInfo? {
let models = try await listModels()
return models.first { $0.id == id }
}
// MARK: - Chat Completion
func chat(request: ChatRequest) async throws -> ChatResponse {
Log.api.info("Ollama chat request: model=\(request.model), messages=\(request.messages.count)")
let url = URL(string: "\(baseURL)/api/chat")!
let body = buildRequestBody(from: request, stream: false)
let bodyData = try JSONSerialization.data(withJSONObject: body)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = bodyData
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("Ollama chat: invalid response (not HTTP)")
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let errorMsg = errorObj["error"] as? String {
Log.api.error("Ollama chat HTTP \(httpResponse.statusCode): \(errorMsg)")
throw ProviderError.unknown(errorMsg)
}
Log.api.error("Ollama chat HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("Ollama HTTP \(httpResponse.statusCode)")
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw ProviderError.invalidResponse
}
return parseOllamaResponse(json, model: request.model)
}
// MARK: - Chat with raw tool messages
func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse {
// Ollama doesn't support tool calls natively just send messages as plain chat
let url = URL(string: "\(baseURL)/api/chat")!
// Convert messages, stripping tool-specific fields
var ollamaMessages: [[String: Any]] = []
for msg in messages {
let role = msg["role"] as? String ?? "user"
let content = msg["content"] as? String ?? ""
if role == "tool" {
// Convert tool results to assistant context
let toolName = msg["name"] as? String ?? "tool"
ollamaMessages.append(["role": "user", "content": "[\(toolName) result]: \(content)"])
} else if role == "assistant" {
// Strip tool_calls, just keep content
if let tc = msg["tool_calls"] as? [[String: Any]], !tc.isEmpty {
let toolNames = tc.compactMap { ($0["function"] as? [String: Any])?["name"] as? String }
let text = (msg["content"] as? String) ?? ""
let combined = text.isEmpty ? "Calling: \(toolNames.joined(separator: ", "))" : text
ollamaMessages.append(["role": "assistant", "content": combined])
} else {
ollamaMessages.append(["role": "assistant", "content": content])
}
} else {
ollamaMessages.append(["role": role, "content": content])
}
}
var body: [String: Any] = [
"model": model,
"messages": ollamaMessages,
"stream": false
]
var options: [String: Any] = [:]
if let maxTokens = maxTokens { options["num_predict"] = maxTokens }
if let temperature = temperature { options["temperature"] = temperature }
if !options.isEmpty { body["options"] = options }
let bodyData = try JSONSerialization.data(withJSONObject: body)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = bodyData
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw ProviderError.unknown("Ollama error")
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw ProviderError.invalidResponse
}
return parseOllamaResponse(json, model: model)
}
// MARK: - Streaming Chat
func streamChat(request: ChatRequest) -> AsyncThrowingStream<StreamChunk, Error> {
Log.api.info("Ollama stream request: model=\(request.model), messages=\(request.messages.count)")
return AsyncThrowingStream { continuation in
Task {
do {
let url = URL(string: "\(baseURL)/api/chat")!
let body = buildRequestBody(from: request, stream: true)
let bodyData = try JSONSerialization.data(withJSONObject: body)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = bodyData
let (bytes, response) = try await session.bytes(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("Ollama stream: invalid response (not HTTP)")
continuation.finish(throwing: ProviderError.invalidResponse)
return
}
guard httpResponse.statusCode == 200 else {
Log.api.error("Ollama stream HTTP \(httpResponse.statusCode)")
continuation.finish(throwing: ProviderError.unknown("Ollama HTTP \(httpResponse.statusCode)"))
return
}
// Ollama streams JSON lines (one complete JSON object per line)
for try await line in bytes.lines {
guard !line.isEmpty,
let lineData = line.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] else {
continue
}
let done = json["done"] as? Bool ?? false
let message = json["message"] as? [String: Any]
let content = message?["content"] as? String
if done {
// Final chunk has usage stats
let promptTokens = json["prompt_eval_count"] as? Int ?? 0
let completionTokens = json["eval_count"] as? Int ?? 0
continuation.yield(StreamChunk(
id: "",
model: request.model,
delta: .init(content: content, role: nil, images: nil),
finishReason: "stop",
usage: ChatResponse.Usage(
promptTokens: promptTokens,
completionTokens: completionTokens,
totalTokens: promptTokens + completionTokens
)
))
continuation.finish()
return
} else if let content = content {
continuation.yield(StreamChunk(
id: "",
model: request.model,
delta: .init(content: content, role: nil, images: nil),
finishReason: nil,
usage: nil
))
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
// MARK: - Credits
func getCredits() async throws -> Credits? {
// Local models no credits needed
return nil
}
// MARK: - Helpers
private func buildRequestBody(from request: ChatRequest, stream: Bool) -> [String: Any] {
var messages: [[String: Any]] = []
// Add system prompt as a system message
if let systemPrompt = request.systemPrompt {
messages.append(["role": "system", "content": systemPrompt])
}
for msg in request.messages {
messages.append(["role": msg.role.rawValue, "content": msg.content])
}
var body: [String: Any] = [
"model": request.model,
"messages": messages,
"stream": stream
]
var options: [String: Any] = [:]
if let maxTokens = request.maxTokens { options["num_predict"] = maxTokens }
if let temperature = request.temperature { options["temperature"] = temperature }
if !options.isEmpty { body["options"] = options }
return body
}
private func parseOllamaResponse(_ json: [String: Any], model: String) -> ChatResponse {
let message = json["message"] as? [String: Any]
let content = message?["content"] as? String ?? ""
let promptTokens = json["prompt_eval_count"] as? Int ?? 0
let completionTokens = json["eval_count"] as? Int ?? 0
return ChatResponse(
id: UUID().uuidString,
model: model,
content: content,
role: "assistant",
finishReason: "stop",
usage: ChatResponse.Usage(
promptTokens: promptTokens,
completionTokens: completionTokens,
totalTokens: promptTokens + completionTokens
),
created: Date()
)
}
}

View File

@@ -0,0 +1,367 @@
//
// OpenAIProvider.swift
// oAI
//
// OpenAI API provider with SSE streaming and tool support
//
import Foundation
import os
class OpenAIProvider: AIProvider {
let name = "OpenAI"
let capabilities = ProviderCapabilities(
supportsStreaming: true,
supportsVision: true,
supportsTools: true,
supportsOnlineSearch: false,
maxContextLength: nil
)
private let apiKey: String
private let baseURL = "https://api.openai.com/v1"
private let session: URLSession
init(apiKey: String) {
self.apiKey = apiKey
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 60
config.timeoutIntervalForResource = 300
self.session = URLSession(configuration: config)
}
// MARK: - Models
/// Known models with pricing, used as fallback and for enrichment
private static let knownModels: [String: (name: String, desc: String?, ctx: Int, prompt: Double, completion: Double, vision: Bool)] = [
"gpt-4o": ("GPT-4o", "Most capable GPT-4 model", 128_000, 2.50, 10.0, true),
"gpt-4o-mini": ("GPT-4o Mini", "Affordable and fast", 128_000, 0.15, 0.60, true),
"gpt-4-turbo": ("GPT-4 Turbo", "GPT-4 Turbo with vision", 128_000, 10.0, 30.0, true),
"gpt-3.5-turbo": ("GPT-3.5 Turbo", "Fast and affordable", 16_385, 0.50, 1.50, false),
"o1": ("o1", "Advanced reasoning model", 200_000, 15.0, 60.0, true),
"o1-mini": ("o1 Mini", "Fast reasoning model", 128_000, 3.0, 12.0, false),
"o3-mini": ("o3 Mini", "Latest fast reasoning model", 200_000, 1.10, 4.40, false),
]
func listModels() async throws -> [ModelInfo] {
Log.api.info("Fetching model list from OpenAI")
let url = URL(string: "\(baseURL)/models")!
var request = URLRequest(url: url)
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
do {
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
Log.api.warning("OpenAI models endpoint failed, using fallback models")
return fallbackModels()
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let modelsArray = json["data"] as? [[String: Any]] else {
return fallbackModels()
}
// Filter to chat models
let chatModelIds = modelsArray
.compactMap { $0["id"] as? String }
.filter { id in
id.contains("gpt") || id.hasPrefix("o1") || id.hasPrefix("o3")
}
.sorted()
var models: [ModelInfo] = []
for id in chatModelIds {
if let known = Self.knownModels[id] {
models.append(ModelInfo(
id: id,
name: known.name,
description: known.desc,
contextLength: known.ctx,
pricing: .init(prompt: known.prompt, completion: known.completion),
capabilities: .init(vision: known.vision, tools: true, online: false)
))
} else {
models.append(ModelInfo(
id: id,
name: id,
description: nil,
contextLength: 128_000,
pricing: .init(prompt: 0, completion: 0),
capabilities: .init(vision: false, tools: true, online: false)
))
}
}
Log.api.info("OpenAI loaded \(models.count) models")
return models.isEmpty ? fallbackModels() : models
} catch {
Log.api.warning("OpenAI models fetch failed: \(error.localizedDescription), using fallback")
return fallbackModels()
}
}
private func fallbackModels() -> [ModelInfo] {
Self.knownModels.map { id, info in
ModelInfo(
id: id,
name: info.name,
description: info.desc,
contextLength: info.ctx,
pricing: .init(prompt: info.prompt, completion: info.completion),
capabilities: .init(vision: info.vision, tools: true, online: false)
)
}.sorted { $0.name < $1.name }
}
func getModel(_ id: String) async throws -> ModelInfo? {
let models = try await listModels()
return models.first { $0.id == id }
}
// MARK: - Chat Completion
func chat(request: ChatRequest) async throws -> ChatResponse {
Log.api.info("OpenAI chat request: model=\(request.model), messages=\(request.messages.count)")
let urlRequest = try buildURLRequest(from: request, stream: false)
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("OpenAI chat: invalid response (not HTTP)")
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let error = errorObj["error"] as? [String: Any],
let message = error["message"] as? String {
Log.api.error("OpenAI chat HTTP \(httpResponse.statusCode): \(message)")
throw ProviderError.unknown(message)
}
Log.api.error("OpenAI chat HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
// Reuse the OpenRouter response format OpenAI is identical
let apiResponse = try JSONDecoder().decode(OpenRouterChatResponse.self, from: data)
return convertToChatResponse(apiResponse)
}
// MARK: - Chat with raw tool messages
func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse {
Log.api.info("OpenAI tool chat: model=\(model), messages=\(messages.count)")
let url = URL(string: "\(baseURL)/chat/completions")!
var body: [String: Any] = [
"model": model,
"messages": messages,
"stream": false
]
if let tools = tools {
let toolsData = try JSONEncoder().encode(tools)
body["tools"] = try JSONSerialization.jsonObject(with: toolsData)
body["tool_choice"] = "auto"
}
if let maxTokens = maxTokens { body["max_tokens"] = maxTokens }
// o1/o3 models don't support temperature
if let temperature = temperature, !model.hasPrefix("o1"), !model.hasPrefix("o3") {
body["temperature"] = temperature
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("OpenAI tool chat: invalid response (not HTTP)")
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let error = errorObj["error"] as? [String: Any],
let message = error["message"] as? String {
Log.api.error("OpenAI tool chat HTTP \(httpResponse.statusCode): \(message)")
throw ProviderError.unknown(message)
}
Log.api.error("OpenAI tool chat HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
let apiResponse = try JSONDecoder().decode(OpenRouterChatResponse.self, from: data)
return convertToChatResponse(apiResponse)
}
// MARK: - Streaming Chat
func streamChat(request: ChatRequest) -> AsyncThrowingStream<StreamChunk, Error> {
Log.api.info("OpenAI stream request: model=\(request.model), messages=\(request.messages.count)")
return AsyncThrowingStream { continuation in
Task {
do {
let urlRequest = try buildURLRequest(from: request, stream: true)
let (bytes, response) = try await session.bytes(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("OpenAI stream: invalid response (not HTTP)")
continuation.finish(throwing: ProviderError.invalidResponse)
return
}
guard httpResponse.statusCode == 200 else {
Log.api.error("OpenAI stream HTTP \(httpResponse.statusCode)")
continuation.finish(throwing: ProviderError.unknown("HTTP \(httpResponse.statusCode)"))
return
}
var buffer = ""
for try await line in bytes.lines {
guard line.hasPrefix("data: ") else { continue }
let jsonString = String(line.dropFirst(6))
if jsonString == "[DONE]" {
continuation.finish()
return
}
buffer += jsonString
if let jsonData = buffer.data(using: .utf8) {
do {
let chunk = try JSONDecoder().decode(OpenRouterStreamChunk.self, from: jsonData)
guard let choice = chunk.choices.first else { continue }
continuation.yield(StreamChunk(
id: chunk.id,
model: chunk.model,
delta: .init(content: choice.delta.content, role: choice.delta.role, images: nil),
finishReason: choice.finishReason,
usage: chunk.usage.map {
ChatResponse.Usage(promptTokens: $0.promptTokens, completionTokens: $0.completionTokens, totalTokens: $0.totalTokens)
}
))
buffer = ""
} catch {
continue // Partial JSON, keep buffering
}
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
// MARK: - Credits
func getCredits() async throws -> Credits? {
// OpenAI doesn't have a public credits API
return nil
}
// MARK: - Helpers
private func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> URLRequest {
let url = URL(string: "\(baseURL)/chat/completions")!
var apiMessages: [[String: Any]] = []
// Add system prompt if present
if let systemPrompt = request.systemPrompt {
apiMessages.append(["role": "system", "content": systemPrompt])
}
for msg in request.messages {
let hasAttachments = msg.attachments?.contains(where: { $0.data != nil }) ?? false
if hasAttachments, let attachments = msg.attachments {
// Multi-part content (OpenAI vision format)
var contentArray: [[String: Any]] = [
["type": "text", "text": msg.content]
]
for attachment in attachments {
guard let data = attachment.data else { continue }
switch attachment.type {
case .image, .pdf:
let base64 = data.base64EncodedString()
let dataURL = "data:\(attachment.mimeType);base64,\(base64)"
contentArray.append([
"type": "image_url",
"image_url": ["url": dataURL]
])
case .text:
let filename = (attachment.path as NSString).lastPathComponent
let textContent = String(data: data, encoding: .utf8) ?? ""
contentArray.append(["type": "text", "text": "File: \(filename)\n\n\(textContent)"])
}
}
apiMessages.append(["role": msg.role.rawValue, "content": contentArray])
} else {
apiMessages.append(["role": msg.role.rawValue, "content": msg.content])
}
}
var body: [String: Any] = [
"model": request.model,
"messages": apiMessages,
"stream": stream
]
if let maxTokens = request.maxTokens { body["max_tokens"] = maxTokens }
// o1/o3 reasoning models don't support temperature
if let temperature = request.temperature, !request.model.hasPrefix("o1"), !request.model.hasPrefix("o3") {
body["temperature"] = temperature
}
if let tools = request.tools {
let toolsData = try JSONEncoder().encode(tools)
body["tools"] = try JSONSerialization.jsonObject(with: toolsData)
body["tool_choice"] = "auto"
}
if stream {
// Request usage in streaming mode
body["stream_options"] = ["include_usage": true]
}
let bodyData = try JSONSerialization.data(withJSONObject: body)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
if stream {
urlRequest.addValue("text/event-stream", forHTTPHeaderField: "Accept")
}
urlRequest.httpBody = bodyData
return urlRequest
}
private func convertToChatResponse(_ apiResponse: OpenRouterChatResponse) -> ChatResponse {
guard let choice = apiResponse.choices.first else {
return ChatResponse(id: apiResponse.id, model: apiResponse.model, content: "", role: "assistant", finishReason: nil, usage: nil, created: Date())
}
let toolCalls: [ToolCallInfo]? = choice.message.toolCalls?.map { tc in
ToolCallInfo(id: tc.id, type: tc.type, functionName: tc.function.name, arguments: tc.function.arguments)
}
return ChatResponse(
id: apiResponse.id,
model: apiResponse.model,
content: choice.message.content ?? "",
role: choice.message.role,
finishReason: choice.finishReason,
usage: apiResponse.usage.map {
ChatResponse.Usage(promptTokens: $0.promptTokens, completionTokens: $0.completionTokens, totalTokens: $0.totalTokens)
},
created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)),
toolCalls: toolCalls
)
}
}

View File

@@ -0,0 +1,313 @@
//
// OpenRouterModels.swift
// oAI
//
// OpenRouter API request and response models
//
import Foundation
// MARK: - API Request
struct OpenRouterChatRequest: Codable {
let model: String
let messages: [APIMessage]
var stream: Bool
let maxTokens: Int?
let temperature: Double?
let topP: Double?
let tools: [Tool]?
let toolChoice: String?
let modalities: [String]?
struct APIMessage: Codable {
let role: String
let content: MessageContent
enum MessageContent: Codable {
case string(String)
case array([ContentItem])
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let str = try? container.decode(String.self) {
self = .string(str)
} else if let arr = try? container.decode([ContentItem].self) {
self = .array(arr)
} else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid content")
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let str):
try container.encode(str)
case .array(let arr):
try container.encode(arr)
}
}
}
enum ContentItem: Codable {
case text(String)
case image(ImageContent)
struct TextContent: Codable {
let type: String // "text"
let text: String
}
struct ImageContent: Codable {
let type: String // "image_url"
let imageUrl: ImageURL
struct ImageURL: Codable {
let url: String
}
enum CodingKeys: String, CodingKey {
case type
case imageUrl = "image_url"
}
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let textContent = try? container.decode(TextContent.self), textContent.type == "text" {
self = .text(textContent.text)
} else if let image = try? container.decode(ImageContent.self) {
self = .image(image)
} else if let str = try? container.decode(String.self) {
self = .text(str)
} else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid content item")
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .text(let text):
try container.encode(TextContent(type: "text", text: text))
case .image(let image):
try container.encode(image)
}
}
}
}
enum CodingKeys: String, CodingKey {
case model
case messages
case stream
case maxTokens = "max_tokens"
case temperature
case topP = "top_p"
case tools
case toolChoice = "tool_choice"
case modalities
}
}
// MARK: - API Response
struct OpenRouterChatResponse: Codable {
let id: String
let model: String
let choices: [Choice]
let usage: Usage?
let created: Int
struct Choice: Codable {
let index: Int
let message: MessageContent
let finishReason: String?
struct MessageContent: Codable {
let role: String
let content: String?
let toolCalls: [APIToolCall]?
let images: [ImageOutput]?
enum CodingKeys: String, CodingKey {
case role
case content
case toolCalls = "tool_calls"
case images
}
}
enum CodingKeys: String, CodingKey {
case index
case message
case finishReason = "finish_reason"
}
}
struct ImageOutput: Codable {
let imageUrl: ImageURL
struct ImageURL: Codable {
let url: String
}
enum CodingKeys: String, CodingKey {
case imageUrl = "image_url"
}
}
struct Usage: Codable {
let promptTokens: Int
let completionTokens: Int
let totalTokens: Int
enum CodingKeys: String, CodingKey {
case promptTokens = "prompt_tokens"
case completionTokens = "completion_tokens"
case totalTokens = "total_tokens"
}
}
}
// MARK: - Streaming Response
struct OpenRouterStreamChunk: Codable {
let id: String
let model: String
let choices: [StreamChoice]
let usage: OpenRouterChatResponse.Usage?
struct StreamChoice: Codable {
let index: Int
let delta: Delta
let finishReason: String?
struct Delta: Codable {
let role: String?
let content: String?
let images: [OpenRouterChatResponse.ImageOutput]?
}
enum CodingKeys: String, CodingKey {
case index
case delta
case finishReason = "finish_reason"
}
}
}
// MARK: - Models List
struct OpenRouterModelsResponse: Codable {
let data: [ModelData]
struct ModelData: Codable {
let id: String
let name: String
let description: String?
let contextLength: Int
let pricing: PricingData
let architecture: Architecture?
let supportedParameters: [String]?
let outputModalities: [String]?
struct PricingData: Codable {
let prompt: String
let completion: String
}
struct Architecture: Codable {
let modality: String?
let tokenizer: String?
let instructType: String?
enum CodingKeys: String, CodingKey {
case modality
case tokenizer
case instructType = "instruct_type"
}
}
enum CodingKeys: String, CodingKey {
case id
case name
case description
case contextLength = "context_length"
case pricing
case architecture
case supportedParameters = "supported_parameters"
case outputModalities = "output_modalities"
}
}
}
// MARK: - Credits Response
struct OpenRouterCreditsResponse: Codable {
let data: CreditsData
struct CreditsData: Codable {
let totalCredits: Double?
let totalUsage: Double?
enum CodingKeys: String, CodingKey {
case totalCredits = "total_credits"
case totalUsage = "total_usage"
}
}
}
// MARK: - Tool Call Models
struct APIToolCall: Codable {
let id: String
let type: String
let function: FunctionCall
struct FunctionCall: Codable {
let name: String
let arguments: String
}
}
/// Message shape for encoding assistant messages that contain tool calls
struct AssistantToolCallMessage: Encodable {
let role: String
let content: String?
let toolCalls: [APIToolCall]
enum CodingKeys: String, CodingKey {
case role
case content
case toolCalls = "tool_calls"
}
}
/// Message shape for encoding tool result messages back to the API
struct ToolResultMessage: Encodable {
let role: String // "tool"
let toolCallId: String
let name: String
let content: String
enum CodingKeys: String, CodingKey {
case role
case toolCallId = "tool_call_id"
case name
case content
}
}
// MARK: - Error Response
struct OpenRouterErrorResponse: Codable {
let error: ErrorDetail
struct ErrorDetail: Codable {
let message: String
let type: String?
let code: String?
}
}

View File

@@ -0,0 +1,433 @@
//
// OpenRouterProvider.swift
// oAI
//
// OpenRouter AI provider implementation with SSE streaming
//
import Foundation
import os
class OpenRouterProvider: AIProvider {
let name = "OpenRouter"
let capabilities = ProviderCapabilities(
supportsStreaming: true,
supportsVision: true,
supportsTools: true,
supportsOnlineSearch: true,
maxContextLength: nil
)
private let apiKey: String
private let baseURL = "https://openrouter.ai/api/v1"
private let session: URLSession
init(apiKey: String) {
self.apiKey = apiKey
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 60
config.timeoutIntervalForResource = 300
self.session = URLSession(configuration: config)
}
// MARK: - List Models
func listModels() async throws -> [ModelInfo] {
Log.api.info("Fetching model list from OpenRouter")
let url = URL(string: "\(baseURL)/models")!
var request = URLRequest(url: url)
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("OpenRouter models: invalid response (not HTTP)")
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorResponse = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
Log.api.error("OpenRouter models HTTP \(httpResponse.statusCode): \(errorResponse.error.message)")
throw ProviderError.unknown(errorResponse.error.message)
}
Log.api.error("OpenRouter models HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
let modelsResponse = try JSONDecoder().decode(OpenRouterModelsResponse.self, from: data)
Log.api.info("OpenRouter loaded \(modelsResponse.data.count) models")
return modelsResponse.data.map { modelData in
let promptPrice = Double(modelData.pricing.prompt) ?? 0.0
let completionPrice = Double(modelData.pricing.completion) ?? 0.0
return ModelInfo(
id: modelData.id,
name: modelData.name,
description: modelData.description,
contextLength: modelData.contextLength,
pricing: ModelInfo.Pricing(
prompt: promptPrice * 1_000_000, // Convert to per 1M tokens
completion: completionPrice * 1_000_000
),
capabilities: ModelInfo.ModelCapabilities(
vision: {
let mod = modelData.architecture?.modality ?? ""
return mod == "multimodal" || mod.hasPrefix("text+image")
}(),
tools: modelData.supportedParameters?.contains("tools") ?? false,
online: {
// OpenRouter supports :online suffix for all text models
let mod = modelData.architecture?.modality ?? ""
if let arrow = mod.range(of: "->") {
return !mod[arrow.upperBound...].contains("image")
}
return true
}(),
imageGeneration: {
if let mod = modelData.architecture?.modality,
let arrow = mod.range(of: "->") {
let output = mod[arrow.upperBound...]
return output.contains("image")
}
return false
}()
),
architecture: modelData.architecture.map { arch in
ModelInfo.Architecture(
tokenizer: arch.tokenizer,
instructType: arch.instructType,
modality: arch.modality
)
},
topProvider: modelData.id.components(separatedBy: "/").first
)
}
}
func getModel(_ id: String) async throws -> ModelInfo? {
let models = try await listModels()
return models.first { $0.id == id }
}
// MARK: - Chat Completion
func chat(request: ChatRequest) async throws -> ChatResponse {
Log.api.info("OpenRouter chat request: model=\(request.model), messages=\(request.messages.count)")
let apiRequest = try buildAPIRequest(from: request)
let url = URL(string: "\(baseURL)/chat/completions")!
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.addValue("https://github.com/yourusername/oAI", forHTTPHeaderField: "HTTP-Referer")
urlRequest.addValue("oAI-Swift", forHTTPHeaderField: "X-Title")
urlRequest.httpBody = try JSONEncoder().encode(apiRequest)
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorResponse = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
Log.api.error("OpenRouter chat HTTP \(httpResponse.statusCode): \(errorResponse.error.message)")
throw ProviderError.unknown(errorResponse.error.message)
}
Log.api.error("OpenRouter chat HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
let apiResponse = try JSONDecoder().decode(OpenRouterChatResponse.self, from: data)
return try convertToChatResponse(apiResponse)
}
// MARK: - Chat with raw tool messages
/// Chat completion that accepts pre-encoded messages (for the tool call loop where
/// message shapes vary: user, assistant+tool_calls, tool results).
func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse {
let url = URL(string: "\(baseURL)/chat/completions")!
var body: [String: Any] = [
"model": model,
"messages": messages,
"stream": false
]
if let tools = tools {
let toolsData = try JSONEncoder().encode(tools)
body["tools"] = try JSONSerialization.jsonObject(with: toolsData)
body["tool_choice"] = "auto"
}
if let maxTokens = maxTokens { body["max_tokens"] = maxTokens }
if let temperature = temperature { body["temperature"] = temperature }
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.addValue("https://github.com/yourusername/oAI", forHTTPHeaderField: "HTTP-Referer")
urlRequest.addValue("oAI-Swift", forHTTPHeaderField: "X-Title")
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorResponse = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
Log.api.error("OpenRouter tool chat HTTP \(httpResponse.statusCode): \(errorResponse.error.message)")
throw ProviderError.unknown(errorResponse.error.message)
}
Log.api.error("OpenRouter tool chat HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
let apiResponse = try JSONDecoder().decode(OpenRouterChatResponse.self, from: data)
return try convertToChatResponse(apiResponse)
}
// MARK: - Streaming Chat
func streamChat(request: ChatRequest) -> AsyncThrowingStream<StreamChunk, Error> {
Log.api.info("OpenRouter stream request: model=\(request.model), messages=\(request.messages.count)")
return AsyncThrowingStream { continuation in
Task {
do {
var apiRequest = try buildAPIRequest(from: request)
apiRequest.stream = true
let url = URL(string: "\(baseURL)/chat/completions")!
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.addValue("text/event-stream", forHTTPHeaderField: "Accept")
urlRequest.addValue("https://github.com/yourusername/oAI", forHTTPHeaderField: "HTTP-Referer")
urlRequest.addValue("oAI-Swift", forHTTPHeaderField: "X-Title")
urlRequest.httpBody = try JSONEncoder().encode(apiRequest)
let (bytes, response) = try await session.bytes(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("OpenRouter stream: invalid response (not HTTP)")
continuation.finish(throwing: ProviderError.invalidResponse)
return
}
guard httpResponse.statusCode == 200 else {
Log.api.error("OpenRouter stream HTTP \(httpResponse.statusCode)")
continuation.finish(throwing: ProviderError.unknown("HTTP \(httpResponse.statusCode)"))
return
}
var buffer = ""
for try await line in bytes.lines {
if line.hasPrefix("data: ") {
let jsonString = String(line.dropFirst(6))
if jsonString == "[DONE]" {
continuation.finish()
return
}
buffer += jsonString
if let jsonData = buffer.data(using: .utf8) {
do {
let chunk = try JSONDecoder().decode(OpenRouterStreamChunk.self, from: jsonData)
let streamChunk = try convertToStreamChunk(chunk)
continuation.yield(streamChunk)
buffer = ""
} catch {
// Partial JSON, keep buffering
continue
}
}
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
// MARK: - Credits
func getCredits() async throws -> Credits? {
Log.api.info("Fetching OpenRouter credits")
let url = URL(string: "\(baseURL)/credits")!
var request = URLRequest(url: url)
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
return nil
}
let creditsResponse = try JSONDecoder().decode(OpenRouterCreditsResponse.self, from: data)
let totalCredits = creditsResponse.data.totalCredits ?? 0
let totalUsage = creditsResponse.data.totalUsage ?? 0
let remaining = totalCredits - totalUsage
return Credits(
balance: remaining,
currency: "USD",
usage: totalUsage,
limit: totalCredits
)
}
// MARK: - Helper Methods
private func buildAPIRequest(from request: ChatRequest) throws -> OpenRouterChatRequest {
let apiMessages = request.messages.map { message -> OpenRouterChatRequest.APIMessage in
let hasAttachments = message.attachments?.contains(where: { $0.data != nil }) ?? false
let content: OpenRouterChatRequest.APIMessage.MessageContent
if hasAttachments {
// Use array format for messages with attachments
var contentArray: [OpenRouterChatRequest.APIMessage.ContentItem] = []
// Add main text content
contentArray.append(.text(message.content))
// Add attachments
if let attachments = message.attachments {
for attachment in attachments {
guard let data = attachment.data else { continue }
switch attachment.type {
case .image, .pdf:
// Send as base64 data URL with correct MIME type
let base64String = data.base64EncodedString()
let dataURL = "data:\(attachment.mimeType);base64,\(base64String)"
let imageContent = OpenRouterChatRequest.APIMessage.ContentItem.ImageContent(
type: "image_url",
imageUrl: .init(url: dataURL)
)
contentArray.append(.image(imageContent))
case .text:
// Inline text file content
let filename = (attachment.path as NSString).lastPathComponent
let textContent = String(data: data, encoding: .utf8) ?? ""
contentArray.append(.text("File: \(filename)\n\n\(textContent)"))
}
}
}
content = .array(contentArray)
} else {
// Use simple string format for text-only messages
content = .string(message.content)
}
return OpenRouterChatRequest.APIMessage(
role: message.role.rawValue,
content: content
)
}
// Append :online suffix for web search when online mode is enabled
let effectiveModel: String
if request.onlineMode && !request.imageGeneration && !request.model.hasSuffix(":online") {
effectiveModel = request.model + ":online"
} else {
effectiveModel = request.model
}
return OpenRouterChatRequest(
model: effectiveModel,
messages: apiMessages,
stream: request.stream,
maxTokens: request.maxTokens,
temperature: request.temperature,
topP: request.topP,
tools: request.tools,
toolChoice: request.tools != nil ? "auto" : nil,
modalities: request.imageGeneration ? ["text", "image"] : nil
)
}
private func convertToChatResponse(_ apiResponse: OpenRouterChatResponse) throws -> ChatResponse {
guard let choice = apiResponse.choices.first else {
throw ProviderError.invalidResponse
}
let toolCalls: [ToolCallInfo]? = choice.message.toolCalls?.map { tc in
ToolCallInfo(id: tc.id, type: tc.type, functionName: tc.function.name, arguments: tc.function.arguments)
}
let images = choice.message.images.flatMap { decodeImageOutputs($0) }
return ChatResponse(
id: apiResponse.id,
model: apiResponse.model,
content: choice.message.content ?? "",
role: choice.message.role,
finishReason: choice.finishReason,
usage: apiResponse.usage.map { usage in
ChatResponse.Usage(
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
totalTokens: usage.totalTokens
)
},
created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)),
toolCalls: toolCalls,
generatedImages: images
)
}
private func convertToStreamChunk(_ apiChunk: OpenRouterStreamChunk) throws -> StreamChunk {
guard let choice = apiChunk.choices.first else {
throw ProviderError.invalidResponse
}
let images = choice.delta.images.flatMap { decodeImageOutputs($0) }
return StreamChunk(
id: apiChunk.id,
model: apiChunk.model,
delta: StreamChunk.Delta(
content: choice.delta.content,
role: choice.delta.role,
images: images
),
finishReason: choice.finishReason,
usage: apiChunk.usage.map { usage in
ChatResponse.Usage(
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
totalTokens: usage.totalTokens
)
}
)
}
/// Decode base64 data URL images from API response
private func decodeImageOutputs(_ outputs: [OpenRouterChatResponse.ImageOutput]) -> [Data]? {
let decoded = outputs.compactMap { output -> Data? in
let url = output.imageUrl.url
// Strip "data:image/...;base64," prefix
guard let commaIndex = url.firstIndex(of: ",") else { return nil }
let base64String = String(url[url.index(after: commaIndex)...])
return Data(base64Encoded: base64String)
}
return decoded.isEmpty ? nil : decoded
}
}

View File

@@ -0,0 +1,102 @@
//
// ProviderRegistry.swift
// oAI
//
// Registry for managing multiple AI providers
//
import Foundation
import os
class ProviderRegistry {
static let shared = ProviderRegistry()
private var providers: [Settings.Provider: AIProvider] = [:]
private let settings = SettingsService.shared
private init() {}
// MARK: - Get Provider
func getProvider(for providerType: Settings.Provider) -> AIProvider? {
// Return cached provider if exists
if let provider = providers[providerType] {
return provider
}
// Create new provider based on type
let provider: AIProvider?
switch providerType {
case .openrouter:
guard let apiKey = settings.openrouterAPIKey, !apiKey.isEmpty else {
Log.api.warning("No API key configured for OpenRouter")
return nil
}
provider = OpenRouterProvider(apiKey: apiKey)
case .anthropic:
if AnthropicOAuthService.shared.isAuthenticated {
// OAuth (Pro/Max subscription) takes precedence
provider = AnthropicProvider(oauth: true)
} else if let apiKey = settings.anthropicAPIKey, !apiKey.isEmpty {
provider = AnthropicProvider(apiKey: apiKey)
} else {
Log.api.warning("No API key or OAuth configured for Anthropic")
return nil
}
case .openai:
guard let apiKey = settings.openaiAPIKey, !apiKey.isEmpty else {
Log.api.warning("No API key configured for OpenAI")
return nil
}
provider = OpenAIProvider(apiKey: apiKey)
case .ollama:
provider = OllamaProvider(baseURL: settings.ollamaEffectiveURL)
}
// Cache and return
if let provider = provider {
Log.api.info("Created \(providerType.rawValue) provider")
providers[providerType] = provider
}
return provider
}
// MARK: - Current Provider
func getCurrentProvider() -> AIProvider? {
let currentProviderType = settings.defaultProvider
return getProvider(for: currentProviderType)
}
// MARK: - Clear Cache
func clearCache() {
providers.removeAll()
}
// MARK: - Validate API Key
func hasValidAPIKey(for providerType: Settings.Provider) -> Bool {
switch providerType {
case .openrouter:
return settings.openrouterAPIKey != nil && !settings.openrouterAPIKey!.isEmpty
case .anthropic:
return AnthropicOAuthService.shared.isAuthenticated
|| (settings.anthropicAPIKey != nil && !settings.anthropicAPIKey!.isEmpty)
case .openai:
return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty
case .ollama:
return settings.ollamaConfigured
}
}
/// Providers that have credentials configured (API key or, for Ollama, a saved URL)
var configuredProviders: [Settings.Provider] {
Settings.Provider.allCases.filter { hasValidAPIKey(for: $0) }
}
}

View File

@@ -0,0 +1,298 @@
//
// AnthropicOAuthService.swift
// oAI
//
// OAuth 2.0 PKCE flow for Anthropic Pro/Max subscription login
//
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: "")
}
}

View File

@@ -0,0 +1,318 @@
//
// DatabaseService.swift
// oAI
//
// SQLite persistence layer for conversations using GRDB
//
import Foundation
import GRDB
import os
// MARK: - Database Record Types
struct ConversationRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
static let databaseTableName = "conversations"
var id: String
var name: String
var createdAt: String
var updatedAt: String
}
struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
static let databaseTableName = "messages"
var id: String
var conversationId: String
var role: String
var content: String
var tokens: Int?
var cost: Double?
var timestamp: String
var sortOrder: Int
}
struct SettingRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
static let databaseTableName = "settings"
var key: String
var value: String
}
// MARK: - DatabaseService
final class DatabaseService: Sendable {
nonisolated static let shared = DatabaseService()
private let dbQueue: DatabaseQueue
private let isoFormatter: ISO8601DateFormatter
nonisolated private init() {
isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let fileManager = FileManager.default
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true)
try! fileManager.createDirectory(at: dbDirectory, withIntermediateDirectories: true)
let dbPath = dbDirectory.appendingPathComponent("oai_conversations.db").path
Log.db.info("Opening database at \(dbPath)")
dbQueue = try! DatabaseQueue(path: dbPath)
try! migrator.migrate(dbQueue)
}
private var migrator: DatabaseMigrator {
var migrator = DatabaseMigrator()
migrator.registerMigration("v1") { db in
try db.create(table: "conversations") { t in
t.primaryKey("id", .text)
t.column("name", .text).notNull()
t.column("createdAt", .text).notNull()
t.column("updatedAt", .text).notNull()
}
try db.create(table: "messages") { t in
t.primaryKey("id", .text)
t.column("conversationId", .text).notNull()
.references("conversations", onDelete: .cascade)
t.column("role", .text).notNull()
t.column("content", .text).notNull()
t.column("tokens", .integer)
t.column("cost", .double)
t.column("timestamp", .text).notNull()
t.column("sortOrder", .integer).notNull()
}
try db.create(
index: "messages_on_conversationId",
on: "messages",
columns: ["conversationId"]
)
}
migrator.registerMigration("v2") { db in
try db.create(table: "settings") { t in
t.primaryKey("key", .text)
t.column("value", .text).notNull()
}
}
return migrator
}
// MARK: - Settings Operations
nonisolated func loadAllSettings() throws -> [String: String] {
try dbQueue.read { db in
let records = try SettingRecord.fetchAll(db)
return Dictionary(uniqueKeysWithValues: records.map { ($0.key, $0.value) })
}
}
nonisolated func setSetting(key: String, value: String) {
try? dbQueue.write { db in
let record = SettingRecord(key: key, value: value)
try record.save(db)
}
}
nonisolated func deleteSetting(key: String) {
try? dbQueue.write { db in
_ = try SettingRecord.deleteOne(db, key: key)
}
}
// MARK: - Conversation Operations
nonisolated func saveConversation(name: String, messages: [Message]) throws -> Conversation {
Log.db.info("Saving conversation '\(name)' with \(messages.count) messages")
let convId = UUID()
let now = Date()
let nowString = isoFormatter.string(from: now)
let convRecord = ConversationRecord(
id: convId.uuidString,
name: name,
createdAt: nowString,
updatedAt: nowString
)
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
guard msg.role != .system else { return nil }
return MessageRecord(
id: UUID().uuidString,
conversationId: convId.uuidString,
role: msg.role.rawValue,
content: msg.content,
tokens: msg.tokens,
cost: msg.cost,
timestamp: isoFormatter.string(from: msg.timestamp),
sortOrder: index
)
}
try dbQueue.write { db in
try convRecord.insert(db)
for record in messageRecords {
try record.insert(db)
}
}
let savedMessages = messages.filter { $0.role != .system }
return Conversation(
id: convId,
name: name,
messages: savedMessages,
createdAt: now,
updatedAt: now
)
}
nonisolated func loadConversation(id: UUID) throws -> (Conversation, [Message])? {
try dbQueue.read { db in
guard let convRecord = try ConversationRecord.fetchOne(db, key: id.uuidString) else {
return nil
}
let messageRecords = try MessageRecord
.filter(Column("conversationId") == id.uuidString)
.order(Column("sortOrder"))
.fetchAll(db)
let messages = messageRecords.compactMap { record -> Message? in
guard let msgId = UUID(uuidString: record.id),
let role = MessageRole(rawValue: record.role),
let timestamp = self.isoFormatter.date(from: record.timestamp)
else { return nil }
return Message(
id: msgId,
role: role,
content: record.content,
tokens: record.tokens,
cost: record.cost,
timestamp: timestamp
)
}
guard let convId = UUID(uuidString: convRecord.id),
let createdAt = self.isoFormatter.date(from: convRecord.createdAt),
let updatedAt = self.isoFormatter.date(from: convRecord.updatedAt)
else { return nil }
let conversation = Conversation(
id: convId,
name: convRecord.name,
messages: messages,
createdAt: createdAt,
updatedAt: updatedAt
)
return (conversation, messages)
}
}
nonisolated func listConversations() throws -> [Conversation] {
try dbQueue.read { db in
let records = try ConversationRecord
.order(Column("updatedAt").desc)
.fetchAll(db)
return records.compactMap { record -> Conversation? in
guard let id = UUID(uuidString: record.id),
let createdAt = self.isoFormatter.date(from: record.createdAt),
let updatedAt = self.isoFormatter.date(from: record.updatedAt)
else { return nil }
// Fetch message count without loading all messages
let messageCount = (try? MessageRecord
.filter(Column("conversationId") == record.id)
.fetchCount(db)) ?? 0
// Get last message date
let lastMsg = try? MessageRecord
.filter(Column("conversationId") == record.id)
.order(Column("sortOrder").desc)
.fetchOne(db)
let lastDate = lastMsg.flatMap { self.isoFormatter.date(from: $0.timestamp) } ?? updatedAt
// Create conversation with empty messages array but correct metadata
var conv = Conversation(
id: id,
name: record.name,
messages: Array(repeating: Message(role: .user, content: ""), count: messageCount),
createdAt: createdAt,
updatedAt: lastDate
)
// We store placeholder messages just for the count; lastMessageDate uses updatedAt
conv.updatedAt = lastDate
return conv
}
}
}
nonisolated func deleteConversation(id: UUID) throws -> Bool {
Log.db.info("Deleting conversation \(id.uuidString)")
return try dbQueue.write { db in
try MessageRecord.filter(Column("conversationId") == id.uuidString).deleteAll(db)
return try ConversationRecord.deleteOne(db, key: id.uuidString)
}
}
nonisolated func deleteConversation(name: String) throws -> Bool {
try dbQueue.write { db in
guard let record = try ConversationRecord
.filter(Column("name") == name)
.fetchOne(db)
else { return false }
try MessageRecord.filter(Column("conversationId") == record.id).deleteAll(db)
return try ConversationRecord.deleteOne(db, key: record.id)
}
}
nonisolated func updateConversation(id: UUID, name: String?, messages: [Message]?) throws -> Bool {
try dbQueue.write { db in
guard var convRecord = try ConversationRecord.fetchOne(db, key: id.uuidString) else {
return false
}
if let name = name {
convRecord.name = name
}
convRecord.updatedAt = self.isoFormatter.string(from: Date())
try convRecord.update(db)
if let messages = messages {
try MessageRecord.filter(Column("conversationId") == id.uuidString).deleteAll(db)
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
guard msg.role != .system else { return nil }
return MessageRecord(
id: UUID().uuidString,
conversationId: id.uuidString,
role: msg.role.rawValue,
content: msg.content,
tokens: msg.tokens,
cost: msg.cost,
timestamp: self.isoFormatter.string(from: msg.timestamp),
sortOrder: index
)
}
for record in messageRecords {
try record.insert(db)
}
}
return true
}
}
}

View File

@@ -0,0 +1,840 @@
//
// MCPService.swift
// oAI
//
// MCP (Model Context Protocol) service for filesystem tool execution
//
import Foundation
import os
@Observable
class MCPService {
static let shared = MCPService()
private(set) var allowedFolders: [String] = []
private let settings = SettingsService.shared
private let fm = FileManager.default
private let maxFileSize = 10 * 1024 * 1024 // 10 MB
private let maxTextDisplay = 50 * 1024 // 50 KB before truncation
private let maxDirItems = 1000
private let maxSearchResults = 100
private let skipPatterns: Set<String> = [
".git", "node_modules", ".DS_Store", "__pycache__",
".build", ".swiftpm", "Pods", ".Trash", ".Spotlight-V100"
]
/// Cached gitignore rules per allowed folder
private var gitignoreRules: [String: GitignoreParser] = [:]
private init() {
allowedFolders = settings.mcpAllowedFolders
if settings.mcpRespectGitignore {
loadGitignores()
}
}
// MARK: - Folder Management
func addFolder(_ rawPath: String) -> String? {
let expanded = (rawPath as NSString).expandingTildeInPath
let resolved = (expanded as NSString).standardizingPath
var isDir: ObjCBool = false
guard fm.fileExists(atPath: resolved, isDirectory: &isDir), isDir.boolValue else {
return "Path is not a directory: \(rawPath)"
}
if allowedFolders.contains(resolved) {
return "Folder already added: \(resolved)"
}
allowedFolders.append(resolved)
settings.mcpAllowedFolders = allowedFolders
if settings.mcpRespectGitignore {
loadGitignoreForFolder(resolved)
}
return nil
}
func removeFolder(at index: Int) -> Bool {
guard index >= 0 && index < allowedFolders.count else { return false }
let removed = allowedFolders.remove(at: index)
settings.mcpAllowedFolders = allowedFolders
gitignoreRules.removeValue(forKey: removed)
return true
}
func removeFolder(path: String) -> Bool {
let resolved = ((path as NSString).expandingTildeInPath as NSString).standardizingPath
if let index = allowedFolders.firstIndex(of: resolved) {
allowedFolders.remove(at: index)
settings.mcpAllowedFolders = allowedFolders
gitignoreRules.removeValue(forKey: resolved)
return true
}
return false
}
func isPathAllowed(_ path: String) -> Bool {
let resolved = ((path as NSString).expandingTildeInPath as NSString).standardizingPath
return allowedFolders.contains { resolved.hasPrefix($0) }
}
// MARK: - Permission Helpers
var canWriteFiles: Bool { settings.mcpCanWriteFiles }
var canDeleteFiles: Bool { settings.mcpCanDeleteFiles }
var canCreateDirectories: Bool { settings.mcpCanCreateDirectories }
var canMoveFiles: Bool { settings.mcpCanMoveFiles }
var respectGitignore: Bool { settings.mcpRespectGitignore }
// MARK: - Tool Schema Generation
func getToolSchemas() -> [Tool] {
var tools: [Tool] = [
makeTool(
name: "read_file",
description: "Read the contents of a file. Returns the text content of the file. Maximum file size is 10MB.",
properties: [
"file_path": prop("string", "The absolute path to the file to read")
],
required: ["file_path"]
),
makeTool(
name: "list_directory",
description: "List the contents of a directory. Returns file and directory names. Skips hidden/build directories like .git, node_modules, etc.",
properties: [
"dir_path": prop("string", "The absolute path to the directory to list"),
"recursive": prop("boolean", "Whether to list recursively (default: false)")
],
required: ["dir_path"]
),
makeTool(
name: "search_files",
description: "Search for files by name pattern or content. Use 'pattern' for filename glob matching (e.g. '*.swift'). Use 'content_search' for searching inside file contents.",
properties: [
"pattern": prop("string", "Glob pattern to match filenames (e.g. '*.py', 'README*')"),
"search_path": prop("string", "Directory to search in (defaults to first allowed folder)"),
"content_search": prop("string", "Optional text to search for inside files")
],
required: ["pattern"]
)
]
if canWriteFiles {
tools.append(makeTool(
name: "write_file",
description: "Create or overwrite a file with the given content. Parent directories are created automatically.",
properties: [
"file_path": prop("string", "The absolute path to the file to write"),
"content": prop("string", "The text content to write to the file")
],
required: ["file_path", "content"]
))
tools.append(makeTool(
name: "edit_file",
description: "Find and replace text in a file. The old_text must appear exactly once in the file.",
properties: [
"file_path": prop("string", "The absolute path to the file to edit"),
"old_text": prop("string", "The exact text to find (must be a unique match)"),
"new_text": prop("string", "The replacement text")
],
required: ["file_path", "old_text", "new_text"]
))
}
if canDeleteFiles {
tools.append(makeTool(
name: "delete_file",
description: "Delete a file at the given path.",
properties: [
"file_path": prop("string", "The absolute path to the file to delete")
],
required: ["file_path"]
))
}
if canCreateDirectories {
tools.append(makeTool(
name: "create_directory",
description: "Create a directory (and any intermediate directories) at the given path.",
properties: [
"dir_path": prop("string", "The absolute path to the directory to create")
],
required: ["dir_path"]
))
}
if canMoveFiles {
tools.append(makeTool(
name: "move_file",
description: "Move or rename a file or directory.",
properties: [
"source": prop("string", "The absolute path of the file/directory to move"),
"destination": prop("string", "The absolute destination path")
],
required: ["source", "destination"]
))
tools.append(makeTool(
name: "copy_file",
description: "Copy a file or directory to a new location.",
properties: [
"source": prop("string", "The absolute path of the file/directory to copy"),
"destination": prop("string", "The absolute destination path for the copy")
],
required: ["source", "destination"]
))
}
return tools
}
private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
Tool(
type: "function",
function: Tool.Function(
name: name,
description: description,
parameters: Tool.Function.Parameters(
type: "object",
properties: properties,
required: required
)
)
)
}
private func prop(_ type: String, _ description: String) -> Tool.Function.Parameters.Property {
Tool.Function.Parameters.Property(type: type, description: description, enum: nil)
}
// MARK: - Tool Execution
func executeTool(name: String, arguments: String) -> [String: Any] {
Log.mcp.info("Executing tool: \(name)")
guard let argData = arguments.data(using: .utf8),
let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
Log.mcp.error("Invalid arguments JSON for tool \(name)")
return ["error": "Invalid arguments JSON"]
}
switch name {
case "read_file":
guard let filePath = args["file_path"] as? String else {
return ["error": "Missing required parameter: file_path"]
}
return readFile(filePath: filePath)
case "list_directory":
guard let dirPath = args["dir_path"] as? String else {
return ["error": "Missing required parameter: dir_path"]
}
let recursive = args["recursive"] as? Bool ?? false
return listDirectory(dirPath: dirPath, recursive: recursive)
case "search_files":
guard let pattern = args["pattern"] as? String else {
return ["error": "Missing required parameter: pattern"]
}
let searchPath = args["search_path"] as? String
let contentSearch = args["content_search"] as? String
return searchFiles(pattern: pattern, searchPath: searchPath, contentSearch: contentSearch)
case "write_file":
guard canWriteFiles else {
return ["error": "Permission denied: write_file is not enabled. Enable 'Write & Edit Files' in Settings > MCP."]
}
guard let filePath = args["file_path"] as? String,
let content = args["content"] as? String else {
return ["error": "Missing required parameters: file_path, content"]
}
return writeFile(filePath: filePath, content: content)
case "edit_file":
guard canWriteFiles else {
return ["error": "Permission denied: edit_file is not enabled. Enable 'Write & Edit Files' in Settings > MCP."]
}
guard let filePath = args["file_path"] as? String,
let oldText = args["old_text"] as? String,
let newText = args["new_text"] as? String else {
return ["error": "Missing required parameters: file_path, old_text, new_text"]
}
return editFile(filePath: filePath, oldText: oldText, newText: newText)
case "delete_file":
guard canDeleteFiles else {
return ["error": "Permission denied: delete_file is not enabled. Enable 'Delete Files' in Settings > MCP."]
}
guard let filePath = args["file_path"] as? String else {
return ["error": "Missing required parameter: file_path"]
}
return deleteFile(filePath: filePath)
case "create_directory":
guard canCreateDirectories else {
return ["error": "Permission denied: create_directory is not enabled. Enable 'Create Directories' in Settings > MCP."]
}
guard let dirPath = args["dir_path"] as? String else {
return ["error": "Missing required parameter: dir_path"]
}
return createDirectory(dirPath: dirPath)
case "move_file":
guard canMoveFiles else {
return ["error": "Permission denied: move_file is not enabled. Enable 'Move & Copy Files' in Settings > MCP."]
}
guard let source = args["source"] as? String,
let destination = args["destination"] as? String else {
return ["error": "Missing required parameters: source, destination"]
}
return moveFile(source: source, destination: destination)
case "copy_file":
guard canMoveFiles else {
return ["error": "Permission denied: copy_file is not enabled. Enable 'Move & Copy Files' in Settings > MCP."]
}
guard let source = args["source"] as? String,
let destination = args["destination"] as? String else {
return ["error": "Missing required parameters: source, destination"]
}
return copyFile(source: source, destination: destination)
default:
return ["error": "Unknown tool: \(name)"]
}
}
// MARK: - Read Tool Implementations
private func readFile(filePath: String) -> [String: Any] {
let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
Log.mcp.warning("Read denied: path outside allowed folders: \(resolved)")
return ["error": "Access denied: path is outside allowed folders"]
}
guard fm.fileExists(atPath: resolved) else {
return ["error": "File not found: \(filePath)"]
}
guard let attrs = try? fm.attributesOfItem(atPath: resolved),
let fileSize = attrs[.size] as? Int else {
return ["error": "Cannot read file attributes: \(filePath)"]
}
if fileSize > maxFileSize {
let sizeMB = String(format: "%.1f", Double(fileSize) / 1_000_000)
return ["error": "File too large (\(sizeMB) MB, max 10 MB)"]
}
guard let content = try? String(contentsOfFile: resolved, encoding: .utf8) else {
return ["error": "Cannot read file as UTF-8 text: \(filePath)"]
}
var finalContent = content
if content.utf8.count > maxTextDisplay {
let lines = content.components(separatedBy: "\n")
if lines.count > 600 {
let head = lines.prefix(500).joined(separator: "\n")
let tail = lines.suffix(100).joined(separator: "\n")
let omitted = lines.count - 600
finalContent = head + "\n\n... [\(omitted) lines omitted] ...\n\n" + tail
}
}
return ["content": finalContent, "path": resolved, "size": fileSize]
}
private func listDirectory(dirPath: String, recursive: Bool) -> [String: Any] {
let resolved = ((dirPath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
return ["error": "Access denied: path is outside allowed folders"]
}
var isDir: ObjCBool = false
guard fm.fileExists(atPath: resolved, isDirectory: &isDir), isDir.boolValue else {
return ["error": "Directory not found: \(dirPath)"]
}
var items: [String] = []
if recursive {
guard let enumerator = fm.enumerator(atPath: resolved) else {
return ["error": "Cannot enumerate directory"]
}
while let item = enumerator.nextObject() as? String {
let components = item.components(separatedBy: "/")
if components.contains(where: { skipPatterns.contains($0) }) {
enumerator.skipDescendants()
continue
}
let fullPath = (resolved as NSString).appendingPathComponent(item)
if respectGitignore && isGitignored(fullPath) {
// Skip gitignored directories entirely
var itemIsDir: ObjCBool = false
if fm.fileExists(atPath: fullPath, isDirectory: &itemIsDir), itemIsDir.boolValue {
enumerator.skipDescendants()
}
continue
}
items.append(item)
if items.count >= maxDirItems { break }
}
} else {
guard let contents = try? fm.contentsOfDirectory(atPath: resolved) else {
return ["error": "Cannot list directory"]
}
items = contents.filter { !skipPatterns.contains($0) }
.filter { entry in
if respectGitignore {
let fullPath = (resolved as NSString).appendingPathComponent(entry)
return !isGitignored(fullPath)
}
return true
}
.prefix(maxDirItems).map { entry in
var entryIsDir: ObjCBool = false
let fullPath = (resolved as NSString).appendingPathComponent(entry)
fm.fileExists(atPath: fullPath, isDirectory: &entryIsDir)
return entryIsDir.boolValue ? "\(entry)/" : entry
}
}
let truncated = items.count >= maxDirItems
return ["items": items, "count": items.count, "truncated": truncated, "path": resolved]
}
private func searchFiles(pattern: String, searchPath: String?, contentSearch: String?) -> [String: Any] {
let basePath: String
if let sp = searchPath {
basePath = ((sp as NSString).expandingTildeInPath as NSString).standardizingPath
} else if let first = allowedFolders.first {
basePath = first
} else {
return ["error": "No search path specified and no allowed folders configured"]
}
guard isPathAllowed(basePath) else {
return ["error": "Access denied: search path is outside allowed folders"]
}
guard let enumerator = fm.enumerator(atPath: basePath) else {
return ["error": "Cannot enumerate directory: \(basePath)"]
}
var results: [String] = []
while let item = enumerator.nextObject() as? String {
let components = item.components(separatedBy: "/")
if components.contains(where: { skipPatterns.contains($0) }) {
enumerator.skipDescendants()
continue
}
let fullPath = (basePath as NSString).appendingPathComponent(item)
if respectGitignore && isGitignored(fullPath) {
var itemIsDir: ObjCBool = false
if fm.fileExists(atPath: fullPath, isDirectory: &itemIsDir), itemIsDir.boolValue {
enumerator.skipDescendants()
}
continue
}
let filename = (item as NSString).lastPathComponent
// Filename pattern match
if fnmatch(pattern, filename, 0) != 0 {
continue
}
// Content search if requested
if let searchText = contentSearch, !searchText.isEmpty {
guard let content = try? String(contentsOfFile: fullPath, encoding: .utf8) else {
continue
}
if !content.localizedCaseInsensitiveContains(searchText) {
continue
}
}
results.append(item)
if results.count >= maxSearchResults { break }
}
let truncated = results.count >= maxSearchResults
return ["matches": results, "count": results.count, "truncated": truncated, "base_path": basePath]
}
// MARK: - Write Tool Implementations
private func writeFile(filePath: String, content: String) -> [String: Any] {
let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
Log.mcp.warning("Write denied: path outside allowed folders: \(resolved)")
return ["error": "Access denied: path is outside allowed folders"]
}
// Create parent directories if needed
let parentDir = (resolved as NSString).deletingLastPathComponent
if !fm.fileExists(atPath: parentDir) {
do {
try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
} catch {
return ["error": "Cannot create parent directories: \(error.localizedDescription)"]
}
}
do {
try content.write(toFile: resolved, atomically: true, encoding: .utf8)
} catch {
Log.mcp.error("Failed to write file \(resolved): \(error.localizedDescription)")
return ["error": "Cannot write file: \(error.localizedDescription)"]
}
Log.mcp.info("Wrote \(content.utf8.count) bytes to \(resolved)")
return ["success": true, "path": resolved, "bytes_written": content.utf8.count]
}
private func editFile(filePath: String, oldText: String, newText: String) -> [String: Any] {
let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
Log.mcp.warning("Edit denied: path outside allowed folders: \(resolved)")
return ["error": "Access denied: path is outside allowed folders"]
}
guard fm.fileExists(atPath: resolved) else {
return ["error": "File not found: \(filePath)"]
}
guard let content = try? String(contentsOfFile: resolved, encoding: .utf8) else {
return ["error": "Cannot read file as UTF-8 text: \(filePath)"]
}
// Count occurrences
let occurrences = content.components(separatedBy: oldText).count - 1
if occurrences == 0 {
return ["error": "old_text not found in file"]
}
if occurrences > 1 {
return ["error": "old_text appears \(occurrences) times in the file — it must be unique (exactly 1 match). Provide more surrounding context to make it unique."]
}
let newContent = content.replacingOccurrences(of: oldText, with: newText)
do {
try newContent.write(toFile: resolved, atomically: true, encoding: .utf8)
} catch {
return ["error": "Cannot write file: \(error.localizedDescription)"]
}
return ["success": true, "path": resolved]
}
private func deleteFile(filePath: String) -> [String: Any] {
let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
Log.mcp.warning("Delete denied: path outside allowed folders: \(resolved)")
return ["error": "Access denied: path is outside allowed folders"]
}
guard fm.fileExists(atPath: resolved) else {
return ["error": "File not found: \(filePath)"]
}
do {
try fm.removeItem(atPath: resolved)
} catch {
Log.mcp.error("Failed to delete \(resolved): \(error.localizedDescription)")
return ["error": "Cannot delete file: \(error.localizedDescription)"]
}
Log.mcp.info("Deleted \(resolved)")
return ["success": true, "path": resolved]
}
private func createDirectory(dirPath: String) -> [String: Any] {
let resolved = ((dirPath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
return ["error": "Access denied: path is outside allowed folders"]
}
do {
try fm.createDirectory(atPath: resolved, withIntermediateDirectories: true)
} catch {
return ["error": "Cannot create directory: \(error.localizedDescription)"]
}
return ["success": true, "path": resolved]
}
private func moveFile(source: String, destination: String) -> [String: Any] {
let resolvedSrc = ((source as NSString).expandingTildeInPath as NSString).standardizingPath
let resolvedDst = ((destination as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolvedSrc) else {
return ["error": "Access denied: source path is outside allowed folders"]
}
guard isPathAllowed(resolvedDst) else {
return ["error": "Access denied: destination path is outside allowed folders"]
}
guard fm.fileExists(atPath: resolvedSrc) else {
return ["error": "Source not found: \(source)"]
}
// Create parent directory of destination if needed
let parentDir = (resolvedDst as NSString).deletingLastPathComponent
if !fm.fileExists(atPath: parentDir) {
do {
try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
} catch {
return ["error": "Cannot create destination directory: \(error.localizedDescription)"]
}
}
do {
try fm.moveItem(atPath: resolvedSrc, toPath: resolvedDst)
} catch {
return ["error": "Cannot move file: \(error.localizedDescription)"]
}
return ["success": true, "source": resolvedSrc, "destination": resolvedDst]
}
private func copyFile(source: String, destination: String) -> [String: Any] {
let resolvedSrc = ((source as NSString).expandingTildeInPath as NSString).standardizingPath
let resolvedDst = ((destination as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolvedSrc) else {
return ["error": "Access denied: source path is outside allowed folders"]
}
guard isPathAllowed(resolvedDst) else {
return ["error": "Access denied: destination path is outside allowed folders"]
}
guard fm.fileExists(atPath: resolvedSrc) else {
return ["error": "Source not found: \(source)"]
}
// Create parent directory of destination if needed
let parentDir = (resolvedDst as NSString).deletingLastPathComponent
if !fm.fileExists(atPath: parentDir) {
do {
try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
} catch {
return ["error": "Cannot create destination directory: \(error.localizedDescription)"]
}
}
do {
try fm.copyItem(atPath: resolvedSrc, toPath: resolvedDst)
} catch {
return ["error": "Cannot copy file: \(error.localizedDescription)"]
}
return ["success": true, "source": resolvedSrc, "destination": resolvedDst]
}
// MARK: - Gitignore Support
/// Reload gitignore rules for all allowed folders
func reloadGitignores() {
gitignoreRules.removeAll()
if settings.mcpRespectGitignore {
loadGitignores()
}
}
private func loadGitignores() {
for folder in allowedFolders {
loadGitignoreForFolder(folder)
}
}
private func loadGitignoreForFolder(_ folder: String) {
var parser = GitignoreParser(rootPath: folder)
parser.loadRules(fileManager: fm)
gitignoreRules[folder] = parser
}
/// Check if an absolute path is gitignored by any loaded gitignore rules
func isGitignored(_ absolutePath: String) -> Bool {
guard settings.mcpRespectGitignore else { return false }
for (folder, parser) in gitignoreRules {
if absolutePath.hasPrefix(folder) {
let relativePath = String(absolutePath.dropFirst(folder.count).drop(while: { $0 == "/" }))
if !relativePath.isEmpty && parser.isIgnored(relativePath) {
return true
}
}
}
return false
}
}
// MARK: - GitignoreParser
/// Parses .gitignore files and checks paths against the patterns.
/// Supports: wildcards (*), double wildcards (**), directory patterns (/), negation (!), comments (#).
struct GitignoreParser {
let rootPath: String
private var rules: [GitignoreRule] = []
struct GitignoreRule {
let pattern: String
let isNegation: Bool
let isDirectoryOnly: Bool
/// Regex compiled from the gitignore glob pattern
let regex: NSRegularExpression?
}
init(rootPath: String) {
self.rootPath = rootPath
}
/// Load .gitignore from the root path (non-recursive only the root .gitignore)
mutating func loadRules(fileManager fm: FileManager) {
let gitignorePath = (rootPath as NSString).appendingPathComponent(".gitignore")
guard let content = try? String(contentsOfFile: gitignorePath, encoding: .utf8) else {
return
}
parseContent(content)
}
mutating func parseContent(_ content: String) {
let lines = content.components(separatedBy: .newlines)
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
// Skip empty lines and comments
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
var pattern = trimmed
let isNegation = pattern.hasPrefix("!")
if isNegation {
pattern = String(pattern.dropFirst())
}
// Remove trailing spaces (unless escaped)
while pattern.hasSuffix(" ") && !pattern.hasSuffix("\\ ") {
pattern = String(pattern.dropLast())
}
let isDirectoryOnly = pattern.hasSuffix("/")
if isDirectoryOnly {
pattern = String(pattern.dropLast())
}
// Remove leading slash (anchors to root, but we match relative paths)
if pattern.hasPrefix("/") {
pattern = String(pattern.dropFirst())
}
guard !pattern.isEmpty else { continue }
let regex = gitignorePatternToRegex(pattern)
rules.append(GitignoreRule(
pattern: pattern,
isNegation: isNegation,
isDirectoryOnly: isDirectoryOnly,
regex: regex
))
}
}
/// Check if a relative path (from rootPath) is ignored
func isIgnored(_ relativePath: String) -> Bool {
var ignored = false
for rule in rules {
let matches: Bool
if let regex = rule.regex {
let range = NSRange(relativePath.startIndex..., in: relativePath)
matches = regex.firstMatch(in: relativePath, range: range) != nil
} else {
// Fallback: simple contains check for the pattern basename
let basename = (relativePath as NSString).lastPathComponent
matches = basename == rule.pattern
}
if matches {
if rule.isNegation {
ignored = false
} else {
ignored = true
}
}
}
return ignored
}
/// Convert a gitignore glob pattern to a regex
private func gitignorePatternToRegex(_ pattern: String) -> NSRegularExpression? {
var regex = ""
let chars = Array(pattern)
var i = 0
// If the pattern contains no slash, it matches against the filename only
let matchesPath = pattern.contains("/")
if !matchesPath {
// Match against any path component the pattern can appear as the last component
regex += "(?:^|/)"
} else {
regex += "^"
}
while i < chars.count {
let c = chars[i]
switch c {
case "*":
if i + 1 < chars.count && chars[i + 1] == "*" {
// **
if i + 2 < chars.count && chars[i + 2] == "/" {
// **/ matches zero or more directories
regex += "(?:.+/)?"
i += 3
continue
} else {
// ** at end matches everything
regex += ".*"
i += 2
continue
}
} else {
// Single * matches anything except /
regex += "[^/]*"
}
case "?":
regex += "[^/]"
case ".":
regex += "\\."
case "[":
// Character class pass through
regex += "["
case "]":
regex += "]"
case "\\":
// Escape next character
if i + 1 < chars.count {
i += 1
regex += NSRegularExpression.escapedPattern(for: String(chars[i]))
}
default:
regex += NSRegularExpression.escapedPattern(for: String(c))
}
i += 1
}
// Allow matching as a prefix (directory) or exact match
regex += "(?:/.*)?$"
return try? NSRegularExpression(pattern: regex, options: [])
}
}

View File

@@ -0,0 +1,408 @@
//
// SettingsService.swift
// oAI
//
// Settings persistence: SQLite for preferences, Keychain for API keys
//
import Foundation
import os
import Security
@Observable
class SettingsService {
static let shared = SettingsService()
// In-memory cache of DB settings for fast reads
private var cache: [String: String] = [:]
// Keychain keys (secrets only)
private enum KeychainKeys {
static let openrouterAPIKey = "com.oai.apikey.openrouter"
static let anthropicAPIKey = "com.oai.apikey.anthropic"
static let openaiAPIKey = "com.oai.apikey.openai"
static let googleAPIKey = "com.oai.apikey.google"
static let googleSearchEngineID = "com.oai.google.searchEngineID"
}
private init() {
// Load all settings from DB into cache
cache = (try? DatabaseService.shared.loadAllSettings()) ?? [:]
Log.settings.info("Settings initialized with \(self.cache.count) cached entries")
// Migrate from UserDefaults on first launch
migrateFromUserDefaultsIfNeeded()
}
// MARK: - Provider Settings
var defaultProvider: Settings.Provider {
get {
if let raw = cache["defaultProvider"],
let provider = Settings.Provider(rawValue: raw) {
return provider
}
return .openrouter
}
set {
cache["defaultProvider"] = newValue.rawValue
DatabaseService.shared.setSetting(key: "defaultProvider", value: newValue.rawValue)
}
}
var defaultModel: String? {
get { cache["defaultModel"] }
set {
if let value = newValue {
cache["defaultModel"] = value
DatabaseService.shared.setSetting(key: "defaultModel", value: value)
} else {
cache.removeValue(forKey: "defaultModel")
DatabaseService.shared.deleteSetting(key: "defaultModel")
}
}
}
// MARK: - Model Settings
var streamEnabled: Bool {
get { cache["streamEnabled"].map { $0 == "true" } ?? true }
set {
cache["streamEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "streamEnabled", value: String(newValue))
}
}
var maxTokens: Int {
get { cache["maxTokens"].flatMap(Int.init) ?? 0 }
set {
cache["maxTokens"] = String(newValue)
DatabaseService.shared.setSetting(key: "maxTokens", value: String(newValue))
}
}
var temperature: Double {
get { cache["temperature"].flatMap(Double.init) ?? 0.0 }
set {
cache["temperature"] = String(newValue)
DatabaseService.shared.setSetting(key: "temperature", value: String(newValue))
}
}
// MARK: - Feature Settings
var onlineMode: Bool {
get { cache["onlineMode"] == "true" }
set {
cache["onlineMode"] = String(newValue)
DatabaseService.shared.setSetting(key: "onlineMode", value: String(newValue))
}
}
var memoryEnabled: Bool {
get { cache["memoryEnabled"].map { $0 == "true" } ?? true }
set {
cache["memoryEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "memoryEnabled", value: String(newValue))
}
}
var mcpEnabled: Bool {
get { cache["mcpEnabled"] == "true" }
set {
cache["mcpEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "mcpEnabled", value: String(newValue))
}
}
// MARK: - Text Size Settings
/// GUI text size (headers, labels, buttons) default 13
var guiTextSize: Double {
get { cache["guiTextSize"].flatMap(Double.init) ?? 13.0 }
set {
cache["guiTextSize"] = String(newValue)
DatabaseService.shared.setSetting(key: "guiTextSize", value: String(newValue))
}
}
/// Dialog/chat message text size default 14
var dialogTextSize: Double {
get { cache["dialogTextSize"].flatMap(Double.init) ?? 14.0 }
set {
cache["dialogTextSize"] = String(newValue)
DatabaseService.shared.setSetting(key: "dialogTextSize", value: String(newValue))
}
}
/// Input box text size default 14
var inputTextSize: Double {
get { cache["inputTextSize"].flatMap(Double.init) ?? 14.0 }
set {
cache["inputTextSize"] = String(newValue)
DatabaseService.shared.setSetting(key: "inputTextSize", value: String(newValue))
}
}
// MARK: - MCP Permissions
var mcpCanWriteFiles: Bool {
get { cache["mcpCanWriteFiles"] == "true" }
set {
cache["mcpCanWriteFiles"] = String(newValue)
DatabaseService.shared.setSetting(key: "mcpCanWriteFiles", value: String(newValue))
}
}
var mcpCanDeleteFiles: Bool {
get { cache["mcpCanDeleteFiles"] == "true" }
set {
cache["mcpCanDeleteFiles"] = String(newValue)
DatabaseService.shared.setSetting(key: "mcpCanDeleteFiles", value: String(newValue))
}
}
var mcpCanCreateDirectories: Bool {
get { cache["mcpCanCreateDirectories"] == "true" }
set {
cache["mcpCanCreateDirectories"] = String(newValue)
DatabaseService.shared.setSetting(key: "mcpCanCreateDirectories", value: String(newValue))
}
}
var mcpCanMoveFiles: Bool {
get { cache["mcpCanMoveFiles"] == "true" }
set {
cache["mcpCanMoveFiles"] = String(newValue)
DatabaseService.shared.setSetting(key: "mcpCanMoveFiles", value: String(newValue))
}
}
var mcpRespectGitignore: Bool {
get { cache["mcpRespectGitignore"].map { $0 == "true" } ?? true }
set {
cache["mcpRespectGitignore"] = String(newValue)
DatabaseService.shared.setSetting(key: "mcpRespectGitignore", value: String(newValue))
}
}
// MARK: - MCP Allowed Folders
var mcpAllowedFolders: [String] {
get {
guard let json = cache["mcpAllowedFolders"],
let data = json.data(using: .utf8),
let folders = try? JSONDecoder().decode([String].self, from: data) else {
return []
}
return folders
}
set {
if let data = try? JSONEncoder().encode(newValue),
let json = String(data: data, encoding: .utf8) {
cache["mcpAllowedFolders"] = json
DatabaseService.shared.setSetting(key: "mcpAllowedFolders", value: json)
}
}
}
// MARK: - Search Settings
var searchProvider: Settings.SearchProvider {
get {
if let raw = cache["searchProvider"],
let provider = Settings.SearchProvider(rawValue: raw) {
return provider
}
return .duckduckgo
}
set {
cache["searchProvider"] = newValue.rawValue
DatabaseService.shared.setSetting(key: "searchProvider", value: newValue.rawValue)
}
}
// MARK: - Ollama Settings
var ollamaBaseURL: String {
get { cache["ollamaBaseURL"] ?? "" }
set {
let trimmed = newValue.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty {
cache.removeValue(forKey: "ollamaBaseURL")
DatabaseService.shared.deleteSetting(key: "ollamaBaseURL")
} else {
cache["ollamaBaseURL"] = trimmed
DatabaseService.shared.setSetting(key: "ollamaBaseURL", value: trimmed)
}
}
}
/// Resolved Ollama URL returns the user value or the default
var ollamaEffectiveURL: String {
let url = ollamaBaseURL
return url.isEmpty ? "http://localhost:11434" : url
}
/// Whether the user has explicitly configured an Ollama URL
var ollamaConfigured: Bool {
cache["ollamaBaseURL"] != nil && !(cache["ollamaBaseURL"]!.isEmpty)
}
// MARK: - API Keys (Keychain)
var openrouterAPIKey: String? {
get { getKeychainValue(for: KeychainKeys.openrouterAPIKey) }
set {
if let value = newValue {
setKeychainValue(value, for: KeychainKeys.openrouterAPIKey)
} else {
deleteKeychainValue(for: KeychainKeys.openrouterAPIKey)
}
}
}
var anthropicAPIKey: String? {
get { getKeychainValue(for: KeychainKeys.anthropicAPIKey) }
set {
if let value = newValue {
setKeychainValue(value, for: KeychainKeys.anthropicAPIKey)
} else {
deleteKeychainValue(for: KeychainKeys.anthropicAPIKey)
}
}
}
var openaiAPIKey: String? {
get { getKeychainValue(for: KeychainKeys.openaiAPIKey) }
set {
if let value = newValue {
setKeychainValue(value, for: KeychainKeys.openaiAPIKey)
} else {
deleteKeychainValue(for: KeychainKeys.openaiAPIKey)
}
}
}
var googleAPIKey: String? {
get { getKeychainValue(for: KeychainKeys.googleAPIKey) }
set {
if let value = newValue {
setKeychainValue(value, for: KeychainKeys.googleAPIKey)
} else {
deleteKeychainValue(for: KeychainKeys.googleAPIKey)
}
}
}
var googleSearchEngineID: String? {
get { getKeychainValue(for: KeychainKeys.googleSearchEngineID) }
set {
if let value = newValue {
setKeychainValue(value, for: KeychainKeys.googleSearchEngineID)
} else {
deleteKeychainValue(for: KeychainKeys.googleSearchEngineID)
}
}
}
// MARK: - UserDefaults Migration
private func migrateFromUserDefaultsIfNeeded() {
// Skip if already migrated
guard cache["_migrated"] == nil else { return }
let defaults = UserDefaults.standard
let migrations: [(udKey: String, dbKey: String)] = [
("defaultProvider", "defaultProvider"),
("defaultModel", "defaultModel"),
("streamEnabled", "streamEnabled"),
("maxTokens", "maxTokens"),
("temperature", "temperature"),
("onlineMode", "onlineMode"),
("memoryEnabled", "memoryEnabled"),
("mcpEnabled", "mcpEnabled"),
("searchProvider", "searchProvider"),
("ollamaBaseURL", "ollamaBaseURL"),
]
for (udKey, dbKey) in migrations {
guard cache[dbKey] == nil else { continue }
if let stringVal = defaults.string(forKey: udKey) {
cache[dbKey] = stringVal
DatabaseService.shared.setSetting(key: dbKey, value: stringVal)
} else if defaults.object(forKey: udKey) != nil {
// Handle bool/int/double stored as non-string
let value: String
if let boolVal = defaults.object(forKey: udKey) as? Bool {
value = String(boolVal)
} else if defaults.integer(forKey: udKey) != 0 {
value = String(defaults.integer(forKey: udKey))
} else if defaults.double(forKey: udKey) != 0.0 {
value = String(defaults.double(forKey: udKey))
} else {
continue
}
cache[dbKey] = value
DatabaseService.shared.setSetting(key: dbKey, value: value)
}
}
// Mark migration complete
cache["_migrated"] = "true"
DatabaseService.shared.setSetting(key: "_migrated", value: "true")
}
// 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 dataTypeRef: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
guard status == errSecSuccess,
let data = dataTypeRef 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 attributes: [String: Any] = [
kSecValueData as String: data
]
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if updateStatus == 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)
}
}

View File

@@ -0,0 +1,143 @@
//
// WebSearchService.swift
// oAI
//
// DuckDuckGo web search for non-OpenRouter providers
//
import Foundation
import os
struct SearchResult: Sendable {
let title: String
let url: String
let snippet: String
}
final class WebSearchService: Sendable {
nonisolated static let shared = WebSearchService()
private let session: URLSession
nonisolated private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 10
session = URLSession(configuration: config)
}
/// Search DuckDuckGo HTML interface (no API key needed)
nonisolated func search(query: String, maxResults: Int = 5) async -> [SearchResult] {
Log.search.info("Web search: \(query)")
guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: "https://html.duckduckgo.com/html/?q=\(encoded)")
else { return [] }
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
forHTTPHeaderField: "User-Agent"
)
do {
let (data, _) = try await session.data(for: request)
guard let html = String(data: data, encoding: .utf8) else { return [] }
return parseResults(from: html, maxResults: maxResults)
} catch {
Log.search.error("Web search failed: \(error.localizedDescription)")
return []
}
}
/// Format search results as markdown for prompt injection
nonisolated func formatResults(_ results: [SearchResult], maxLength: Int = 2000) -> String {
if results.isEmpty { return "No search results found." }
var formatted = "**Web Search Results:**\n\n"
for (i, result) in results.enumerated() {
var entry = "\(i + 1). **\(result.title)**\n"
entry += " URL: \(result.url)\n"
if !result.snippet.isEmpty {
entry += " \(result.snippet)\n"
}
entry += "\n"
if formatted.count + entry.count > maxLength {
formatted += "... (\(results.count - i) more results truncated)\n"
break
}
formatted += entry
}
return formatted.trimmingCharacters(in: .whitespacesAndNewlines)
}
// MARK: - HTML Parsing
private nonisolated func parseResults(from html: String, maxResults: Int) -> [SearchResult] {
var results: [SearchResult] = []
// Match result blocks: <div class="result results_links ...">
let blockPattern = #"<div class="result results_links.*?(?=<div class="result results_links|<div id="links")"#
guard let blockRegex = try? NSRegularExpression(pattern: blockPattern, options: .dotMatchesLineSeparators) else {
return []
}
let range = NSRange(html.startIndex..., in: html)
let blocks = blockRegex.matches(in: html, range: range)
for match in blocks.prefix(maxResults) {
guard let blockRange = Range(match.range, in: html) else { continue }
let block = String(html[blockRange])
// Extract title and URL from <a class="result__a" href="...">Title</a>
let titlePattern = #"<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([^<]+)</a>"#
guard let titleRegex = try? NSRegularExpression(pattern: titlePattern),
let titleMatch = titleRegex.firstMatch(in: block, range: NSRange(block.startIndex..., in: block)),
let urlRange = Range(titleMatch.range(at: 1), in: block),
let titleRange = Range(titleMatch.range(at: 2), in: block)
else { continue }
var resultURL = String(block[urlRange])
let title = decodeHTMLEntities(String(block[titleRange]).trimmingCharacters(in: .whitespaces))
// Extract snippet from <a class="result__snippet" ...>text</a>
let snippetPattern = #"<a[^>]*class="result__snippet"[^>]*>([^<]+)</a>"#
var snippet = ""
if let snippetRegex = try? NSRegularExpression(pattern: snippetPattern),
let snippetMatch = snippetRegex.firstMatch(in: block, range: NSRange(block.startIndex..., in: block)),
let snippetRange = Range(snippetMatch.range(at: 1), in: block) {
snippet = decodeHTMLEntities(String(block[snippetRange]).trimmingCharacters(in: .whitespaces))
}
// Decode DDG redirect URL
if resultURL.contains("uddg=") {
let uddgPattern = #"uddg=([^&]+)"#
if let uddgRegex = try? NSRegularExpression(pattern: uddgPattern),
let uddgMatch = uddgRegex.firstMatch(in: resultURL, range: NSRange(resultURL.startIndex..., in: resultURL)),
let uddgRange = Range(uddgMatch.range(at: 1), in: resultURL) {
resultURL = String(resultURL[uddgRange]).removingPercentEncoding ?? resultURL
}
}
results.append(SearchResult(title: title, url: resultURL, snippet: snippet))
}
return results
}
private nonisolated func decodeHTMLEntities(_ string: String) -> String {
var result = string
let entities: [(String, String)] = [
("&amp;", "&"), ("&lt;", "<"), ("&gt;", ">"),
("&quot;", "\""), ("&#39;", "'"), ("&apos;", "'"),
("&#x27;", "'"), ("&#x2F;", "/"), ("&nbsp;", " "),
]
for (entity, char) in entities {
result = result.replacingOccurrences(of: entity, with: char)
}
return result
}
}

View File

@@ -0,0 +1,78 @@
//
// Color+Extensions.swift
// oAI
//
// Color scheme matching Python TUI dark theme
//
import SwiftUI
extension Color {
// MARK: - oAI Color Palette (Matching Python TUI)
static let oaiBackground = Color(hex: "#1e1e1e") // Main background
static let oaiSurface = Color(hex: "#2d2d2d") // Cards, surfaces
static let oaiPrimary = Color(hex: "#cccccc") // Primary text
static let oaiSecondary = Color(hex: "#888888") // Secondary text
static let oaiAccent = Color(hex: "#0a7aca") // Blue accent (assistant)
static let oaiSuccess = Color(hex: "#90ee90") // Green (user messages)
static let oaiError = Color(hex: "#ff6b6b") // Red (errors)
static let oaiWarning = Color(hex: "#ffaa00") // Orange (warnings)
static let oaiBorder = Color(hex: "#555555") // Borders, dividers
// MARK: - Message Role Colors
static func messageColor(for role: MessageRole) -> Color {
switch role {
case .user: return .oaiSuccess
case .assistant: return .oaiAccent
case .system: return .oaiSecondary
}
}
static func messageBackground(for role: MessageRole) -> Color {
switch role {
case .user: return .oaiSurface
case .assistant: return .oaiBackground
case .system: return Color(hex: "#2a2a2a")
}
}
// MARK: - Provider Colors
static func providerColor(_ provider: Settings.Provider) -> Color {
switch provider {
case .openrouter: return Color(hex: "#7c3aed") // Purple
case .anthropic: return Color(hex: "#d4895a") // Orange
case .openai: return Color(hex: "#10a37f") // Green
case .ollama: return Color(hex: "#ffffff") // White
}
}
// MARK: - Hex Initializer
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: // RGB (12-bit)
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB (32-bit)
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (255, 0, 0, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}

View File

@@ -0,0 +1,87 @@
//
// String+Extensions.swift
// oAI
//
// String utility extensions
//
import Foundation
extension String {
// MARK: - Command Parsing
var isSlashCommand: Bool {
hasPrefix("/")
}
func parseCommand() -> (command: String, args: [String])? {
guard isSlashCommand else { return nil }
let parts = self.split(separator: " ", omittingEmptySubsequences: true)
.map(String.init)
guard let command = parts.first else { return nil }
let args = Array(parts.dropFirst())
return (command, args)
}
// MARK: - File Attachment Parsing
func parseFileAttachments() -> (cleanText: String, filePaths: [String]) {
var cleanText = self
var filePaths: [String] = []
// Pattern 1: @<filepath>
let anglePattern = #"@<([^>]+)>"#
if let regex = try? NSRegularExpression(pattern: anglePattern) {
let matches = regex.matches(in: self, range: NSRange(self.startIndex..., in: self))
for match in matches.reversed() {
if let range = Range(match.range(at: 1), in: self) {
let path = String(self[range])
filePaths.insert(path, at: 0)
}
if let fullRange = Range(match.range, in: self) {
cleanText.removeSubrange(fullRange)
}
}
}
// Pattern 2: @filepath (starting with /, ~, ., or drive letter)
let directPattern = #"@([~/.][\S]+|[A-Za-z]:[\\\/][\S]+)"#
if let regex = try? NSRegularExpression(pattern: directPattern) {
let matches = regex.matches(in: cleanText, range: NSRange(cleanText.startIndex..., in: cleanText))
for match in matches.reversed() {
if let range = Range(match.range(at: 1), in: cleanText) {
let path = String(cleanText[range])
if !filePaths.contains(path) {
filePaths.insert(path, at: 0)
}
}
if let fullRange = Range(match.range, in: cleanText) {
cleanText.removeSubrange(fullRange)
}
}
}
return (cleanText.trimmingCharacters(in: .whitespaces), filePaths)
}
// MARK: - Token Estimation
func estimateTokens() -> Int {
// Rough estimation: ~4 characters per token
// This is approximate; Phase 2 will use proper tokenizer
return max(1, count / 4)
}
// MARK: - Truncation
func truncated(to length: Int, trailing: String = "...") -> String {
if count <= length {
return self
}
let endIndex = index(startIndex, offsetBy: length - trailing.count)
return String(self[..<endIndex]) + trailing
}
}

View File

@@ -0,0 +1,81 @@
//
// View+Extensions.swift
// oAI
//
// SwiftUI view helpers and modifiers
//
import SwiftUI
extension View {
// MARK: - Conditional Modifiers
@ViewBuilder
func `if`<Transform: View>(_ condition: Bool, transform: (Self) -> Transform) -> some View {
if condition {
transform(self)
} else {
self
}
}
@ViewBuilder
func ifLet<Value, Transform: View>(_ value: Value?, transform: (Self, Value) -> Transform) -> some View {
if let value = value {
transform(self, value)
} else {
self
}
}
// MARK: - Platform-Specific Helpers
#if os(macOS)
func onCommandReturn(perform action: @escaping () -> Void) -> some View {
self
// Note: onKeyPress modifiers don't work in command-line Swift build
// This will be implemented when running in actual Xcode project
// For now, using keyboard shortcuts in toolbar instead
}
#endif
// MARK: - Common Styling
func oaiCardStyle() -> some View {
self
.background(Color.oaiSurface)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.oaiBorder, lineWidth: 1)
)
}
func oaiButton() -> some View {
self
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.oaiSurface)
.foregroundColor(.oaiPrimary)
.cornerRadius(6)
}
func oaiTextField() -> some View {
self
.padding(8)
.background(Color.oaiBackground)
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.oaiBorder, lineWidth: 1)
)
}
}
// MARK: - Frame Helpers
extension View {
func frame(square size: CGFloat) -> some View {
self.frame(width: size, height: size)
}
}

136
oAI/Utilities/Logging.swift Normal file
View File

@@ -0,0 +1,136 @@
//
// Logging.swift
// oAI
//
// Dual logging: os.Logger (unified log) + file (~Library/Logs/oAI.log)
//
import Foundation
import os
// MARK: - Log Level
enum LogLevel: Int, Comparable, CaseIterable, Sendable {
case debug = 0
case info = 1
case warning = 2
case error = 3
var label: String {
switch self {
case .debug: return "DEBUG"
case .info: return "INFO"
case .warning: return "WARN"
case .error: return "ERROR"
}
}
var displayName: String {
switch self {
case .debug: return "Debug"
case .info: return "Info"
case .warning: return "Warning"
case .error: return "Error"
}
}
static func < (lhs: LogLevel, rhs: LogLevel) -> Bool {
lhs.rawValue < rhs.rawValue
}
}
// MARK: - File Logger
final class FileLogger: @unchecked Sendable {
static let shared = FileLogger()
private let fileHandle: FileHandle?
private let queue = DispatchQueue(label: "com.oai.filelogger")
private let dateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
return f
}()
/// Current minimum log level (read from UserDefaults for thread safety)
var minimumLevel: LogLevel {
get {
let raw = UserDefaults.standard.integer(forKey: "logLevel")
return LogLevel(rawValue: raw) ?? .info
}
set {
UserDefaults.standard.set(newValue.rawValue, forKey: "logLevel")
}
}
private init() {
let logsDir = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Logs")
let logFile = logsDir.appendingPathComponent("oAI.log")
// Ensure file exists
if !FileManager.default.fileExists(atPath: logFile.path) {
FileManager.default.createFile(atPath: logFile.path, contents: nil)
}
fileHandle = try? FileHandle(forWritingTo: logFile)
fileHandle?.seekToEndOfFile()
}
func write(_ level: LogLevel, category: String, message: String) {
guard level >= minimumLevel else { return }
queue.async { [weak self] in
guard let self, let fh = self.fileHandle else { return }
let timestamp = self.dateFormatter.string(from: Date())
let line = "[\(timestamp)] [\(level.label)] [\(category)] \(message)\n"
if let data = line.data(using: .utf8) {
fh.write(data)
}
}
}
deinit {
fileHandle?.closeFile()
}
}
// MARK: - App Logger (wraps os.Logger + file)
struct AppLogger {
let osLogger: Logger
let category: String
func debug(_ message: String) {
FileLogger.shared.write(.debug, category: category, message: message)
osLogger.debug("\(message, privacy: .public)")
}
func info(_ message: String) {
FileLogger.shared.write(.info, category: category, message: message)
osLogger.info("\(message, privacy: .public)")
}
func warning(_ message: String) {
FileLogger.shared.write(.warning, category: category, message: message)
osLogger.warning("\(message, privacy: .public)")
}
func error(_ message: String) {
FileLogger.shared.write(.error, category: category, message: message)
osLogger.error("\(message, privacy: .public)")
}
}
// MARK: - Log Namespace
enum Log {
private static let subsystem = "com.oai.oAI"
static let api = AppLogger(osLogger: Logger(subsystem: subsystem, category: "api"), category: "api")
static let db = AppLogger(osLogger: Logger(subsystem: subsystem, category: "database"), category: "database")
static let mcp = AppLogger(osLogger: Logger(subsystem: subsystem, category: "mcp"), category: "mcp")
static let settings = AppLogger(osLogger: Logger(subsystem: subsystem, category: "settings"), category: "settings")
static let search = AppLogger(osLogger: Logger(subsystem: subsystem, category: "search"), category: "search")
static let ui = AppLogger(osLogger: Logger(subsystem: subsystem, category: "ui"), category: "ui")
static let general = AppLogger(osLogger: Logger(subsystem: subsystem, category: "general"), category: "general")
}

View File

@@ -0,0 +1,294 @@
//
// SyntaxHighlighter.swift
// oAI
//
// Keyword-based syntax highlighting using AttributedString
//
import SwiftUI
struct SyntaxHighlighter {
// MARK: - Token Colors (dark theme)
static let keywordColor = Color(hex: "#569cd6") // Blue
static let stringColor = Color(hex: "#ce9178") // Orange
static let commentColor = Color(hex: "#6a9955") // Green
static let numberColor = Color(hex: "#b5cea8") // Light green
static let typeColor = Color(hex: "#4ec9b0") // Teal
static let functionColor = Color(hex: "#dcdcaa") // Yellow
static let defaultColor = Color(hex: "#d4d4d4") // Light gray
static let punctuationColor = Color(hex: "#808080") // Gray
// MARK: - Language Keywords
private static let keywords: [String: Set<String>] = [
"swift": ["import", "func", "class", "struct", "enum", "protocol", "extension",
"var", "let", "if", "else", "guard", "switch", "case", "default",
"for", "while", "repeat", "return", "break", "continue", "throw",
"throws", "try", "catch", "do", "async", "await", "in", "where",
"self", "Self", "super", "init", "deinit", "nil", "true", "false",
"public", "private", "internal", "fileprivate", "open", "static",
"override", "mutating", "weak", "unowned", "lazy", "some", "any",
"typealias", "associatedtype", "inout", "as", "is", "defer"],
"python": ["import", "from", "def", "class", "if", "elif", "else", "for",
"while", "return", "yield", "break", "continue", "pass", "raise",
"try", "except", "finally", "with", "as", "lambda", "and", "or",
"not", "in", "is", "True", "False", "None", "self", "async", "await",
"global", "nonlocal", "del", "assert", "print"],
"javascript": ["function", "const", "let", "var", "if", "else", "for", "while",
"do", "switch", "case", "default", "return", "break", "continue",
"throw", "try", "catch", "finally", "class", "extends", "new",
"this", "super", "import", "export", "from", "async", "await",
"yield", "of", "in", "typeof", "instanceof", "delete", "void",
"true", "false", "null", "undefined", "debugger"],
"typescript": ["function", "const", "let", "var", "if", "else", "for", "while",
"do", "switch", "case", "default", "return", "break", "continue",
"throw", "try", "catch", "finally", "class", "extends", "new",
"this", "super", "import", "export", "from", "async", "await",
"yield", "of", "in", "typeof", "instanceof", "delete", "void",
"true", "false", "null", "undefined", "type", "interface",
"enum", "implements", "abstract", "readonly", "as", "keyof",
"namespace", "declare", "module"],
"go": ["package", "import", "func", "type", "struct", "interface", "var",
"const", "if", "else", "for", "range", "switch", "case", "default",
"return", "break", "continue", "go", "defer", "select", "chan",
"map", "make", "new", "append", "len", "cap", "nil", "true", "false",
"fallthrough", "goto"],
"rust": ["fn", "let", "mut", "const", "if", "else", "match", "for", "while",
"loop", "return", "break", "continue", "struct", "enum", "impl",
"trait", "pub", "use", "mod", "crate", "self", "super", "as", "in",
"ref", "move", "async", "await", "where", "type", "dyn", "unsafe",
"extern", "true", "false", "Some", "None", "Ok", "Err"],
"java": ["class", "interface", "enum", "extends", "implements", "import",
"package", "public", "private", "protected", "static", "final",
"abstract", "void", "int", "long", "double", "float", "boolean",
"char", "byte", "short", "if", "else", "for", "while", "do",
"switch", "case", "default", "return", "break", "continue",
"throw", "throws", "try", "catch", "finally", "new", "this",
"super", "null", "true", "false", "synchronized", "volatile"],
"c": ["if", "else", "for", "while", "do", "switch", "case", "default",
"return", "break", "continue", "goto", "typedef", "struct", "union",
"enum", "const", "static", "extern", "volatile", "register", "auto",
"void", "int", "long", "short", "char", "float", "double", "unsigned",
"signed", "sizeof", "NULL", "include", "define", "ifdef", "ifndef",
"endif", "pragma"],
"cpp": ["if", "else", "for", "while", "do", "switch", "case", "default",
"return", "break", "continue", "goto", "typedef", "struct", "union",
"enum", "const", "static", "extern", "volatile", "class", "public",
"private", "protected", "virtual", "override", "template", "typename",
"namespace", "using", "new", "delete", "throw", "try", "catch",
"nullptr", "true", "false", "auto", "constexpr", "inline",
"void", "int", "long", "short", "char", "float", "double", "bool",
"include", "define", "ifdef", "ifndef", "endif"],
"sql": ["SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES", "UPDATE",
"SET", "DELETE", "CREATE", "TABLE", "ALTER", "DROP", "INDEX",
"JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "ON", "AND", "OR",
"NOT", "NULL", "IS", "IN", "LIKE", "BETWEEN", "EXISTS", "AS",
"ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "UNION",
"DISTINCT", "COUNT", "SUM", "AVG", "MAX", "MIN", "PRIMARY",
"KEY", "FOREIGN", "REFERENCES", "CASCADE", "CONSTRAINT",
"select", "from", "where", "insert", "into", "values", "update",
"set", "delete", "create", "table", "alter", "drop", "index",
"join", "left", "right", "inner", "outer", "on", "and", "or",
"not", "null", "is", "in", "like", "between", "exists", "as",
"order", "by", "group", "having", "limit", "offset", "union",
"distinct", "primary", "key", "foreign", "references"],
"shell": ["if", "then", "else", "elif", "fi", "for", "while", "do", "done",
"case", "esac", "function", "return", "exit", "echo", "export",
"local", "readonly", "shift", "set", "unset", "source", "eval",
"exec", "cd", "pwd", "ls", "cp", "mv", "rm", "mkdir", "cat",
"grep", "sed", "awk", "find", "xargs", "pipe", "true", "false",
"in", "sudo", "chmod", "chown"],
"html": ["html", "head", "body", "div", "span", "p", "a", "img", "ul", "ol",
"li", "table", "tr", "td", "th", "form", "input", "button", "select",
"option", "textarea", "script", "style", "link", "meta", "title",
"header", "footer", "nav", "main", "section", "article", "aside",
"class", "id", "href", "src", "alt", "type", "value", "name"],
"css": ["color", "background", "margin", "padding", "border", "font",
"display", "position", "width", "height", "top", "left", "right",
"bottom", "flex", "grid", "align", "justify", "transform", "transition",
"animation", "opacity", "overflow", "z-index", "important",
"none", "block", "inline", "absolute", "relative", "fixed", "sticky"],
"ruby": ["def", "end", "class", "module", "if", "elsif", "else", "unless",
"while", "until", "for", "do", "begin", "rescue", "ensure", "raise",
"return", "yield", "block_given?", "require", "include", "extend",
"attr_accessor", "attr_reader", "attr_writer", "self", "super",
"nil", "true", "false", "puts", "print", "lambda", "proc"],
]
// MARK: - Comment Styles
private static let lineCommentPrefixes: [String: String] = [
"swift": "//", "python": "#", "javascript": "//", "typescript": "//",
"go": "//", "rust": "//", "java": "//", "c": "//", "cpp": "//",
"shell": "#", "bash": "#", "ruby": "#", "yaml": "#", "toml": "#",
]
// MARK: - Language Aliases
private static let languageAliases: [String: String] = [
"js": "javascript", "ts": "typescript", "py": "python",
"sh": "shell", "bash": "shell", "zsh": "shell",
"c++": "cpp", "objective-c": "c", "objc": "c",
"yml": "yaml", "md": "markdown", "rb": "ruby",
"h": "c", "hpp": "cpp", "m": "c",
]
// MARK: - Public API
static func highlight(code: String, language: String?) -> AttributedString {
let lang = resolveLanguage(language)
let langKeywords = keywords[lang] ?? Set()
let commentPrefix = lineCommentPrefixes[lang]
var result = AttributedString()
let lines = code.components(separatedBy: "\n")
for (lineIndex, line) in lines.enumerated() {
let highlightedLine = highlightLine(line, keywords: langKeywords, commentPrefix: commentPrefix, language: lang)
result.append(highlightedLine)
if lineIndex < lines.count - 1 {
result.append(AttributedString("\n"))
}
}
return result
}
// MARK: - Private
private static func resolveLanguage(_ lang: String?) -> String {
guard let lang = lang?.lowercased().trimmingCharacters(in: .whitespaces), !lang.isEmpty else {
return ""
}
return languageAliases[lang] ?? lang
}
private static func highlightLine(_ line: String, keywords: Set<String>, commentPrefix: String?, language: String) -> AttributedString {
// Check for line comments
if let prefix = commentPrefix {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix(prefix) {
var attr = AttributedString(line)
attr.foregroundColor = commentColor
return attr
}
}
// Tokenize and colorize
var result = AttributedString()
var i = line.startIndex
while i < line.endIndex {
let c = line[i]
// String literals
if c == "\"" || c == "'" || c == "`" {
let (strAttr, newIndex) = consumeString(line, from: i, quote: c)
result.append(strAttr)
i = newIndex
continue
}
// Block comment start
if c == "/" && line.index(after: i) < line.endIndex && line[line.index(after: i)] == "*" {
// Consume rest of line as comment (simplified no multi-line tracking)
let rest = String(line[i...])
var attr = AttributedString(rest)
attr.foregroundColor = commentColor
result.append(attr)
return result
}
// Numbers
if c.isNumber && (i == line.startIndex || !line[line.index(before: i)].isLetter) {
let (numAttr, newIndex) = consumeNumber(line, from: i)
result.append(numAttr)
i = newIndex
continue
}
// Words (identifiers/keywords)
if c.isLetter || c == "_" || c == "@" || c == "#" {
let (wordAttr, newIndex) = consumeWord(line, from: i, keywords: keywords)
result.append(wordAttr)
i = newIndex
continue
}
// Punctuation/operators
var charAttr = AttributedString(String(c))
charAttr.foregroundColor = defaultColor
result.append(charAttr)
i = line.index(after: i)
}
return result
}
private static func consumeString(_ line: String, from start: String.Index, quote: Character) -> (AttributedString, String.Index) {
var i = line.index(after: start)
var str = String(quote)
while i < line.endIndex {
let c = line[i]
str.append(c)
if c == "\\" && line.index(after: i) < line.endIndex {
// Escaped character
i = line.index(after: i)
str.append(line[i])
i = line.index(after: i)
continue
}
i = line.index(after: i)
if c == quote {
break
}
}
var attr = AttributedString(str)
attr.foregroundColor = stringColor
return (attr, i)
}
private static func consumeNumber(_ line: String, from start: String.Index) -> (AttributedString, String.Index) {
var i = start
var num = ""
while i < line.endIndex && (line[i].isHexDigit || line[i] == "." || line[i] == "x" || line[i] == "X" || line[i] == "_") {
num.append(line[i])
i = line.index(after: i)
}
var attr = AttributedString(num)
attr.foregroundColor = numberColor
return (attr, i)
}
private static func consumeWord(_ line: String, from start: String.Index, keywords: Set<String>) -> (AttributedString, String.Index) {
var i = start
var word = ""
while i < line.endIndex && (line[i].isLetter || line[i].isNumber || line[i] == "_" || line[i] == "@" || line[i] == "#") {
word.append(line[i])
i = line.index(after: i)
}
var attr = AttributedString(word)
if keywords.contains(word) {
attr.foregroundColor = keywordColor
} else if word.first?.isUppercase == true && word.count > 1 {
// Type-like identifier (capitalized)
attr.foregroundColor = typeColor
} else if i < line.endIndex && line[i] == "(" {
// Function call
attr.foregroundColor = functionColor
} else {
attr.foregroundColor = defaultColor
}
return (attr, i)
}
}

View File

@@ -0,0 +1,947 @@
//
// ChatViewModel.swift
// oAI
//
// Main chat view model
//
import Foundation
import os
import SwiftUI
@Observable
@MainActor
class ChatViewModel {
// MARK: - Observable State
var messages: [Message] = []
var inputText: String = ""
var isGenerating: Bool = false
var sessionStats = SessionStats()
var selectedModel: ModelInfo?
var currentProvider: Settings.Provider = .openrouter
var onlineMode: Bool = false
var memoryEnabled: Bool = true
var mcpEnabled: Bool = false
var mcpStatus: String? = nil
var availableModels: [ModelInfo] = []
var isLoadingModels: Bool = false
var showConversations: Bool = false
var showModelSelector: Bool = false
var showSettings: Bool = false
var showStats: Bool = false
var showHelp: Bool = false
var showCredits: Bool = false
var modelInfoTarget: ModelInfo? = nil
// MARK: - Private State
private var commandHistory: [String] = []
private var historyIndex: Int = -1
private var streamingTask: Task<Void, Never>?
private let settings = SettingsService.shared
private let providerRegistry = ProviderRegistry.shared
// MARK: - Initialization
init() {
// Load settings
self.currentProvider = settings.defaultProvider
self.onlineMode = settings.onlineMode
self.memoryEnabled = settings.memoryEnabled
self.mcpEnabled = settings.mcpEnabled
}
// MARK: - Public Methods
/// Switch to a different provider (from header dropdown)
func changeProvider(_ newProvider: Settings.Provider) {
guard newProvider != currentProvider else { return }
Log.ui.info("Switching provider to \(newProvider.rawValue)")
settings.defaultProvider = newProvider
currentProvider = newProvider
selectedModel = nil
availableModels = []
Task { await loadAvailableModels() }
}
/// Start a new conversation
func newConversation() {
messages = []
sessionStats = SessionStats()
inputText = ""
}
/// Re-sync local state from SettingsService (called when Settings sheet dismisses)
func syncFromSettings() {
let newProvider = settings.defaultProvider
let providerChanged = currentProvider != newProvider
currentProvider = newProvider
onlineMode = settings.onlineMode
memoryEnabled = settings.memoryEnabled
mcpEnabled = settings.mcpEnabled
mcpStatus = mcpEnabled ? "MCP" : nil
if providerChanged {
selectedModel = nil
availableModels = []
Task { await loadAvailableModels() }
}
}
func loadAvailableModels() async {
isLoadingModels = true
do {
guard let provider = providerRegistry.getCurrentProvider() else {
Log.ui.warning("No API key configured for current provider")
isLoadingModels = false
showSystemMessage("⚠️ No API key configured. Add your API key in Settings to load models.")
return
}
let models = try await provider.listModels()
availableModels = models
if selectedModel == nil, let firstModel = models.first {
selectedModel = firstModel
}
isLoadingModels = false
} catch {
Log.api.error("Failed to load models: \(error.localizedDescription)")
isLoadingModels = false
showSystemMessage("⚠️ Could not load models: \(error.localizedDescription)")
}
}
func sendMessage() {
guard !inputText.trimmingCharacters(in: .whitespaces).isEmpty else { return }
let trimmedInput = inputText.trimmingCharacters(in: .whitespaces)
// Check if it's a slash command
if trimmedInput.hasPrefix("/") {
handleCommand(trimmedInput)
inputText = ""
return
}
// Parse file attachments
let (cleanText, filePaths) = trimmedInput.parseFileAttachments()
// Read file attachments from disk
let attachments: [FileAttachment]? = filePaths.isEmpty ? nil : readFileAttachments(filePaths)
// Create user message
let userMessage = Message(
role: .user,
content: cleanText,
tokens: cleanText.estimateTokens(),
cost: nil,
timestamp: Date(),
attachments: attachments
)
messages.append(userMessage)
sessionStats.addMessage(inputTokens: userMessage.tokens, outputTokens: nil, cost: nil)
// Clear input
inputText = ""
// Add to command history
commandHistory.append(trimmedInput)
historyIndex = commandHistory.count
// Generate real AI response
generateAIResponse(to: cleanText, attachments: userMessage.attachments)
}
func cancelGeneration() {
streamingTask?.cancel()
streamingTask = nil
isGenerating = false
}
func clearChat() {
messages.removeAll()
sessionStats.reset()
showSystemMessage("Chat cleared")
}
func loadConversation(_ conversation: Conversation) {
do {
guard let (_, loadedMessages) = try DatabaseService.shared.loadConversation(id: conversation.id) else {
showSystemMessage("Could not load conversation '\(conversation.name)'")
return
}
messages.removeAll()
sessionStats.reset()
messages = loadedMessages
// Rebuild session stats from loaded messages
for msg in loadedMessages {
sessionStats.addMessage(
inputTokens: msg.role == .user ? msg.tokens : nil,
outputTokens: msg.role == .assistant ? msg.tokens : nil,
cost: msg.cost
)
}
showSystemMessage("Loaded conversation '\(conversation.name)'")
} catch {
showSystemMessage("Failed to load: \(error.localizedDescription)")
}
}
func retryLastMessage() {
guard let lastUserMessage = messages.last(where: { $0.role == .user }) else {
showSystemMessage("No previous message to retry")
return
}
// Remove last assistant response if exists
if let lastMessage = messages.last, lastMessage.role == .assistant {
messages.removeLast()
}
generateAIResponse(to: lastUserMessage.content, attachments: lastUserMessage.attachments)
}
// MARK: - Command Handling
private func handleCommand(_ command: String) {
guard let (cmd, args) = command.parseCommand() else {
showSystemMessage("Invalid command")
return
}
switch cmd.lowercased() {
case "/help":
showHelp = true
case "/model":
showModelSelector = true
case "/clear":
clearChat()
case "/retry":
retryLastMessage()
case "/memory":
if let arg = args.first?.lowercased() {
memoryEnabled = arg == "on"
showSystemMessage("Memory \(memoryEnabled ? "enabled" : "disabled")")
} else {
showSystemMessage("Usage: /memory on|off")
}
case "/online":
if let arg = args.first?.lowercased() {
onlineMode = arg == "on"
showSystemMessage("Online mode \(onlineMode ? "enabled" : "disabled")")
} else {
showSystemMessage("Usage: /online on|off")
}
case "/stats":
showStats = true
case "/config", "/settings":
showSettings = true
case "/provider":
if let providerName = args.first?.lowercased() {
if let provider = Settings.Provider.allCases.first(where: { $0.rawValue == providerName }) {
currentProvider = provider
showSystemMessage("Switched to \(provider.displayName) provider")
} else {
showSystemMessage("Unknown provider: \(providerName)")
}
} else {
showSystemMessage("Current provider: \(currentProvider.displayName)")
}
case "/save":
if let name = args.first {
let chatMessages = messages.filter { $0.role != .system }
guard !chatMessages.isEmpty else {
showSystemMessage("Nothing to save — no messages in this conversation")
return
}
do {
let _ = try DatabaseService.shared.saveConversation(name: name, messages: chatMessages)
showSystemMessage("Conversation saved as '\(name)'")
} catch {
showSystemMessage("Failed to save: \(error.localizedDescription)")
}
} else {
showSystemMessage("Usage: /save <name>")
}
case "/load", "/list":
showConversations = true
case "/delete":
if let name = args.first {
do {
let deleted = try DatabaseService.shared.deleteConversation(name: name)
if deleted {
showSystemMessage("Deleted conversation '\(name)'")
} else {
showSystemMessage("No conversation found with name '\(name)'")
}
} catch {
showSystemMessage("Failed to delete: \(error.localizedDescription)")
}
} else {
showSystemMessage("Usage: /delete <name>")
}
case "/export":
if args.count >= 1 {
let format = args[0].lowercased()
let filename = args.count >= 2 ? args[1] : "conversation.\(format)"
exportConversation(format: format, filename: filename)
} else {
showSystemMessage("Usage: /export md|json <filename>")
}
case "/info":
if let modelId = args.first {
if let model = availableModels.first(where: { $0.id == modelId || $0.name.lowercased() == modelId.lowercased() }) {
showModelInfo(model)
} else {
showSystemMessage("Model not found: \(modelId)")
}
} else if let model = selectedModel {
showModelInfo(model)
} else {
showSystemMessage("No model selected")
}
case "/credits":
showCredits = true
case "/mcp":
handleMCPCommand(args: args)
default:
showSystemMessage("Unknown command: \(cmd)\nType /help for available commands")
}
}
// MARK: - AI Response Generation
private func generateAIResponse(to prompt: String, attachments: [FileAttachment]?) {
// Get provider
guard let provider = providerRegistry.getCurrentProvider() else {
Log.ui.warning("Cannot generate: no API key configured")
showSystemMessage("❌ No API key configured. Please add your API key in Settings.")
return
}
guard let modelId = selectedModel?.id else {
Log.ui.warning("Cannot generate: no model selected")
showSystemMessage("❌ No model selected. Please select a model first.")
return
}
Log.ui.info("Sending message: model=\(modelId), messages=\(self.messages.count)")
// Dispatch to tool-aware path when MCP is enabled with folders
// Skip for image generation models they don't support tool calling
let mcp = MCPService.shared
let mcpActive = mcpEnabled || settings.mcpEnabled
let modelSupportTools = selectedModel?.capabilities.tools ?? false
if mcpActive && !mcp.allowedFolders.isEmpty && modelSupportTools {
generateAIResponseWithTools(provider: provider, modelId: modelId)
return
}
isGenerating = true
// Cancel any existing task
streamingTask?.cancel()
// Start streaming
streamingTask = Task {
do {
// Create empty assistant message for streaming
let assistantMessage = Message(
role: .assistant,
content: "",
tokens: nil,
cost: nil,
timestamp: Date(),
attachments: nil,
isStreaming: true
)
// Already on MainActor
messages.append(assistantMessage)
// Build chat request AFTER adding the assistant message
// Only include messages up to (but not including) the streaming assistant message
var messagesToSend = Array(messages.dropLast()) // Remove the empty assistant message
// Web search via our WebSearchService (skip Anthropic uses native search tool)
// Append results to last user message content (matching Python oAI approach)
if onlineMode && currentProvider != .anthropic && currentProvider != .openrouter && !messagesToSend.isEmpty {
if let lastUserIdx = messagesToSend.lastIndex(where: { $0.role == .user }) {
Log.search.info("Running web search for \(currentProvider.displayName)")
let results = await WebSearchService.shared.search(query: messagesToSend[lastUserIdx].content)
if !results.isEmpty {
let searchContext = "\n\n\(WebSearchService.shared.formatResults(results))\n\nPlease use the above web search results to help answer the user's question."
messagesToSend[lastUserIdx].content += searchContext
Log.search.info("Injected \(results.count) search results into user message")
}
}
}
let isImageGen = selectedModel?.capabilities.imageGeneration ?? false
if isImageGen {
Log.ui.info("Image generation mode for model \(modelId)")
}
let chatRequest = ChatRequest(
messages: Array(memoryEnabled ? messagesToSend : [messagesToSend.last!]),
model: modelId,
stream: settings.streamEnabled,
maxTokens: settings.maxTokens > 0 ? settings.maxTokens : nil,
temperature: settings.temperature > 0 ? settings.temperature : nil,
topP: nil,
systemPrompt: nil,
tools: nil,
onlineMode: onlineMode,
imageGeneration: isImageGen
)
let messageId = assistantMessage.id
if isImageGen {
// Image generation: use non-streaming request
// Image models don't reliably support streaming
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = "Generating image..."
}
let nonStreamRequest = ChatRequest(
messages: chatRequest.messages,
model: chatRequest.model,
stream: false,
maxTokens: chatRequest.maxTokens,
temperature: chatRequest.temperature,
imageGeneration: true
)
let response = try await provider.chat(request: nonStreamRequest)
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = response.content
messages[index].isStreaming = false
messages[index].generatedImages = response.generatedImages
if let usage = response.usage {
messages[index].tokens = usage.completionTokens
if let model = selectedModel {
let cost = (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
}
}
}
} else {
// Regular text: stream response
var fullContent = ""
var totalTokens: ChatResponse.Usage? = nil
for try await chunk in provider.streamChat(request: chatRequest) {
if Task.isCancelled { break }
if let content = chunk.deltaContent {
fullContent += content
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = fullContent
}
}
if let usage = chunk.usage {
totalTokens = usage
}
}
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = fullContent
messages[index].isStreaming = false
if let usage = totalTokens {
messages[index].tokens = usage.completionTokens
if let model = selectedModel {
let cost = (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
}
}
}
}
isGenerating = false
streamingTask = nil
} catch {
// Remove the empty streaming message
if let index = messages.lastIndex(where: { $0.role == .assistant && $0.content.isEmpty }) {
messages.remove(at: index)
}
Log.api.error("Generation failed: \(error.localizedDescription)")
showSystemMessage("\(friendlyErrorMessage(from: error))")
isGenerating = false
streamingTask = nil
}
}
}
// MARK: - File Attachment Reading
private let maxFileSize: Int = 10 * 1024 * 1024 // 10 MB
private let maxTextSize: Int = 50 * 1024 // 50 KB before truncation
private func readFileAttachments(_ paths: [String]) -> [FileAttachment] {
var attachments: [FileAttachment] = []
let fm = FileManager.default
for rawPath in paths {
// Expand ~ and resolve path
let expanded = (rawPath as NSString).expandingTildeInPath
let resolvedPath = expanded.hasPrefix("/") ? expanded : (fm.currentDirectoryPath as NSString).appendingPathComponent(expanded)
// Check file exists
guard fm.fileExists(atPath: resolvedPath) else {
showSystemMessage("⚠️ File not found: \(rawPath)")
continue
}
// Check file size
guard let attrs = try? fm.attributesOfItem(atPath: resolvedPath),
let fileSize = attrs[.size] as? Int else {
showSystemMessage("⚠️ Cannot read file: \(rawPath)")
continue
}
if fileSize > maxFileSize {
let sizeMB = String(format: "%.1f", Double(fileSize) / 1_000_000)
showSystemMessage("⚠️ File too large (\(sizeMB) MB, max 10 MB): \(rawPath)")
continue
}
let type = FileAttachment.typeFromExtension(resolvedPath)
switch type {
case .image, .pdf:
// Read as raw data
guard let data = fm.contents(atPath: resolvedPath) else {
showSystemMessage("⚠️ Could not read file: \(rawPath)")
continue
}
attachments.append(FileAttachment(path: rawPath, type: type, data: data))
case .text:
// Read as string
guard let content = try? String(contentsOfFile: resolvedPath, encoding: .utf8) else {
showSystemMessage("⚠️ Could not read file as text: \(rawPath)")
continue
}
var finalContent = content
// Truncate large text files
if content.utf8.count > maxTextSize {
let lines = content.components(separatedBy: "\n")
if lines.count > 600 {
let head = lines.prefix(500).joined(separator: "\n")
let tail = lines.suffix(100).joined(separator: "\n")
let omitted = lines.count - 600
finalContent = head + "\n\n... [\(omitted) lines omitted] ...\n\n" + tail
}
}
attachments.append(FileAttachment(path: rawPath, type: .text, data: finalContent.data(using: .utf8)))
}
}
return attachments
}
// MARK: - MCP Command Handling
private func handleMCPCommand(args: [String]) {
let mcp = MCPService.shared
guard let sub = args.first?.lowercased() else {
showSystemMessage("Usage: /mcp on|off|status|add|remove|list")
return
}
switch sub {
case "on":
mcpEnabled = true
settings.mcpEnabled = true
mcpStatus = "MCP"
showSystemMessage("MCP enabled (\(mcp.allowedFolders.count) folder\(mcp.allowedFolders.count == 1 ? "" : "s") registered)")
case "off":
mcpEnabled = false
settings.mcpEnabled = false
mcpStatus = nil
showSystemMessage("MCP disabled")
case "add":
if args.count >= 2 {
let path = args.dropFirst().joined(separator: " ")
if let error = mcp.addFolder(path) {
showSystemMessage("MCP: \(error)")
} else {
showSystemMessage("MCP: Added folder — \(mcp.allowedFolders.count) folder\(mcp.allowedFolders.count == 1 ? "" : "s") registered")
}
} else {
showSystemMessage("Usage: /mcp add <path>")
}
case "remove":
if args.count >= 2 {
let ref = args.dropFirst().joined(separator: " ")
if let index = Int(ref) {
if mcp.removeFolder(at: index) {
showSystemMessage("MCP: Removed folder at index \(index)")
} else {
showSystemMessage("MCP: Invalid index \(index)")
}
} else {
if mcp.removeFolder(path: ref) {
showSystemMessage("MCP: Removed folder")
} else {
showSystemMessage("MCP: Folder not found: \(ref)")
}
}
} else {
showSystemMessage("Usage: /mcp remove <index|path>")
}
case "list":
if mcp.allowedFolders.isEmpty {
showSystemMessage("MCP: No folders registered. Use /mcp add <path>")
} else {
let list = mcp.allowedFolders.enumerated().map { "\($0): \($1)" }.joined(separator: "\n")
showSystemMessage("MCP folders:\n\(list)")
}
case "write":
guard args.count >= 2 else {
showSystemMessage("Usage: /mcp write on|off")
return
}
let toggle = args[1].lowercased()
if toggle == "on" {
settings.mcpCanWriteFiles = true
settings.mcpCanDeleteFiles = true
settings.mcpCanCreateDirectories = true
settings.mcpCanMoveFiles = true
showSystemMessage("MCP: All write permissions enabled (write, edit, delete, create dirs, move, copy)")
} else if toggle == "off" {
settings.mcpCanWriteFiles = false
settings.mcpCanDeleteFiles = false
settings.mcpCanCreateDirectories = false
settings.mcpCanMoveFiles = false
showSystemMessage("MCP: All write permissions disabled")
} else {
showSystemMessage("Usage: /mcp write on|off")
}
case "status":
let enabled = mcpEnabled ? "enabled" : "disabled"
let folders = mcp.allowedFolders.count
var perms: [String] = []
if settings.mcpCanWriteFiles { perms.append("write") }
if settings.mcpCanDeleteFiles { perms.append("delete") }
if settings.mcpCanCreateDirectories { perms.append("mkdir") }
if settings.mcpCanMoveFiles { perms.append("move/copy") }
let permStr = perms.isEmpty ? "read-only" : "read + \(perms.joined(separator: ", "))"
showSystemMessage("MCP: \(enabled), \(folders) folder\(folders == 1 ? "" : "s"), \(permStr), gitignore: \(settings.mcpRespectGitignore ? "on" : "off")")
default:
showSystemMessage("MCP subcommands: on, off, status, add, remove, list, write")
}
}
// MARK: - AI Response with Tool Calls
private func generateAIResponseWithTools(provider: AIProvider, modelId: String) {
let mcp = MCPService.shared
isGenerating = true
streamingTask?.cancel()
streamingTask = Task {
do {
let tools = mcp.getToolSchemas()
// Apply :online suffix for OpenRouter when online mode is active
var effectiveModelId = modelId
if onlineMode && currentProvider == .openrouter && !modelId.hasSuffix(":online") {
effectiveModelId = modelId + ":online"
}
// Build initial messages as raw dictionaries for the tool loop
let folderList = mcp.allowedFolders.joined(separator: "\n - ")
var capabilities = "You can read files, list directories, and search for files."
var writeCapabilities: [String] = []
if mcp.canWriteFiles { writeCapabilities.append("write and edit files") }
if mcp.canDeleteFiles { writeCapabilities.append("delete files") }
if mcp.canCreateDirectories { writeCapabilities.append("create directories") }
if mcp.canMoveFiles { writeCapabilities.append("move and copy files") }
if !writeCapabilities.isEmpty {
capabilities += " You can also \(writeCapabilities.joined(separator: ", "))."
}
let systemContent = "You have access to the user's filesystem through tool calls. \(capabilities) The user has granted you access to these folders:\n - \(folderList)\n\nWhen the user asks about their files, use the tools proactively with the allowed paths. Always use absolute paths."
var messagesToSend: [Message] = memoryEnabled
? messages.filter { $0.role != .system }
: [messages.last(where: { $0.role == .user })].compactMap { $0 }
// Web search via our WebSearchService (skip Anthropic uses native search tool)
// Append results to last user message content (matching Python oAI approach)
if onlineMode && currentProvider != .anthropic && currentProvider != .openrouter {
if let lastUserIdx = messagesToSend.lastIndex(where: { $0.role == .user }) {
Log.search.info("Running web search for tool-aware path (\(currentProvider.displayName))")
let results = await WebSearchService.shared.search(query: messagesToSend[lastUserIdx].content)
if !results.isEmpty {
let searchContext = "\n\n\(WebSearchService.shared.formatResults(results))\n\nPlease use the above web search results to help answer the user's question."
messagesToSend[lastUserIdx].content += searchContext
Log.search.info("Injected \(results.count) search results into user message")
}
}
}
let systemPrompt: [String: Any] = [
"role": "system",
"content": systemContent
]
var apiMessages: [[String: Any]] = [systemPrompt] + messagesToSend.map { msg in
["role": msg.role.rawValue, "content": msg.content]
}
let maxIterations = 5
var finalContent = ""
var totalUsage: ChatResponse.Usage?
for iteration in 0..<maxIterations {
if Task.isCancelled { break }
let response = try await provider.chatWithToolMessages(
model: effectiveModelId,
messages: apiMessages,
tools: tools,
maxTokens: settings.maxTokens > 0 ? settings.maxTokens : nil,
temperature: settings.temperature > 0 ? settings.temperature : nil
)
if let usage = response.usage { totalUsage = usage }
// Check if the model wants to call tools
guard let toolCalls = response.toolCalls, !toolCalls.isEmpty else {
// No tool calls this is the final text response
finalContent = response.content
break
}
// Show what tools the model is calling
let toolNames = toolCalls.map { $0.functionName }.joined(separator: ", ")
showSystemMessage("🔧 Calling: \(toolNames)")
// Append assistant message with tool_calls to conversation
var assistantMsg: [String: Any] = ["role": "assistant"]
if !response.content.isEmpty {
assistantMsg["content"] = response.content
}
let toolCallDicts: [[String: Any]] = toolCalls.map { tc in
[
"id": tc.id,
"type": tc.type,
"function": [
"name": tc.functionName,
"arguments": tc.arguments
]
]
}
assistantMsg["tool_calls"] = toolCallDicts
apiMessages.append(assistantMsg)
// Execute each tool and append results
for tc in toolCalls {
if Task.isCancelled { break }
let result = mcp.executeTool(name: tc.functionName, arguments: tc.arguments)
let resultJSON: String
if let data = try? JSONSerialization.data(withJSONObject: result),
let str = String(data: data, encoding: .utf8) {
resultJSON = str
} else {
resultJSON = "{\"error\": \"Failed to serialize result\"}"
}
apiMessages.append([
"role": "tool",
"tool_call_id": tc.id,
"name": tc.functionName,
"content": resultJSON
])
}
// If this was the last iteration, note it
if iteration == maxIterations - 1 {
finalContent = response.content.isEmpty
? "[Tool loop reached maximum iterations]"
: response.content
}
}
// Display the final response as an assistant message
let assistantMessage = Message(
role: .assistant,
content: finalContent,
tokens: totalUsage?.completionTokens,
cost: nil,
timestamp: Date()
)
messages.append(assistantMessage)
// Calculate cost
if let usage = totalUsage, let model = selectedModel {
let cost = (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) {
messages[index].cost = cost
}
sessionStats.addMessage(
inputTokens: usage.promptTokens,
outputTokens: usage.completionTokens,
cost: cost
)
}
isGenerating = false
streamingTask = nil
} catch {
Log.api.error("Tool generation failed: \(error.localizedDescription)")
showSystemMessage("\(friendlyErrorMessage(from: error))")
isGenerating = false
streamingTask = nil
}
}
}
private func showSystemMessage(_ text: String) {
let message = Message(
role: .system,
content: text,
tokens: nil,
cost: nil,
timestamp: Date(),
attachments: nil
)
messages.append(message)
}
// MARK: - Error Helpers
private func friendlyErrorMessage(from error: Error) -> String {
let desc = error.localizedDescription
// Network connectivity
if let urlError = error as? URLError {
switch urlError.code {
case .notConnectedToInternet, .networkConnectionLost:
return "Unable to reach the server. Check your internet connection."
case .timedOut:
return "Request timed out. Try a shorter message or different model."
case .cannotFindHost, .cannotConnectToHost:
return "Cannot connect to the server. Check your network or provider URL."
default:
break
}
}
// HTTP status codes in error messages
if desc.contains("401") || desc.contains("403") || desc.lowercased().contains("unauthorized") || desc.lowercased().contains("invalid.*key") {
return "Invalid API key. Update it in Settings (\u{2318},)."
}
if desc.contains("429") || desc.lowercased().contains("rate limit") {
return "Rate limited. Wait a moment and try again."
}
if desc.contains("404") || desc.lowercased().contains("model not found") || desc.lowercased().contains("not available") {
return "Model not available. Select a different model (\u{2318}M)."
}
if desc.contains("500") || desc.contains("502") || desc.contains("503") {
return "Server error. The provider may be experiencing issues. Try again shortly."
}
// Timeout patterns
if desc.lowercased().contains("timed out") || desc.lowercased().contains("timeout") {
return "Request timed out. Try a shorter message or different model."
}
// Fallback
return desc
}
// MARK: - Helpers
private func showModelInfo(_ model: ModelInfo) {
modelInfoTarget = model
}
private func exportConversation(format: String, filename: String) {
let chatMessages = messages.filter { $0.role != .system }
guard !chatMessages.isEmpty else {
showSystemMessage("Nothing to export — no messages")
return
}
let content: String
switch format {
case "md", "markdown":
content = chatMessages.map { msg in
let header = msg.role == .user ? "**User**" : "**Assistant**"
return "\(header)\n\n\(msg.content)"
}.joined(separator: "\n\n---\n\n")
case "json":
let dicts = chatMessages.map { msg -> [String: String] in
["role": msg.role.rawValue, "content": msg.content]
}
if let data = try? JSONSerialization.data(withJSONObject: dicts, options: .prettyPrinted),
let json = String(data: data, encoding: .utf8) {
content = json
} else {
showSystemMessage("Failed to encode JSON")
return
}
default:
showSystemMessage("Unsupported format: \(format). Use md or json.")
return
}
// Write to Downloads folder
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
let fileURL = downloads.appendingPathComponent(filename)
do {
try content.write(to: fileURL, atomically: true, encoding: .utf8)
showSystemMessage("Exported to \(fileURL.path)")
} catch {
showSystemMessage("Export failed: \(error.localizedDescription)")
}
}
}

View File

@@ -0,0 +1,80 @@
//
// ChatView.swift
// oAI
//
// Main chat interface
//
import SwiftUI
struct ChatView: View {
@Environment(ChatViewModel.self) var viewModel
let onModelSelect: () -> Void
let onProviderChange: (Settings.Provider) -> Void
var body: some View {
@Bindable var viewModel = viewModel
VStack(spacing: 0) {
// Header
HeaderView(
provider: viewModel.currentProvider,
model: viewModel.selectedModel,
stats: viewModel.sessionStats,
onlineMode: viewModel.onlineMode,
mcpEnabled: viewModel.mcpEnabled,
mcpStatus: viewModel.mcpStatus,
onModelSelect: onModelSelect,
onProviderChange: onProviderChange
)
// Messages
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
ForEach(viewModel.messages) { message in
MessageRow(message: message)
.id(message.id)
}
// Invisible bottom anchor for auto-scroll
Color.clear
.frame(height: 1)
.id("bottom")
}
.padding()
}
.background(Color.oaiBackground)
.onChange(of: viewModel.messages.count) {
withAnimation {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
.onChange(of: viewModel.messages.last?.content) {
// Auto-scroll as streaming content arrives
if viewModel.isGenerating {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
}
// Input bar
InputBar(
text: $viewModel.inputText,
isGenerating: viewModel.isGenerating,
mcpStatus: viewModel.mcpStatus,
onlineMode: viewModel.onlineMode,
onSend: viewModel.sendMessage,
onCancel: viewModel.cancelGeneration
)
// Footer
FooterView(stats: viewModel.sessionStats)
}
.background(Color.oaiBackground)
}
}
#Preview {
ChatView(onModelSelect: {}, onProviderChange: { _ in })
.environment(ChatViewModel())
}

View File

@@ -0,0 +1,150 @@
//
// ContentView.swift
// oAI
//
// Root navigation container
//
import SwiftUI
struct ContentView: View {
@Environment(ChatViewModel.self) var chatViewModel
var body: some View {
@Bindable var vm = chatViewModel
NavigationStack {
ChatView(
onModelSelect: { chatViewModel.showModelSelector = true },
onProviderChange: { newProvider in
chatViewModel.changeProvider(newProvider)
}
)
.navigationTitle("")
.toolbar {
#if os(macOS)
macOSToolbar
#endif
}
}
.frame(minWidth: 600, minHeight: 400)
#if os(macOS)
.onKeyPress(.return, phases: .down) { press in
if press.modifiers.contains(.command) {
chatViewModel.sendMessage()
return .handled
}
return .ignored
}
#endif
.sheet(isPresented: $vm.showModelSelector) {
ModelSelectorView(
models: chatViewModel.availableModels,
selectedModel: chatViewModel.selectedModel,
onSelect: { model in
chatViewModel.selectedModel = model
chatViewModel.showModelSelector = false
}
)
.task {
if chatViewModel.availableModels.count <= 10 {
await chatViewModel.loadAvailableModels()
}
}
}
.sheet(isPresented: $vm.showSettings, onDismiss: {
chatViewModel.syncFromSettings()
}) {
SettingsView()
}
.sheet(isPresented: $vm.showStats) {
StatsView(
stats: chatViewModel.sessionStats,
model: chatViewModel.selectedModel,
provider: chatViewModel.currentProvider
)
}
.sheet(isPresented: $vm.showHelp) {
HelpView()
}
.sheet(isPresented: $vm.showCredits) {
CreditsView(provider: chatViewModel.currentProvider)
}
.sheet(isPresented: $vm.showConversations) {
ConversationListView(onLoad: { conversation in
chatViewModel.loadConversation(conversation)
})
}
.sheet(item: $vm.modelInfoTarget) { model in
ModelInfoView(model: model)
}
}
#if os(macOS)
@ToolbarContentBuilder
private var macOSToolbar: some ToolbarContent {
ToolbarItemGroup(placement: .automatic) {
// New conversation
Button(action: { chatViewModel.newConversation() }) {
Label("New Chat", systemImage: "square.and.pencil")
}
.keyboardShortcut("n", modifiers: .command)
.help("New conversation")
Button(action: { chatViewModel.showConversations = true }) {
Label("History", systemImage: "clock.arrow.circlepath")
}
.keyboardShortcut("l", modifiers: .command)
.help("Saved conversations (Cmd+L)")
Spacer()
Button(action: { chatViewModel.showModelSelector = true }) {
Label("Model", systemImage: "cpu")
}
.keyboardShortcut("m", modifiers: .command)
.help("Select AI model (Cmd+M)")
Button(action: {
if let model = chatViewModel.selectedModel {
chatViewModel.modelInfoTarget = model
}
}) {
Label("Model Info", systemImage: "info.circle")
}
.keyboardShortcut("i", modifiers: .command)
.help("Model info (Cmd+I)")
.disabled(chatViewModel.selectedModel == nil)
Button(action: { chatViewModel.showStats = true }) {
Label("Stats", systemImage: "chart.bar")
}
.keyboardShortcut("s", modifiers: .command)
.help("Session statistics (Cmd+S)")
Button(action: { chatViewModel.showCredits = true }) {
Label("Credits", systemImage: "creditcard")
}
.help("Check API credits")
Spacer()
Button(action: { chatViewModel.showSettings = true }) {
Label("Settings", systemImage: "gearshape")
}
.keyboardShortcut(",", modifiers: .command)
.help("Settings (Cmd+,)")
Button(action: { chatViewModel.showHelp = true }) {
Label("Help", systemImage: "questionmark.circle")
}
.keyboardShortcut("/", modifiers: .command)
.help("Help & commands (Cmd+/)")
}
}
#endif
}
#Preview {
ContentView()
.environment(ChatViewModel())
}

View File

@@ -0,0 +1,91 @@
//
// FooterView.swift
// oAI
//
// Footer bar with session summary
//
import SwiftUI
struct FooterView: View {
let stats: SessionStats
var body: some View {
HStack(spacing: 20) {
// Session summary
HStack(spacing: 16) {
FooterItem(
icon: "message",
label: "Messages",
value: "\(stats.messageCount)"
)
FooterItem(
icon: "chart.bar.xaxis",
label: "Tokens",
value: "\(stats.totalTokens) (\(stats.totalInputTokens) in, \(stats.totalOutputTokens) out)"
)
FooterItem(
icon: "dollarsign.circle",
label: "Cost",
value: stats.totalCostDisplay
)
}
Spacer()
// Shortcuts hint
#if os(macOS)
Text("⌘M Model • ⌘K Clear • ⌘S Stats")
.font(.caption2)
.foregroundColor(.oaiSecondary)
#endif
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.ultraThinMaterial)
.overlay(
Rectangle()
.fill(Color.oaiBorder.opacity(0.5))
.frame(height: 1),
alignment: .top
)
}
}
struct FooterItem: View {
let icon: String
let label: String
let value: String
private let guiSize = SettingsService.shared.guiTextSize
var body: some View {
HStack(spacing: 6) {
Image(systemName: icon)
.font(.system(size: guiSize - 2))
.foregroundColor(.oaiSecondary)
Text(label + ":")
.font(.system(size: guiSize - 2))
.foregroundColor(.oaiSecondary)
Text(value)
.font(.system(size: guiSize - 2, weight: .medium))
.foregroundColor(.oaiPrimary)
}
}
}
#Preview {
VStack {
Spacer()
FooterView(stats: SessionStats(
totalInputTokens: 1250,
totalOutputTokens: 3420,
totalCost: 0.0152,
messageCount: 12
))
}
.background(Color.oaiBackground)
}

View File

@@ -0,0 +1,209 @@
//
// HeaderView.swift
// oAI
//
// Header bar with provider, model, and stats
//
import SwiftUI
struct HeaderView: View {
let provider: Settings.Provider
let model: ModelInfo?
let stats: SessionStats
let onlineMode: Bool
let mcpEnabled: Bool
let mcpStatus: String?
let onModelSelect: () -> Void
let onProviderChange: (Settings.Provider) -> Void
private let settings = SettingsService.shared
private let registry = ProviderRegistry.shared
var body: some View {
HStack(spacing: 12) {
// Provider picker dropdown only shows configured providers
Menu {
ForEach(registry.configuredProviders, id: \.self) { p in
Button {
onProviderChange(p)
} label: {
HStack {
Image(systemName: p.iconName)
Text(p.displayName)
if p == provider {
Image(systemName: "checkmark")
}
}
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: provider.iconName)
.font(.system(size: settings.guiTextSize - 2))
Text(provider.displayName)
.font(.system(size: settings.guiTextSize - 2, weight: .medium))
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 8))
.opacity(0.7)
}
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.providerColor(provider))
.cornerRadius(4)
}
.menuStyle(.borderlessButton)
.fixedSize()
.help("Switch provider")
// Model info (clickable model selector)
Button(action: onModelSelect) {
if let model = model {
HStack(spacing: 6) {
Text(model.name)
.font(.system(size: settings.guiTextSize, weight: .medium))
.foregroundColor(.oaiPrimary)
// Capability badges
HStack(spacing: 3) {
if model.capabilities.vision {
Image(systemName: "eye")
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
}
if model.capabilities.tools {
Image(systemName: "wrench")
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
}
if model.capabilities.online {
Image(systemName: "globe")
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
}
if model.capabilities.imageGeneration {
Image(systemName: "paintbrush")
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
}
}
Image(systemName: "chevron.down")
.font(.caption2)
.foregroundColor(.oaiSecondary)
}
} else {
HStack(spacing: 4) {
Text("No model selected")
.font(.system(size: settings.guiTextSize))
.foregroundColor(.oaiSecondary)
Image(systemName: "chevron.down")
.font(.caption2)
.foregroundColor(.oaiSecondary)
}
}
}
.buttonStyle(.plain)
.help("Select model")
Spacer()
// Status indicators
HStack(spacing: 8) {
if model?.capabilities.imageGeneration == true {
StatusPill(icon: "paintbrush", label: "Image", color: .purple)
}
if onlineMode {
StatusPill(icon: "globe", label: "Online", color: .green)
}
if mcpEnabled {
StatusPill(icon: "folder", label: "MCP", color: .blue)
}
}
// Divider between status and stats
if onlineMode || mcpEnabled || model?.capabilities.imageGeneration == true {
Divider()
.frame(height: 16)
.opacity(0.5)
}
// Quick stats
HStack(spacing: 16) {
StatItem(icon: "message", value: "\(stats.messageCount)")
StatItem(icon: "arrow.up.arrow.down", value: stats.totalTokensDisplay)
StatItem(icon: "dollarsign", value: stats.totalCostDisplay)
}
.font(.caption)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.ultraThinMaterial)
.overlay(
Rectangle()
.fill(Color.oaiBorder.opacity(0.5))
.frame(height: 1),
alignment: .bottom
)
}
}
struct StatItem: View {
let icon: String
let value: String
private let settings = SettingsService.shared
var body: some View {
HStack(spacing: 4) {
Image(systemName: icon)
.font(.system(size: settings.guiTextSize - 3))
.foregroundColor(.oaiSecondary)
Text(value)
.font(.system(size: settings.guiTextSize - 1, weight: .medium))
.foregroundColor(.oaiPrimary)
}
}
}
struct StatusPill: View {
let icon: String
let label: String
let color: Color
var body: some View {
HStack(spacing: 3) {
Circle()
.fill(color)
.frame(width: 6, height: 6)
Text(label)
.font(.system(size: 10, weight: .medium))
.foregroundColor(.oaiSecondary)
}
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(color.opacity(0.1), in: Capsule())
}
}
#Preview {
VStack {
HeaderView(
provider: .openrouter,
model: ModelInfo.mockModels.first,
stats: SessionStats(
totalInputTokens: 125,
totalOutputTokens: 434,
totalCost: 0.00111,
messageCount: 4
),
onlineMode: true,
mcpEnabled: true,
mcpStatus: "MCP",
onModelSelect: {},
onProviderChange: { _ in }
)
Spacer()
}
.background(Color.oaiBackground)
}

View File

@@ -0,0 +1,327 @@
//
// InputBar.swift
// oAI
//
// Message input bar with status indicators
//
import SwiftUI
struct InputBar: View {
@Binding var text: String
let isGenerating: Bool
let mcpStatus: String?
let onlineMode: Bool
let onSend: () -> Void
let onCancel: () -> Void
private let settings = SettingsService.shared
@State private var showCommandDropdown = false
@State private var selectedSuggestionIndex: Int = 0
@FocusState private var isInputFocused: Bool
/// Commands that execute immediately without additional arguments
private static let immediateCommands: Set<String> = [
"/help", "/model", "/clear", "/retry", "/stats", "/config",
"/settings", "/credits", "/list", "/load",
"/memory on", "/memory off", "/online on", "/online off",
"/mcp on", "/mcp off", "/mcp status", "/mcp list",
"/mcp write on", "/mcp write off",
"/export md", "/export json",
]
var body: some View {
VStack(spacing: 0) {
// Command dropdown (if showing)
if showCommandDropdown && text.hasPrefix("/") {
CommandSuggestionsView(
searchText: text,
selectedIndex: selectedSuggestionIndex,
onSelect: { command in
selectCommand(command)
}
)
.frame(maxHeight: 200)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
// Input area
HStack(alignment: .bottom, spacing: 12) {
// Status indicators
HStack(spacing: 6) {
if let mcp = mcpStatus {
StatusBadge(text: mcp, color: .blue)
}
if onlineMode {
StatusBadge(text: "🌐", color: .green)
}
}
.frame(width: 80, alignment: .leading)
// Text input
ZStack(alignment: .topLeading) {
if text.isEmpty {
Text("Type a message or / for commands...")
.font(.system(size: settings.inputTextSize))
.foregroundColor(.oaiSecondary)
.padding(.horizontal, 12)
.padding(.vertical, 10)
}
TextEditor(text: $text)
.font(.system(size: settings.inputTextSize))
.foregroundColor(.oaiPrimary)
.scrollContentBackground(.hidden)
.frame(minHeight: 44, maxHeight: 120)
.padding(.horizontal, 8)
.padding(.vertical, 6)
.focused($isInputFocused)
.onChange(of: text) {
showCommandDropdown = text.hasPrefix("/")
selectedSuggestionIndex = 0
}
#if os(macOS)
.onKeyPress(.upArrow) {
guard showCommandDropdown else { return .ignored }
if selectedSuggestionIndex > 0 {
selectedSuggestionIndex -= 1
}
return .handled
}
.onKeyPress(.downArrow) {
guard showCommandDropdown else { return .ignored }
let count = CommandSuggestionsView.filteredCommands(for: text).count
if selectedSuggestionIndex < count - 1 {
selectedSuggestionIndex += 1
}
return .handled
}
.onKeyPress(.escape) {
if showCommandDropdown {
showCommandDropdown = false
return .handled
}
return .ignored
}
.onKeyPress(.return) {
// If command dropdown is showing, select the highlighted command
if showCommandDropdown {
let suggestions = CommandSuggestionsView.filteredCommands(for: text)
if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count {
selectCommand(suggestions[selectedSuggestionIndex].command)
return .handled
}
}
// Plain Return on single line: send
if !text.contains("\n") && !text.isEmpty {
onSend()
return .handled
}
// Otherwise: let system handle (insert newline)
return .ignored
}
#endif
}
.background(Color.oaiSurface)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1)
)
// Action buttons
VStack(spacing: 8) {
#if os(macOS)
// File attach button
Button(action: pickFile) {
Image(systemName: "paperclip")
.font(.title2)
.foregroundColor(.oaiPrimary.opacity(0.7))
}
.buttonStyle(.plain)
.help("Attach file")
#endif
if isGenerating {
Button(action: onCancel) {
Image(systemName: "stop.circle.fill")
.font(.title)
.foregroundColor(.oaiError.opacity(0.9))
}
.buttonStyle(.plain)
.help("Stop generation")
} else {
Button(action: onSend) {
Image(systemName: "arrow.up.circle.fill")
.font(.title)
.foregroundColor(text.isEmpty ? .oaiPrimary.opacity(0.4) : .oaiAccent.opacity(0.9))
}
.buttonStyle(.plain)
.disabled(text.isEmpty)
.help("Send message")
}
}
.frame(width: 40)
}
.padding()
.background(Color.oaiSurface)
}
.onAppear {
isInputFocused = true
}
}
private func selectCommand(_ command: String) {
showCommandDropdown = false
if Self.immediateCommands.contains(command) {
// Execute immediately
text = command
onSend()
} else {
// Put in input for user to complete
text = command + " "
}
}
#if os(macOS)
private func pickFile() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.message = "Select files to attach"
guard panel.runModal() == .OK else { return }
let paths = panel.urls.map { $0.path }
let attachmentText = paths.map { "@\($0)" }.joined(separator: " ")
if text.isEmpty {
text = attachmentText + " "
} else {
text += " " + attachmentText
}
}
#endif
}
struct StatusBadge: View {
let text: String
let color: Color
var body: some View {
Text(text)
.font(.caption)
.foregroundColor(color)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(color.opacity(0.15))
.cornerRadius(4)
}
}
struct CommandSuggestionsView: View {
let searchText: String
let selectedIndex: Int
let onSelect: (String) -> Void
static let allCommands: [(command: String, description: String)] = [
("/help", "Show help and available commands"),
("/model", "Select AI model"),
("/clear", "Clear chat history"),
("/retry", "Retry last message"),
("/memory on", "Enable conversation memory"),
("/memory off", "Disable conversation memory"),
("/online on", "Enable web search"),
("/online off", "Disable web search"),
("/stats", "Show session statistics"),
("/config", "Open settings"),
("/provider", "Switch AI provider"),
("/save", "Save conversation"),
("/load", "Load conversation"),
("/list", "List saved conversations"),
("/export md", "Export as Markdown"),
("/export json", "Export as JSON"),
("/info", "Show model information"),
("/credits", "Check account credits"),
("/mcp on", "Enable MCP (file access)"),
("/mcp off", "Disable MCP"),
("/mcp status", "Show MCP status"),
("/mcp list", "List MCP folders"),
("/mcp add", "Add folder for MCP"),
("/mcp write on", "Enable MCP write permissions"),
("/mcp write off", "Disable MCP write permissions"),
]
static func filteredCommands(for searchText: String) -> [(command: String, description: String)] {
let search = searchText.lowercased()
return allCommands.filter { $0.command.contains(search) || search == "/" }
}
private var suggestions: [(command: String, description: String)] {
Self.filteredCommands(for: searchText)
}
var body: some View {
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 0) {
ForEach(Array(suggestions.enumerated()), id: \.element.command) { index, suggestion in
Button(action: { onSelect(suggestion.command) }) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(suggestion.command)
.font(.body)
.foregroundColor(.oaiPrimary)
Text(suggestion.description)
.font(.caption)
.foregroundColor(.oaiSecondary)
}
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(index == selectedIndex ? Color.oaiAccent.opacity(0.2) : Color.oaiSurface)
}
.buttonStyle(.plain)
.id(suggestion.command)
if index < suggestions.count - 1 {
Divider()
.background(Color.oaiBorder)
}
}
}
}
.onChange(of: selectedIndex) {
if selectedIndex < suggestions.count {
withAnimation {
proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center)
}
}
}
}
.background(Color.oaiSurface)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.oaiBorder, lineWidth: 1)
)
.padding(.horizontal)
}
}
#Preview {
VStack {
Spacer()
InputBar(
text: .constant(""),
isGenerating: false,
mcpStatus: "📁 Files",
onlineMode: true,
onSend: {},
onCancel: {}
)
}
.background(Color.oaiBackground)
}

View File

@@ -0,0 +1,183 @@
//
// MarkdownContentView.swift
// oAI
//
// Renders markdown content with syntax-highlighted code blocks
//
import SwiftUI
#if canImport(AppKit)
import AppKit
#endif
struct MarkdownContentView: View {
let content: String
let fontSize: Double
var body: some View {
let segments = parseSegments(content)
VStack(alignment: .leading, spacing: 8) {
ForEach(segments.indices, id: \.self) { index in
switch segments[index] {
case .text(let text):
if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
markdownText(text)
}
case .codeBlock(let language, let code):
CodeBlockView(language: language, code: code, fontSize: fontSize)
}
}
}
}
@ViewBuilder
private func markdownText(_ text: String) -> some View {
if let attrString = try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) {
Text(attrString)
.font(.system(size: fontSize))
.foregroundColor(.oaiPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
} else {
Text(text)
.font(.system(size: fontSize))
.foregroundColor(.oaiPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
}
// MARK: - Parsing
enum Segment {
case text(String)
case codeBlock(language: String?, code: String)
}
private func parseSegments(_ content: String) -> [Segment] {
var segments: [Segment] = []
let lines = content.components(separatedBy: "\n")
var currentText = ""
var inCodeBlock = false
var codeLanguage: String? = nil
var codeContent = ""
for line in lines {
if !inCodeBlock && line.hasPrefix("```") {
// Start of code block
if !currentText.isEmpty {
segments.append(.text(currentText))
currentText = ""
}
inCodeBlock = true
let lang = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
codeLanguage = lang.isEmpty ? nil : lang
codeContent = ""
} else if inCodeBlock && line.hasPrefix("```") {
// End of code block
// Remove trailing newline from code
if codeContent.hasSuffix("\n") {
codeContent = String(codeContent.dropLast())
}
segments.append(.codeBlock(language: codeLanguage, code: codeContent))
inCodeBlock = false
codeLanguage = nil
codeContent = ""
} else if inCodeBlock {
codeContent += line + "\n"
} else {
currentText += line + "\n"
}
}
// Handle unclosed code block
if inCodeBlock {
if codeContent.hasSuffix("\n") {
codeContent = String(codeContent.dropLast())
}
segments.append(.codeBlock(language: codeLanguage, code: codeContent))
}
// Remaining text
if !currentText.isEmpty {
// Remove trailing newline
if currentText.hasSuffix("\n") {
currentText = String(currentText.dropLast())
}
if !currentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
segments.append(.text(currentText))
}
}
return segments
}
}
// MARK: - Code Block View
struct CodeBlockView: View {
let language: String?
let code: String
let fontSize: Double
@State private var copied = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header bar with language label and copy button
HStack {
if let lang = language, !lang.isEmpty {
Text(lang)
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(Color(hex: "#888888"))
}
Spacer()
Button(action: copyCode) {
HStack(spacing: 4) {
Image(systemName: copied ? "checkmark" : "doc.on.doc")
.font(.system(size: 11))
if copied {
Text("Copied!")
.font(.system(size: 11))
}
}
.foregroundColor(copied ? .green : Color(hex: "#888888"))
}
.buttonStyle(.plain)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(hex: "#2d2d2d"))
// Code content
ScrollView(.horizontal, showsIndicators: true) {
Text(SyntaxHighlighter.highlight(code: code, language: language))
.font(.system(size: fontSize - 1, design: .monospaced))
.textSelection(.enabled)
.padding(12)
}
}
.background(Color(hex: "#1e1e1e"))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color(hex: "#3e3e3e"), lineWidth: 1)
)
}
private func copyCode() {
#if os(macOS)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(code, forType: .string)
#endif
withAnimation {
copied = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation {
copied = false
}
}
}
}

View File

@@ -0,0 +1,278 @@
//
// MessageRow.swift
// oAI
//
// Individual message display
//
import SwiftUI
#if canImport(AppKit)
import AppKit
#endif
struct MessageRow: View {
let message: Message
private let settings = SettingsService.shared
#if os(macOS)
@State private var isHovering = false
@State private var showCopied = false
#endif
var body: some View {
HStack(alignment: .top, spacing: 12) {
// Role icon
roleIcon
.frame(square: 32)
VStack(alignment: .leading, spacing: 8) {
// Header
HStack {
Text(message.role.displayName)
.font(.headline)
.foregroundColor(Color.messageColor(for: message.role))
Spacer()
#if os(macOS)
// Copy button (assistant messages only, visible on hover)
if message.role == .assistant && isHovering && !message.content.isEmpty {
Button(action: copyContent) {
HStack(spacing: 3) {
Image(systemName: showCopied ? "checkmark" : "doc.on.doc")
.font(.system(size: 11))
if showCopied {
Text("Copied!")
.font(.system(size: 11))
}
}
.foregroundColor(showCopied ? .green : .oaiSecondary)
}
.buttonStyle(.plain)
.transition(.opacity)
}
#endif
Text(message.timestamp, style: .time)
.font(.caption2)
.foregroundColor(.oaiSecondary)
}
// Content
if !message.content.isEmpty {
messageContent
}
// Generated images
if let images = message.generatedImages, !images.isEmpty {
GeneratedImagesView(images: images)
}
// File attachments
if let attachments = message.attachments, !attachments.isEmpty {
VStack(alignment: .leading, spacing: 4) {
ForEach(attachments.indices, id: \.self) { index in
HStack(spacing: 6) {
Image(systemName: "paperclip")
.font(.caption)
Text(attachments[index].path)
.font(.caption)
.foregroundColor(.oaiSecondary)
}
}
}
.padding(.top, 4)
}
// Token/cost info
if let tokens = message.tokens, let cost = message.cost {
HStack(spacing: 8) {
Label("\(tokens)", systemImage: "chart.bar.xaxis")
Text("\u{2022}")
Text(String(format: "$%.4f", cost))
}
.font(.caption2)
.foregroundColor(.oaiSecondary)
}
}
}
.padding(12)
.background(Color.messageBackground(for: message.role))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(messageBorderColor, lineWidth: isErrorMessage ? 2 : 2)
)
#if os(macOS)
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.15)) {
isHovering = hovering
}
}
#endif
}
// MARK: - Message Content
@ViewBuilder
private var messageContent: some View {
switch message.role {
case .assistant:
MarkdownContentView(content: message.content, fontSize: settings.dialogTextSize)
case .system:
if isErrorMessage {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.oaiError)
.font(.system(size: settings.dialogTextSize))
Text(message.content)
.font(.system(size: settings.dialogTextSize))
.foregroundColor(.oaiPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
} else {
Text(message.content)
.font(.system(size: settings.dialogTextSize))
.foregroundColor(.oaiPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
case .user:
MarkdownContentView(content: message.content, fontSize: settings.dialogTextSize)
}
}
// MARK: - Error Detection
private var isErrorMessage: Bool {
message.role == .system && message.content.hasPrefix("\u{274C}")
}
private var messageBorderColor: Color {
if isErrorMessage {
return .oaiError.opacity(0.5)
}
return Color.messageColor(for: message.role).opacity(0.3)
}
// MARK: - Copy
#if os(macOS)
private func copyContent() {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(message.content, forType: .string)
withAnimation {
showCopied = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation {
showCopied = false
}
}
}
#endif
private var roleIcon: some View {
Image(systemName: message.role.iconName)
.font(.title3)
.foregroundColor(Color.messageColor(for: message.role))
.frame(width: 32, height: 32)
.background(
Circle()
.fill(Color.messageColor(for: message.role).opacity(0.15))
)
}
}
// MARK: - Generated Images Display
struct GeneratedImagesView: View {
let images: [Data]
@State private var savedMessage: String?
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(images.indices, id: \.self) { index in
if let nsImage = platformImage(from: images[index]) {
VStack(alignment: .leading, spacing: 4) {
#if os(macOS)
Image(nsImage: nsImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 512, maxHeight: 512)
.cornerRadius(8)
.shadow(color: .black.opacity(0.3), radius: 4)
.contextMenu {
Button("Save to Downloads") {
saveImage(data: images[index], index: index)
}
Button("Copy Image") {
copyImage(nsImage)
}
}
#else
Image(uiImage: nsImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 512, maxHeight: 512)
.cornerRadius(8)
#endif
}
}
}
if let msg = savedMessage {
Text(msg)
.font(.caption2)
.foregroundColor(.green)
.transition(.opacity)
}
}
}
#if os(macOS)
private func platformImage(from data: Data) -> NSImage? {
NSImage(data: data)
}
private func saveImage(data: Data, index: Int) {
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
let timestamp = Int(Date().timeIntervalSince1970)
let filename = "oai_image_\(timestamp)_\(index).png"
let fileURL = downloads.appendingPathComponent(filename)
do {
try data.write(to: fileURL)
withAnimation { savedMessage = "Saved to \(filename)" }
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
withAnimation { savedMessage = nil }
}
} catch {
withAnimation { savedMessage = "Failed to save: \(error.localizedDescription)" }
}
}
private func copyImage(_ image: NSImage) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.writeObjects([image])
}
#else
private func platformImage(from data: Data) -> UIImage? {
UIImage(data: data)
}
#endif
}
#Preview {
VStack(spacing: 12) {
MessageRow(message: Message.mockUser1)
MessageRow(message: Message.mockAssistant1)
MessageRow(message: Message.mockSystem)
}
.padding()
.background(Color.oaiBackground)
}

View File

@@ -0,0 +1,72 @@
//
// AboutView.swift
// oAI
//
// About modal with app icon and version info
//
import SwiftUI
struct AboutView: View {
@Environment(\.dismiss) var dismiss
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
}
private var buildNumber: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
}
var body: some View {
VStack(spacing: 16) {
Spacer().frame(height: 8)
Image("AppLogo")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 128, height: 128)
.clipShape(RoundedRectangle(cornerRadius: 24))
.shadow(color: .cyan.opacity(0.3), radius: 12)
Text("oAI")
.font(.system(size: 28, weight: .bold))
Text("Version \(appVersion) (\(buildNumber))")
.font(.callout)
.foregroundStyle(.secondary)
Text("Multi-provider AI chat client")
.font(.subheadline)
.foregroundStyle(.secondary)
Divider()
.padding(.horizontal, 40)
VStack(spacing: 4) {
Text("&copy; 2026 [Rune Olsen](https://blog.rune.pm)")
.font(.caption)
.foregroundStyle(.secondary)
Text("Built with SwiftUI")
.font(.caption)
.foregroundStyle(.tertiary)
}
Spacer().frame(height: 4)
Button("OK") { dismiss() }
.keyboardShortcut(.return, modifiers: [])
.keyboardShortcut(.escape, modifiers: [])
.buttonStyle(.borderedProminent)
.controlSize(.regular)
Spacer().frame(height: 20)
}
.frame(width: 320, height: 370)
}
}
#Preview {
AboutView()
}

View File

@@ -0,0 +1,189 @@
//
// ConversationListView.swift
// oAI
//
// Saved conversations list
//
import os
import SwiftUI
struct ConversationListView: View {
@Environment(\.dismiss) var dismiss
@State private var searchText = ""
@State private var conversations: [Conversation] = []
var onLoad: ((Conversation) -> Void)?
private var filteredConversations: [Conversation] {
if searchText.isEmpty {
return conversations
}
return conversations.filter {
$0.name.lowercased().contains(searchText.lowercased())
}
}
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Conversations")
.font(.system(size: 18, weight: .bold))
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.keyboardShortcut(.escape, modifiers: [])
}
.padding(.horizontal, 24)
.padding(.top, 20)
.padding(.bottom, 12)
// Search bar
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search conversations...", text: $searchText)
.textFieldStyle(.plain)
if !searchText.isEmpty {
Button { searchText = "" } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
}
}
.padding(10)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
.padding(.horizontal, 24)
.padding(.bottom, 12)
Divider()
// Content
if filteredConversations.isEmpty {
Spacer()
VStack(spacing: 8) {
Image(systemName: searchText.isEmpty ? "tray" : "magnifyingglass")
.font(.largeTitle)
.foregroundStyle(.tertiary)
Text(searchText.isEmpty ? "No Saved Conversations" : "No Matches")
.font(.headline)
.foregroundStyle(.secondary)
Text(searchText.isEmpty ? "Conversations you save will appear here" : "Try a different search term")
.font(.caption)
.foregroundStyle(.tertiary)
}
Spacer()
} else {
List {
ForEach(filteredConversations) { conversation in
ConversationRow(conversation: conversation)
.contentShape(Rectangle())
.onTapGesture {
onLoad?(conversation)
dismiss()
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
deleteConversation(conversation)
} label: {
Label("Delete", systemImage: "trash")
}
Button {
exportConversation(conversation)
} label: {
Label("Export", systemImage: "square.and.arrow.up")
}
.tint(.blue)
}
}
}
.listStyle(.plain)
}
Divider()
// Bottom bar
HStack {
Spacer()
Button("Done") { dismiss() }
.keyboardShortcut(.return, modifiers: [])
.buttonStyle(.borderedProminent)
.controlSize(.regular)
Spacer()
}
.padding(.horizontal, 24)
.padding(.vertical, 12)
}
.onAppear {
loadConversations()
}
.frame(minWidth: 500, minHeight: 400)
}
private func loadConversations() {
do {
conversations = try DatabaseService.shared.listConversations()
} catch {
Log.db.error("Failed to load conversations: \(error.localizedDescription)")
conversations = []
}
}
private func deleteConversation(_ conversation: Conversation) {
do {
let _ = try DatabaseService.shared.deleteConversation(id: conversation.id)
withAnimation {
conversations.removeAll { $0.id == conversation.id }
}
} catch {
Log.db.error("Failed to delete conversation: \(error.localizedDescription)")
}
}
private func exportConversation(_ conversation: Conversation) {
guard let (_, loadedMessages) = try? DatabaseService.shared.loadConversation(id: conversation.id),
!loadedMessages.isEmpty else {
return
}
let content = loadedMessages.map { msg in
let header = msg.role == .user ? "**User**" : "**Assistant**"
return "\(header)\n\n\(msg.content)"
}.joined(separator: "\n\n---\n\n")
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
let filename = conversation.name.replacingOccurrences(of: " ", with: "_") + ".md"
let fileURL = downloads.appendingPathComponent(filename)
try? content.write(to: fileURL, atomically: true, encoding: .utf8)
}
}
struct ConversationRow: View {
let conversation: Conversation
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(conversation.name)
.font(.headline)
HStack(spacing: 8) {
Label("\(conversation.messageCount)", systemImage: "message")
Text("\u{2022}")
Text(conversation.updatedAt, style: .relative)
}
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
}
}
#Preview {
ConversationListView()
}

View File

@@ -0,0 +1,160 @@
//
// CreditsView.swift
// oAI
//
// Account credits and balance
//
import SwiftUI
struct CreditsView: View {
let provider: Settings.Provider
@Environment(\.dismiss) var dismiss
@State private var credits: Credits?
@State private var isLoading = false
@State private var errorMessage: String?
var body: some View {
NavigationStack {
VStack(spacing: 24) {
// Provider icon
Image(systemName: provider.iconName)
.font(.system(size: 60))
.foregroundColor(Color.providerColor(provider))
Text(provider.displayName)
.font(.title2)
.fontWeight(.semibold)
Divider()
// Credits info based on provider
VStack(spacing: 16) {
switch provider {
case .openrouter:
openRouterCreditsView
case .anthropic:
Text("Anthropic Balance")
.font(.headline)
Text("Check your balance at:")
.font(.caption)
.foregroundColor(.secondary)
Link("console.anthropic.com", destination: URL(string: "https://console.anthropic.com")!)
.font(.body)
case .openai:
Text("OpenAI Balance")
.font(.headline)
Text("Check your usage at:")
.font(.caption)
.foregroundColor(.secondary)
Link("platform.openai.com", destination: URL(string: "https://platform.openai.com/usage")!)
.font(.body)
case .ollama:
Text("Ollama (Local)")
.font(.headline)
Text("Running locally — no credits needed!")
.font(.body)
.foregroundColor(.secondary)
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 40))
.foregroundColor(.green)
.padding(.top)
}
}
Spacer()
}
.padding()
.navigationTitle("Credits")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
.task {
await fetchCredits()
}
}
// MARK: - OpenRouter Credits
@ViewBuilder
private var openRouterCreditsView: some View {
Text("OpenRouter Credits")
.font(.headline)
if isLoading {
ProgressView("Loading...")
.padding()
} else if let error = errorMessage {
Text(error)
.font(.caption)
.foregroundColor(.red)
.padding()
Button("Retry") {
Task { await fetchCredits() }
}
} else if let credits = credits {
VStack(spacing: 12) {
CreditRow(label: "Remaining", value: credits.balanceDisplay, highlight: true)
Divider()
if let limit = credits.limit {
CreditRow(label: "Total Credits", value: String(format: "$%.2f", limit))
}
if let usage = credits.usage {
CreditRow(label: "Used", value: String(format: "$%.2f", usage))
}
}
} else {
Text("No credit data available")
.font(.caption)
.foregroundColor(.secondary)
}
}
private func fetchCredits() async {
guard provider == .openrouter else { return }
guard let apiProvider = ProviderRegistry.shared.getCurrentProvider() else {
errorMessage = "No API key configured"
return
}
isLoading = true
errorMessage = nil
do {
credits = try await apiProvider.getCredits()
isLoading = false
} catch {
errorMessage = error.localizedDescription
isLoading = false
}
}
}
struct CreditRow: View {
let label: String
let value: String
var highlight: Bool = false
var body: some View {
HStack {
Text(label)
.foregroundColor(highlight ? .primary : .secondary)
.fontWeight(highlight ? .semibold : .regular)
Spacer()
Text(value)
.font(highlight ? .title2.monospacedDigit() : .body.monospacedDigit())
.fontWeight(highlight ? .bold : .medium)
.foregroundColor(highlight ? .green : .primary)
}
}
}
#Preview {
CreditsView(provider: .openrouter)
}

View File

@@ -0,0 +1,456 @@
//
// HelpView.swift
// oAI
//
// Help and commands reference with expandable detail and search
//
import SwiftUI
// MARK: - Data Model
struct CommandDetail: Identifiable {
let id = UUID()
let command: String
let brief: String
let detail: String
let examples: [String]
}
struct CommandCategory: Identifiable {
let id = UUID()
let name: String
let icon: String
let commands: [CommandDetail]
}
// MARK: - Help Data
private let helpCategories: [CommandCategory] = [
CommandCategory(name: "Chat", icon: "bubble.left.and.bubble.right", commands: [
CommandDetail(
command: "/clear",
brief: "Clear chat history",
detail: "Removes all messages from the current session and resets the conversation. This does not delete saved conversations.",
examples: ["/clear"]
),
CommandDetail(
command: "/retry",
brief: "Retry last message",
detail: "Resends your last message to the AI. Useful when you get an unsatisfactory response or encounter an error.",
examples: ["/retry"]
),
CommandDetail(
command: "/memory on|off",
brief: "Toggle conversation memory",
detail: "When enabled, the AI remembers all previous messages in the session. When disabled, each message is treated independently — only your latest message is sent.",
examples: ["/memory on", "/memory off"]
),
CommandDetail(
command: "/online on|off",
brief: "Toggle web search",
detail: "Enables or disables online mode. When on, the AI can search the web to find current information before responding.",
examples: ["/online on", "/online off"]
),
]),
CommandCategory(name: "Model & Provider", icon: "cpu", commands: [
CommandDetail(
command: "/model",
brief: "Select AI model",
detail: "Opens the model selector to browse and choose from available models. The list depends on your active provider and API key.",
examples: ["/model"]
),
CommandDetail(
command: "/provider [name]",
brief: "Switch AI provider",
detail: "Without arguments, shows the current provider. With a provider name, switches to that provider. Available providers: openrouter, anthropic, openai, ollama.",
examples: ["/provider", "/provider anthropic", "/provider openai"]
),
CommandDetail(
command: "/info [model]",
brief: "Show model information",
detail: "Displays details about the currently selected model, or a specific model if provided. Shows context length, pricing, and capabilities.",
examples: ["/info", "/info anthropic/claude-sonnet-4"]
),
CommandDetail(
command: "/credits",
brief: "Check account credits",
detail: "Shows your current balance and usage for the active provider (where supported, e.g. OpenRouter).",
examples: ["/credits"]
),
]),
CommandCategory(name: "Conversations", icon: "tray.full", commands: [
CommandDetail(
command: "/save <name>",
brief: "Save current conversation",
detail: "Saves all messages in the current session under the given name. You can load it later to continue the conversation.",
examples: ["/save my-project-chat", "/save debug session"]
),
CommandDetail(
command: "/load",
brief: "Load saved conversation",
detail: "Opens the conversation list to browse and load a previously saved conversation. Replaces the current chat.",
examples: ["/load"]
),
CommandDetail(
command: "/list",
brief: "List saved conversations",
detail: "Opens the conversation list showing all saved conversations with their dates and message counts.",
examples: ["/list"]
),
CommandDetail(
command: "/delete <name>",
brief: "Delete a saved conversation",
detail: "Permanently deletes a saved conversation by name. This cannot be undone.",
examples: ["/delete old-chat", "/delete test"]
),
CommandDetail(
command: "/export md|json",
brief: "Export conversation",
detail: "Exports the current conversation to a file. Supports Markdown (.md) and JSON (.json) formats. Optionally provide a custom filename.",
examples: ["/export md", "/export json", "/export md my-chat.md"]
),
]),
CommandCategory(name: "MCP (File Access)", icon: "folder.badge.gearshape", commands: [
CommandDetail(
command: "/mcp on|off",
brief: "Toggle file access",
detail: "Enables or disables MCP (Model Context Protocol), which gives the AI access to read (and optionally write) files in your allowed folders.",
examples: ["/mcp on", "/mcp off"]
),
CommandDetail(
command: "/mcp add <path>",
brief: "Add folder for access",
detail: "Grants the AI access to a folder on your filesystem. The AI can then read, list, and search files within it. Use absolute paths or ~.",
examples: ["/mcp add ~/Projects/myapp", "/mcp add /Users/me/Documents"]
),
CommandDetail(
command: "/mcp remove <index|path>",
brief: "Remove an allowed folder",
detail: "Revokes AI access to a folder. Specify by index number (from /mcp list) or by path.",
examples: ["/mcp remove 0", "/mcp remove ~/Projects/myapp"]
),
CommandDetail(
command: "/mcp list",
brief: "List allowed folders",
detail: "Shows all folders the AI currently has access to, with their index numbers.",
examples: ["/mcp list"]
),
CommandDetail(
command: "/mcp status",
brief: "Show MCP status",
detail: "Displays whether MCP is enabled, the number of registered folders, active permissions (read/write/delete/move), and gitignore setting.",
examples: ["/mcp status"]
),
CommandDetail(
command: "/mcp write on|off",
brief: "Toggle write permissions",
detail: "Quickly enables or disables all write permissions (write, edit, delete, create directories, move, copy). Fine-grained control is available in Settings > MCP.",
examples: ["/mcp write on", "/mcp write off"]
),
]),
CommandCategory(name: "Settings & Stats", icon: "gearshape", commands: [
CommandDetail(
command: "/config",
brief: "Open settings",
detail: "Opens the settings panel where you can configure providers, API keys, MCP permissions, appearance, and more. Also available via /settings.",
examples: ["/config", "/settings"]
),
CommandDetail(
command: "/stats",
brief: "Show session statistics",
detail: "Displays statistics for the current session: message count, total tokens used, estimated cost, and session duration.",
examples: ["/stats"]
),
]),
]
private let keyboardShortcuts: [(key: String, description: String)] = [
("Return", "Send message"),
("Shift + Return", "New line"),
("\u{2318}M", "Model Selector"),
("\u{2318}K", "Clear Chat"),
("\u{21E7}\u{2318}S", "Statistics"),
("\u{2318},", "Settings"),
("\u{2318}/", "Help"),
("\u{2318}L", "Conversations"),
]
// MARK: - HelpView
struct HelpView: View {
@Environment(\.dismiss) var dismiss
@State private var searchText = ""
@State private var expandedCommandID: UUID?
private var filteredCategories: [CommandCategory] {
if searchText.isEmpty { return helpCategories }
let q = searchText.lowercased()
return helpCategories.compactMap { cat in
let matched = cat.commands.filter {
$0.command.lowercased().contains(q) ||
$0.brief.lowercased().contains(q) ||
$0.detail.lowercased().contains(q) ||
$0.examples.contains { $0.lowercased().contains(q) }
}
return matched.isEmpty ? nil : CommandCategory(name: cat.name, icon: cat.icon, commands: matched)
}
}
private var matchingShortcuts: [(key: String, description: String)] {
if searchText.isEmpty { return keyboardShortcuts }
let q = searchText.lowercased()
return keyboardShortcuts.filter {
$0.key.lowercased().contains(q) || $0.description.lowercased().contains(q)
}
}
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Help")
.font(.system(size: 18, weight: .bold))
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.keyboardShortcut(.escape, modifiers: [])
}
.padding(.horizontal, 24)
.padding(.top, 20)
.padding(.bottom, 12)
// Search bar
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search commands, shortcuts, features...", text: $searchText)
.textFieldStyle(.plain)
if !searchText.isEmpty {
Button {
searchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
}
}
.padding(10)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
.padding(.horizontal, 24)
.padding(.bottom, 12)
Divider()
// Content
ScrollView {
VStack(alignment: .leading, spacing: 20) {
// Quick tip (only when not searching)
if searchText.isEmpty {
HStack(spacing: 10) {
Image(systemName: "lightbulb.fill")
.foregroundStyle(.yellow)
.font(.title3)
VStack(alignment: .leading, spacing: 2) {
Text("Type / in the input to see command suggestions")
.font(.callout)
Text("Use @filename to attach files to your message")
.font(.callout)
.foregroundStyle(.secondary)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.blue.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
}
// Command categories
ForEach(filteredCategories) { category in
CategorySection(
category: category,
expandedCommandID: $expandedCommandID
)
}
// Keyboard shortcuts
#if os(macOS)
if !matchingShortcuts.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Label("Keyboard Shortcuts", systemImage: "keyboard")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
VStack(spacing: 0) {
ForEach(Array(matchingShortcuts.enumerated()), id: \.offset) { idx, shortcut in
HStack {
Text(shortcut.key)
.font(.system(size: 12, weight: .medium, design: .monospaced))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 5))
Spacer()
Text(shortcut.description)
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
if idx < matchingShortcuts.count - 1 {
Divider().padding(.leading, 12)
}
}
}
.background(.background, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.quaternary))
}
}
#endif
// No results
if filteredCategories.isEmpty && matchingShortcuts.isEmpty {
VStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.largeTitle)
.foregroundStyle(.tertiary)
Text("No results for \"\(searchText)\"")
.font(.callout)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
Divider()
// Bottom bar
HStack {
Spacer()
Button("Done") { dismiss() }
.keyboardShortcut(.return, modifiers: [])
.buttonStyle(.borderedProminent)
.controlSize(.regular)
Spacer()
}
.padding(.horizontal, 24)
.padding(.vertical, 12)
}
.frame(minWidth: 520, idealWidth: 600, minHeight: 480, idealHeight: 700)
}
}
// MARK: - Category Section
private struct CategorySection: View {
let category: CommandCategory
@Binding var expandedCommandID: UUID?
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Label(category.name, systemImage: category.icon)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
VStack(spacing: 0) {
ForEach(Array(category.commands.enumerated()), id: \.element.id) { idx, cmd in
CommandRow(
command: cmd,
isExpanded: expandedCommandID == cmd.id,
onTap: {
withAnimation(.easeInOut(duration: 0.2)) {
expandedCommandID = expandedCommandID == cmd.id ? nil : cmd.id
}
}
)
if idx < category.commands.count - 1 {
Divider().padding(.leading, 12)
}
}
}
.background(.background, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.quaternary))
}
}
}
// MARK: - Command Row
private struct CommandRow: View {
let command: CommandDetail
let isExpanded: Bool
let onTap: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header (always visible)
Button(action: onTap) {
HStack(spacing: 10) {
Text(command.command)
.font(.system(size: 13, weight: .medium, design: .monospaced))
.foregroundStyle(.primary)
Text(command.brief)
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
}
.contentShape(Rectangle())
.padding(.horizontal, 12)
.padding(.vertical, 10)
}
.buttonStyle(.plain)
// Expanded detail
if isExpanded {
VStack(alignment: .leading, spacing: 10) {
Text(command.detail)
.font(.callout)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
if !command.examples.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Text(command.examples.count == 1 ? "Example" : "Examples")
.font(.caption)
.foregroundStyle(.secondary)
.fontWeight(.medium)
ForEach(command.examples, id: \.self) { example in
Text(example)
.font(.system(size: 12, design: .monospaced))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.blue.opacity(0.06), in: RoundedRectangle(cornerRadius: 6))
}
}
}
}
.padding(.horizontal, 12)
.padding(.bottom, 12)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
}
#Preview {
HelpView()
}

View File

@@ -0,0 +1,223 @@
//
// ModelInfoView.swift
// oAI
//
// Rich model information modal
//
import SwiftUI
struct ModelInfoView: View {
let model: ModelInfo
@Environment(\.dismiss) var dismiss
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Model Info")
.font(.system(size: 18, weight: .bold))
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.keyboardShortcut(.escape, modifiers: [])
}
.padding(.horizontal, 24)
.padding(.top, 20)
.padding(.bottom, 12)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 20) {
// Overview
sectionHeader("Overview")
infoRow("Name", model.name)
infoRow("ID", model.id)
if let provider = model.topProvider {
infoRow("Provider", provider)
}
if let desc = model.description {
VStack(alignment: .leading, spacing: 6) {
Text("Description")
.font(.subheadline.weight(.medium))
.foregroundColor(.secondary)
Text(desc)
.font(.body)
.foregroundColor(.primary)
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
}
.padding(.leading, 4)
}
Divider()
// Pricing
sectionHeader("Pricing")
infoRow("Input", model.promptPriceDisplay + " / 1M tokens")
infoRow("Output", model.completionPriceDisplay + " / 1M tokens")
if model.pricing.prompt > 0 {
VStack(alignment: .leading, spacing: 6) {
Text("Cost Examples")
.font(.caption)
.foregroundColor(.secondary)
HStack(spacing: 16) {
costExample(label: "1K tokens", inputTokens: 1_000)
costExample(label: "10K tokens", inputTokens: 10_000)
costExample(label: "100K tokens", inputTokens: 100_000)
}
}
.padding(.leading, 4)
}
Divider()
// Context Window
sectionHeader("Context Window")
infoRow("Max Tokens", model.contextLength.formatted())
if model.contextLength > 0 {
VStack(alignment: .leading, spacing: 4) {
let maxContext = 2_000_000.0
let fraction = min(Double(model.contextLength) / maxContext, 1.0)
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray.opacity(0.2))
.frame(height: 16)
GeometryReader { geo in
RoundedRectangle(cornerRadius: 4)
.fill(Color.blue)
.frame(width: geo.size.width * fraction, height: 16)
}
.frame(height: 16)
}
Text(model.contextLengthDisplay + " tokens")
.font(.caption2)
.foregroundColor(.secondary)
}
.padding(.leading, 4)
}
Divider()
// Capabilities
sectionHeader("Capabilities")
HStack(spacing: 12) {
capabilityBadge(icon: "eye.fill", label: "Vision", active: model.capabilities.vision)
capabilityBadge(icon: "wrench.fill", label: "Tools", active: model.capabilities.tools)
capabilityBadge(icon: "globe", label: "Online", active: model.capabilities.online)
capabilityBadge(icon: "photo.fill", label: "Image Gen", active: model.capabilities.imageGeneration)
}
// Architecture (if available)
if let arch = model.architecture {
Divider()
sectionHeader("Architecture")
if let modality = arch.modality {
infoRow("Modality", modality)
}
if let tokenizer = arch.tokenizer {
infoRow("Tokenizer", tokenizer)
}
if let instructType = arch.instructType {
infoRow("Instruct Type", instructType)
}
}
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
Divider()
// Bottom bar
HStack {
Spacer()
Button("Done") { dismiss() }
.keyboardShortcut(.return, modifiers: [])
.buttonStyle(.borderedProminent)
.controlSize(.regular)
Spacer()
}
.padding(.horizontal, 24)
.padding(.vertical, 12)
}
.frame(minWidth: 550, idealWidth: 650, minHeight: 550, idealHeight: 750)
}
// MARK: - Layout Helpers
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
}
private func infoRow(_ label: String, _ value: String) -> some View {
HStack {
Text(label)
.font(.body)
Spacer()
Text(value)
.font(.body)
.foregroundColor(.secondary)
.textSelection(.enabled)
}
.padding(.leading, 4)
}
@ViewBuilder
private func costExample(label: String, inputTokens: Int) -> some View {
let cost = (Double(inputTokens) * model.pricing.prompt / 1_000_000) +
(Double(inputTokens) * model.pricing.completion / 1_000_000)
VStack(spacing: 2) {
Text(label)
.font(.caption2)
.foregroundColor(.secondary)
Text(String(format: "$%.4f", cost))
.font(.caption.monospacedDigit())
.foregroundColor(.primary)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.gray.opacity(0.1))
.cornerRadius(4)
}
@ViewBuilder
private func capabilityBadge(icon: String, label: String, active: Bool) -> some View {
VStack(spacing: 4) {
Image(systemName: icon)
.font(.title3)
.foregroundColor(active ? .blue : .gray.opacity(0.4))
Text(label)
.font(.caption2)
.foregroundColor(active ? .primary : .secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(active ? Color.blue.opacity(0.1) : Color.gray.opacity(0.05))
.cornerRadius(8)
}
}
#Preview {
ModelInfoView(model: ModelInfo(
id: "anthropic/claude-sonnet-4",
name: "Claude Sonnet 4",
description: "Balanced intelligence and speed. This is a longer description to test how the modal handles multi-line text that wraps across several lines in the description field.",
contextLength: 200_000,
pricing: .init(prompt: 3.0, completion: 15.0),
capabilities: .init(vision: true, tools: true, online: false),
architecture: .init(tokenizer: "claude", instructType: "claude", modality: "text+image->text"),
topProvider: "anthropic"
))
}

View File

@@ -0,0 +1,222 @@
//
// ModelSelectorView.swift
// oAI
//
// Model selection screen
//
import SwiftUI
struct ModelSelectorView: View {
let models: [ModelInfo]
let selectedModel: ModelInfo?
let onSelect: (ModelInfo) -> Void
@Environment(\.dismiss) var dismiss
@State private var searchText = ""
@State private var filterVision = false
@State private var filterTools = false
@State private var filterOnline = false
@State private var filterImageGen = false
@State private var keyboardIndex: Int = -1
private var filteredModels: [ModelInfo] {
models.filter { model in
let matchesSearch = searchText.isEmpty ||
model.name.lowercased().contains(searchText.lowercased()) ||
model.id.lowercased().contains(searchText.lowercased())
let matchesVision = !filterVision || model.capabilities.vision
let matchesTools = !filterTools || model.capabilities.tools
let matchesOnline = !filterOnline || model.capabilities.online
let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen
}
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Search bar
TextField("Search models...", text: $searchText)
.textFieldStyle(.roundedBorder)
.padding()
.onChange(of: searchText) {
// Reset keyboard index when search changes
keyboardIndex = -1
}
// Filters
HStack(spacing: 12) {
FilterToggle(isOn: $filterVision, icon: "\u{1F441}\u{FE0F}", label: "Vision")
FilterToggle(isOn: $filterTools, icon: "\u{1F527}", label: "Tools")
FilterToggle(isOn: $filterOnline, icon: "\u{1F310}", label: "Online")
FilterToggle(isOn: $filterImageGen, icon: "\u{1F3A8}", label: "Image Gen")
}
.padding(.horizontal)
.padding(.bottom, 12)
Divider()
// Model list
if filteredModels.isEmpty {
ContentUnavailableView(
"No Models Found",
systemImage: "magnifyingglass",
description: Text("Try adjusting your search or filters")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollViewReader { proxy in
List(Array(filteredModels.enumerated()), id: \.element.id) { index, model in
ModelRowView(
model: model,
isSelected: model.id == selectedModel?.id,
isKeyboardHighlighted: index == keyboardIndex
)
.id(model.id)
.contentShape(Rectangle())
.onTapGesture {
onSelect(model)
}
}
.listStyle(.plain)
.onChange(of: keyboardIndex) { _, newIndex in
if newIndex >= 0 && newIndex < filteredModels.count {
withAnimation {
proxy.scrollTo(filteredModels[newIndex].id, anchor: .center)
}
}
}
}
}
}
.frame(minWidth: 600, minHeight: 500)
.navigationTitle("Select Model")
#if os(macOS)
.onKeyPress(.downArrow) {
if keyboardIndex < filteredModels.count - 1 {
keyboardIndex += 1
}
return .handled
}
.onKeyPress(.upArrow) {
if keyboardIndex > 0 {
keyboardIndex -= 1
} else if keyboardIndex == -1 && !filteredModels.isEmpty {
keyboardIndex = 0
}
return .handled
}
.onKeyPress(.return) {
if keyboardIndex >= 0 && keyboardIndex < filteredModels.count {
onSelect(filteredModels[keyboardIndex])
return .handled
}
return .ignored
}
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
.onAppear {
// Initialize keyboard index to current selection
if let selected = selectedModel,
let index = filteredModels.firstIndex(where: { $0.id == selected.id }) {
keyboardIndex = index
}
}
}
}
}
struct FilterToggle: View {
@Binding var isOn: Bool
let icon: String
let label: String
var body: some View {
Button(action: { isOn.toggle() }) {
HStack(spacing: 4) {
Text(icon)
Text(label)
}
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(isOn ? Color.blue.opacity(0.2) : Color.gray.opacity(0.1))
.foregroundColor(isOn ? .blue : .secondary)
.cornerRadius(6)
}
.buttonStyle(.plain)
}
}
struct ModelRowView: View {
let model: ModelInfo
let isSelected: Bool
var isKeyboardHighlighted: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(model.name)
.font(.headline)
.foregroundColor(isSelected ? .blue : .primary)
Spacer()
// Capabilities
HStack(spacing: 4) {
if model.capabilities.vision { Text("\u{1F441}\u{FE0F}").font(.caption) }
if model.capabilities.tools { Text("\u{1F527}").font(.caption) }
if model.capabilities.online { Text("\u{1F310}").font(.caption) }
if model.capabilities.imageGeneration { Text("\u{1F3A8}").font(.caption) }
}
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
}
}
Text(model.id)
.font(.caption)
.foregroundColor(.secondary)
if let description = model.description {
Text(description)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
}
HStack(spacing: 12) {
Label(model.contextLengthDisplay, systemImage: "text.alignleft")
Label(model.promptPriceDisplay + "/1M", systemImage: "dollarsign.circle")
}
.font(.caption2)
.foregroundColor(.secondary)
}
.padding(.vertical, 6)
.padding(.horizontal, isKeyboardHighlighted ? 4 : 0)
.background(
isKeyboardHighlighted
? RoundedRectangle(cornerRadius: 6).fill(Color.accentColor.opacity(0.15))
: nil
)
}
}
#Preview {
ModelSelectorView(
models: ModelInfo.mockModels,
selectedModel: ModelInfo.mockModels.first,
onSelect: { _ in }
)
}

View File

@@ -0,0 +1,500 @@
//
// SettingsView.swift
// oAI
//
// Settings and configuration screen
//
import SwiftUI
import UniformTypeIdentifiers
struct SettingsView: View {
@Environment(\.dismiss) var dismiss
@Bindable private var settingsService = SettingsService.shared
private var mcpService = MCPService.shared
@State private var openrouterKey = ""
@State private var anthropicKey = ""
@State private var openaiKey = ""
@State private var googleKey = ""
@State private var googleEngineID = ""
@State private var showFolderPicker = false
@State private var selectedTab = 0
// OAuth state
@State private var oauthCode = ""
@State private var oauthError: String?
@State private var showOAuthCodeField = false
private var oauthService = AnthropicOAuthService.shared
private let labelWidth: CGFloat = 140
var body: some View {
VStack(spacing: 0) {
// Title
Text("Settings")
.font(.system(size: 18, weight: .bold))
.padding(.top, 20)
.padding(.bottom, 12)
// Tab picker
Picker("", selection: $selectedTab) {
Text("General").tag(0)
Text("MCP").tag(1)
Text("Appearance").tag(2)
}
.pickerStyle(.segmented)
.padding(.horizontal, 24)
.padding(.bottom, 12)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 20) {
switch selectedTab {
case 0:
generalTab
case 1:
mcpTab
case 2:
appearanceTab
default:
generalTab
}
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
Divider()
// Bottom bar
HStack {
Spacer()
Button("Done") { dismiss() }
.keyboardShortcut(.return, modifiers: [])
.buttonStyle(.borderedProminent)
.controlSize(.regular)
Spacer()
}
.padding(.horizontal, 24)
.padding(.vertical, 12)
}
.frame(minWidth: 550, idealWidth: 600, minHeight: 500, idealHeight: 650)
}
// MARK: - General Tab
@ViewBuilder
private var generalTab: some View {
// Provider
sectionHeader("Provider")
row("Default Provider") {
Picker("", selection: $settingsService.defaultProvider) {
ForEach(ProviderRegistry.shared.configuredProviders, id: \.self) { provider in
Text(provider.displayName).tag(provider)
}
}
.labelsHidden()
.fixedSize()
}
divider()
// API Keys
sectionHeader("API Keys")
row("OpenRouter") {
SecureField("sk-or-...", text: $openrouterKey)
.textFieldStyle(.roundedBorder)
.onAppear { openrouterKey = settingsService.openrouterAPIKey ?? "" }
.onChange(of: openrouterKey) {
settingsService.openrouterAPIKey = openrouterKey.isEmpty ? nil : openrouterKey
ProviderRegistry.shared.clearCache()
}
}
// Anthropic: OAuth or API key
row("Anthropic") {
VStack(alignment: .leading, spacing: 8) {
if oauthService.isAuthenticated {
// Logged in via OAuth
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("Logged in via Claude Pro/Max")
.font(.subheadline)
Spacer()
Button("Logout") {
oauthService.logout()
ProviderRegistry.shared.clearCache()
}
.font(.subheadline)
.foregroundStyle(.red)
}
} else if showOAuthCodeField {
// Waiting for code paste
HStack(spacing: 8) {
TextField("Paste authorization code...", text: $oauthCode)
.textFieldStyle(.roundedBorder)
Button("Submit") {
Task { await submitOAuthCode() }
}
.disabled(oauthCode.isEmpty || oauthService.isLoggingIn)
Button("Cancel") {
showOAuthCodeField = false
oauthCode = ""
oauthError = nil
}
.font(.subheadline)
}
if let error = oauthError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
} else {
// Login button + API key field
HStack(spacing: 8) {
Button {
startOAuthLogin()
} label: {
HStack(spacing: 4) {
Image(systemName: "person.circle")
Text("Login with Claude Pro/Max")
}
.font(.subheadline)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
Text("or")
.font(.caption)
.foregroundStyle(.secondary)
}
SecureField("sk-ant-... (API key)", text: $anthropicKey)
.textFieldStyle(.roundedBorder)
.onAppear { anthropicKey = settingsService.anthropicAPIKey ?? "" }
.onChange(of: anthropicKey) {
settingsService.anthropicAPIKey = anthropicKey.isEmpty ? nil : anthropicKey
ProviderRegistry.shared.clearCache()
}
}
}
}
row("OpenAI") {
SecureField("sk-...", text: $openaiKey)
.textFieldStyle(.roundedBorder)
.onAppear { openaiKey = settingsService.openaiAPIKey ?? "" }
.onChange(of: openaiKey) {
settingsService.openaiAPIKey = openaiKey.isEmpty ? nil : openaiKey
ProviderRegistry.shared.clearCache()
}
}
row("Ollama URL") {
TextField("http://localhost:11434", text: $settingsService.ollamaBaseURL)
.textFieldStyle(.roundedBorder)
.help("Enter your Ollama server URL to enable the Ollama provider")
}
divider()
// Features
sectionHeader("Features")
row("") {
VStack(alignment: .leading, spacing: 6) {
Toggle("Online Mode (Web Search)", isOn: $settingsService.onlineMode)
Toggle("Conversation Memory", isOn: $settingsService.memoryEnabled)
Toggle("MCP (File Access)", isOn: $settingsService.mcpEnabled)
}
}
divider()
// Web Search
sectionHeader("Web Search")
row("Search Provider") {
Picker("", selection: $settingsService.searchProvider) {
ForEach(Settings.SearchProvider.allCases, id: \.self) { provider in
Text(provider.displayName).tag(provider)
}
}
.labelsHidden()
.fixedSize()
}
if settingsService.searchProvider == .google {
row("Google API Key") {
SecureField("", text: $googleKey)
.textFieldStyle(.roundedBorder)
.onAppear { googleKey = settingsService.googleAPIKey ?? "" }
.onChange(of: googleKey) {
settingsService.googleAPIKey = googleKey.isEmpty ? nil : googleKey
}
}
row("Search Engine ID") {
TextField("", text: $googleEngineID)
.textFieldStyle(.roundedBorder)
.onAppear { googleEngineID = settingsService.googleSearchEngineID ?? "" }
.onChange(of: googleEngineID) {
settingsService.googleSearchEngineID = googleEngineID.isEmpty ? nil : googleEngineID
}
}
}
divider()
// Model Settings
sectionHeader("Model Settings")
row("Default Model ID") {
TextField("e.g. anthropic/claude-sonnet-4", text: Binding(
get: { settingsService.defaultModel ?? "" },
set: { settingsService.defaultModel = $0.isEmpty ? nil : $0 }
))
.textFieldStyle(.roundedBorder)
}
row("") {
Toggle("Streaming Responses", isOn: $settingsService.streamEnabled)
}
divider()
// Logging
sectionHeader("Logging")
row("Log Level") {
Picker("", selection: Binding(
get: { FileLogger.shared.minimumLevel },
set: { FileLogger.shared.minimumLevel = $0 }
)) {
ForEach(LogLevel.allCases, id: \.self) { level in
Text(level.displayName).tag(level)
}
}
.labelsHidden()
.fixedSize()
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("Controls which messages are written to ~/Library/Logs/oAI.log")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
// MARK: - MCP Tab
@ViewBuilder
private var mcpTab: some View {
// Enable toggle
sectionHeader("MCP")
row("") {
Toggle("MCP Enabled (File Access)", isOn: $settingsService.mcpEnabled)
}
if settingsService.mcpEnabled {
divider()
// Folders
sectionHeader("Allowed Folders")
if mcpService.allowedFolders.isEmpty {
HStack {
Spacer().frame(width: labelWidth + 12)
Text("No folders added")
.foregroundStyle(.secondary)
.font(.subheadline)
}
} else {
ForEach(Array(mcpService.allowedFolders.enumerated()), id: \.offset) { index, folder in
HStack(spacing: 0) {
Spacer().frame(width: labelWidth + 12)
Image(systemName: "folder.fill")
.foregroundStyle(.blue)
.frame(width: 20)
VStack(alignment: .leading, spacing: 0) {
Text((folder as NSString).lastPathComponent)
.font(.body)
Text(abbreviatePath(folder))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.leading, 6)
Spacer()
Button {
withAnimation { _ = mcpService.removeFolder(at: index) }
} label: {
Image(systemName: "trash.fill")
.foregroundStyle(.red)
.font(.caption)
}
.buttonStyle(.plain)
}
}
}
HStack(spacing: 0) {
Spacer().frame(width: labelWidth + 12)
Button {
showFolderPicker = true
} label: {
HStack(spacing: 4) {
Image(systemName: "plus")
Text("Add Folder...")
}
.font(.subheadline)
}
.buttonStyle(.borderless)
Spacer()
}
.fileImporter(
isPresented: $showFolderPicker,
allowedContentTypes: [.folder],
allowsMultipleSelection: false
) { result in
if case .success(let urls) = result, let url = urls.first {
if url.startAccessingSecurityScopedResource() {
withAnimation { _ = mcpService.addFolder(url.path) }
url.stopAccessingSecurityScopedResource()
}
}
}
divider()
// Permissions
sectionHeader("Permissions")
row("") {
VStack(alignment: .leading, spacing: 6) {
Toggle("Write & Edit Files", isOn: $settingsService.mcpCanWriteFiles)
Toggle("Delete Files", isOn: $settingsService.mcpCanDeleteFiles)
Toggle("Create Directories", isOn: $settingsService.mcpCanCreateDirectories)
Toggle("Move & Copy Files", isOn: $settingsService.mcpCanMoveFiles)
}
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("Read access is always available when MCP is enabled. Write permissions must be explicitly enabled.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
divider()
// Filtering
sectionHeader("Filtering")
row("") {
Toggle("Respect .gitignore", isOn: Binding(
get: { settingsService.mcpRespectGitignore },
set: { newValue in
settingsService.mcpRespectGitignore = newValue
mcpService.reloadGitignores()
}
))
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("When enabled, listing and searching skip gitignored files. Write operations always ignore .gitignore.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
// MARK: - Appearance Tab
@ViewBuilder
private var appearanceTab: some View {
sectionHeader("Text Sizes")
row("GUI Text") {
HStack(spacing: 8) {
Slider(value: $settingsService.guiTextSize, in: 10...20, step: 1)
.frame(maxWidth: 200)
Text("\(Int(settingsService.guiTextSize)) pt")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 40)
}
}
row("Dialog Text") {
HStack(spacing: 8) {
Slider(value: $settingsService.dialogTextSize, in: 10...24, step: 1)
.frame(maxWidth: 200)
Text("\(Int(settingsService.dialogTextSize)) pt")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 40)
}
}
row("Input Text") {
HStack(spacing: 8) {
Slider(value: $settingsService.inputTextSize, in: 10...24, step: 1)
.frame(maxWidth: 200)
Text("\(Int(settingsService.inputTextSize)) pt")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 40)
}
}
}
// MARK: - Layout Helpers
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
}
private func row<Content: View>(_ label: String, @ViewBuilder content: () -> Content) -> some View {
HStack(alignment: .center, spacing: 12) {
Text(label)
.font(.body)
.frame(width: labelWidth, alignment: .trailing)
content()
}
}
private func divider() -> some View {
Divider().padding(.vertical, 2)
}
private func abbreviatePath(_ path: String) -> String {
let home = NSHomeDirectory()
if path.hasPrefix(home) {
return "~" + path.dropFirst(home.count)
}
return path
}
// MARK: - OAuth Helpers
private func startOAuthLogin() {
let url = oauthService.generateAuthorizationURL()
#if os(macOS)
NSWorkspace.shared.open(url)
#endif
showOAuthCodeField = true
oauthError = nil
oauthCode = ""
}
private func submitOAuthCode() async {
oauthService.isLoggingIn = true
oauthError = nil
do {
try await oauthService.exchangeCode(oauthCode)
showOAuthCodeField = false
oauthCode = ""
ProviderRegistry.shared.clearCache()
} catch {
oauthError = error.localizedDescription
}
oauthService.isLoggingIn = false
}
}
#Preview {
SettingsView()
}

View File

@@ -0,0 +1,148 @@
//
// StatsView.swift
// oAI
//
// Session statistics screen
//
import SwiftUI
struct StatsView: View {
let stats: SessionStats
let model: ModelInfo?
let provider: Settings.Provider
@Environment(\.dismiss) var dismiss
var body: some View {
NavigationStack {
List {
Section("Session Info") {
StatRow(label: "Provider", value: provider.displayName)
StatRow(label: "Model", value: model?.name ?? "None selected")
StatRow(label: "Messages", value: "\(stats.messageCount)")
}
Section("Token Usage") {
StatRow(label: "Input Tokens", value: stats.totalInputTokens.formatted())
StatRow(label: "Output Tokens", value: stats.totalOutputTokens.formatted())
StatRow(label: "Total Tokens", value: stats.totalTokens.formatted())
if stats.totalTokens > 0 {
HStack {
Text("Token Distribution")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
GeometryReader { geo in
HStack(spacing: 0) {
Rectangle()
.fill(Color.blue)
.frame(width: geo.size.width * CGFloat(stats.totalInputTokens) / CGFloat(stats.totalTokens))
Rectangle()
.fill(Color.green)
.frame(width: geo.size.width * CGFloat(stats.totalOutputTokens) / CGFloat(stats.totalTokens))
}
}
.frame(height: 20)
.cornerRadius(4)
}
}
}
Section("Costs") {
StatRow(label: "Total Cost", value: stats.totalCostDisplay)
if stats.messageCount > 0 {
StatRow(label: "Avg per Message", value: String(format: "$%.4f", stats.averageCostPerMessage))
}
}
if let model = model {
Section("Model Details") {
StatRow(label: "Context Length", value: model.contextLengthDisplay)
StatRow(label: "Prompt Price", value: model.promptPriceDisplay + "/1M tokens")
StatRow(label: "Completion Price", value: model.completionPriceDisplay + "/1M tokens")
HStack {
Text("Capabilities")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
HStack(spacing: 8) {
if model.capabilities.vision {
CapabilityBadge(icon: "👁️", label: "Vision")
}
if model.capabilities.tools {
CapabilityBadge(icon: "🔧", label: "Tools")
}
if model.capabilities.online {
CapabilityBadge(icon: "🌐", label: "Online")
}
}
}
}
}
}
#if os(iOS)
.listStyle(.insetGrouped)
#else
.listStyle(.sidebar)
#endif
.navigationTitle("Statistics")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
}
}
}
.frame(minWidth: 500, idealWidth: 550, minHeight: 450, idealHeight: 500)
}
}
}
struct StatRow: View {
let label: String
let value: String
var body: some View {
HStack {
Text(label)
.font(.body)
Spacer()
Text(value)
.font(.body.monospacedDigit())
.foregroundColor(.secondary)
}
}
}
struct CapabilityBadge: View {
let icon: String
let label: String
var body: some View {
HStack(spacing: 2) {
Text(icon)
Text(label)
}
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Color.blue.opacity(0.1))
.cornerRadius(4)
}
}
#Preview {
StatsView(
stats: SessionStats(
totalInputTokens: 1250,
totalOutputTokens: 3420,
totalCost: 0.0152,
messageCount: 12
),
model: ModelInfo.mockModels.first,
provider: .openrouter
)
}

12
oAI/oAI.entitlements Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<false/>
</dict>
</plist>

38
oAI/oAIApp.swift Normal file
View File

@@ -0,0 +1,38 @@
//
// oAIApp.swift
// oAI
//
// Main app entry point
//
import SwiftUI
@main
struct oAIApp: App {
@State private var chatViewModel = ChatViewModel()
@State private var showAbout = false
var body: some Scene {
WindowGroup {
ContentView()
.environment(chatViewModel)
.preferredColorScheme(.dark)
.sheet(isPresented: $showAbout) {
AboutView()
}
}
#if os(macOS)
.windowStyle(.hiddenTitleBar)
.windowToolbarStyle(.unified)
.defaultSize(width: 1024, height: 800)
.windowResizability(.contentMinSize)
.commands {
CommandGroup(replacing: .appInfo) {
Button("About oAI") {
showAbout = true
}
}
}
#endif
}
}