Initial commit
74
oAI/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
oAI/Assets.xcassets/AppIcon.appiconset/icon_1024.png
Normal file
|
After Width: | Height: | Size: 709 KiB |
BIN
oAI/Assets.xcassets/AppIcon.appiconset/icon_128.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
oAI/Assets.xcassets/AppIcon.appiconset/icon_16.png
Normal file
|
After Width: | Height: | Size: 755 B |
BIN
oAI/Assets.xcassets/AppIcon.appiconset/icon_256.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
oAI/Assets.xcassets/AppIcon.appiconset/icon_32.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
oAI/Assets.xcassets/AppIcon.appiconset/icon_512.png
Normal file
|
After Width: | Height: | Size: 214 KiB |
BIN
oAI/Assets.xcassets/AppIcon.appiconset/icon_64.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
oAI/Assets.xcassets/AppLogo.imageset/AppLogo.png
vendored
Normal file
|
After Width: | Height: | Size: 818 KiB |
13
oAI/Assets.xcassets/AppLogo.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppLogo.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
oAI/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
38
oAI/Models/Conversation.swift
Normal 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
@@ -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
@@ -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]
|
||||
}
|
||||
56
oAI/Models/ModelInfo.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
58
oAI/Models/SessionStats.swift
Normal 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
@@ -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
|
||||
)
|
||||
}
|
||||
241
oAI/Providers/AIProvider.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
534
oAI/Providers/AnthropicProvider.swift
Normal 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
|
||||
}
|
||||
}
|
||||
308
oAI/Providers/OllamaProvider.swift
Normal 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()
|
||||
)
|
||||
}
|
||||
}
|
||||
367
oAI/Providers/OpenAIProvider.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
313
oAI/Providers/OpenRouterModels.swift
Normal 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?
|
||||
}
|
||||
}
|
||||
433
oAI/Providers/OpenRouterProvider.swift
Normal 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
|
||||
}
|
||||
}
|
||||
102
oAI/Providers/ProviderRegistry.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
298
oAI/Services/AnthropicOAuthService.swift
Normal 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: "")
|
||||
}
|
||||
}
|
||||
318
oAI/Services/DatabaseService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
840
oAI/Services/MCPService.swift
Normal 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: [])
|
||||
}
|
||||
}
|
||||
408
oAI/Services/SettingsService.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
143
oAI/Services/WebSearchService.swift
Normal 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)] = [
|
||||
("&", "&"), ("<", "<"), (">", ">"),
|
||||
(""", "\""), ("'", "'"), ("'", "'"),
|
||||
("'", "'"), ("/", "/"), (" ", " "),
|
||||
]
|
||||
for (entity, char) in entities {
|
||||
result = result.replacingOccurrences(of: entity, with: char)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
78
oAI/Utilities/Extensions/Color+Extensions.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
87
oAI/Utilities/Extensions/String+Extensions.swift
Normal 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
|
||||
}
|
||||
}
|
||||
81
oAI/Utilities/Extensions/View+Extensions.swift
Normal 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
@@ -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")
|
||||
}
|
||||
294
oAI/Utilities/SyntaxHighlighter.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
947
oAI/ViewModels/ChatViewModel.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
80
oAI/Views/Main/ChatView.swift
Normal 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())
|
||||
}
|
||||
150
oAI/Views/Main/ContentView.swift
Normal 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())
|
||||
}
|
||||
91
oAI/Views/Main/FooterView.swift
Normal 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)
|
||||
}
|
||||
209
oAI/Views/Main/HeaderView.swift
Normal 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)
|
||||
}
|
||||
327
oAI/Views/Main/InputBar.swift
Normal 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)
|
||||
}
|
||||
183
oAI/Views/Main/MarkdownContentView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
278
oAI/Views/Main/MessageRow.swift
Normal 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)
|
||||
}
|
||||
72
oAI/Views/Screens/AboutView.swift
Normal 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("© 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()
|
||||
}
|
||||
189
oAI/Views/Screens/ConversationListView.swift
Normal 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()
|
||||
}
|
||||
160
oAI/Views/Screens/CreditsView.swift
Normal 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)
|
||||
}
|
||||
456
oAI/Views/Screens/HelpView.swift
Normal 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()
|
||||
}
|
||||
223
oAI/Views/Screens/ModelInfoView.swift
Normal 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"
|
||||
))
|
||||
}
|
||||
222
oAI/Views/Screens/ModelSelectorView.swift
Normal 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 }
|
||||
)
|
||||
}
|
||||
500
oAI/Views/Screens/SettingsView.swift
Normal 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()
|
||||
}
|
||||
148
oAI/Views/Screens/StatsView.swift
Normal 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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||