feat: initial release v0.1.0
- Custom Keyboard Extension with push-to-talk UI - iOS 26 SpeechAnalyzer + DictationTranscriber (iOS 18 SF fallback) - OpenAI-compatible LLM client (4 built-in providers + custom) - 3-page onboarding flow + provider config UI - App Group shared storage for cross-process config - 8s LLM timeout with raw-transcript fallback - App Store privacy manifests for both targets - SwiftLint + XcodeGen + GitHub Actions CI - Unit tests (4/4 passing)
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
// AppGroupStore.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Convenience wrapper around App Group UserDefaults for non-Published reads.
|
||||
// Used by the keyboard extension (no SwiftUI) to read config without
|
||||
// instantiating an ObservableObject.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct AppGroupStore: @unchecked Sendable {
|
||||
public let defaults: UserDefaults
|
||||
|
||||
public init(defaults: UserDefaults = AppGroup.defaults) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
public var providerId: String {
|
||||
defaults.string(forKey: "config.providerId") ?? "openai"
|
||||
}
|
||||
|
||||
public var baseURL: String {
|
||||
defaults.string(forKey: "config.baseURL") ?? LLMProvider.provider(id: "openai").defaultBaseURL
|
||||
}
|
||||
|
||||
public var apiKey: String {
|
||||
defaults.string(forKey: "config.apiKey") ?? ""
|
||||
}
|
||||
|
||||
public var model: String {
|
||||
defaults.string(forKey: "config.model") ?? LLMProvider.provider(id: "openai").defaultModel
|
||||
}
|
||||
|
||||
public var systemPrompt: String {
|
||||
defaults.string(forKey: "config.systemPrompt")
|
||||
?? "You are a voice-input polishing assistant. Rewrite the user's dictation as clean written text. Preserve intent. Add punctuation and structure. Do not invent facts. Output in the same language as the input."
|
||||
}
|
||||
|
||||
public func makeClient() -> LLMClient {
|
||||
OpenAICompatibleClient(
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
model: model
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// LLMClient.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Protocol-based LLM client. Default implementation is the OpenAI-compatible
|
||||
// chat completion client. Add other impls (Anthropic, Gemini) as needed.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum LLMError: Error, LocalizedError, Sendable {
|
||||
case invalidURL
|
||||
case noAPIKey
|
||||
case http(status: Int, body: String)
|
||||
case decoding(String)
|
||||
case transport(underlying: String)
|
||||
case cancelled
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL: return "Invalid API URL."
|
||||
case .noAPIKey: return "API key is missing."
|
||||
case .http(let s, let body):
|
||||
return "API returned HTTP \(s): \(body.prefix(200))"
|
||||
case .decoding(let s): return "Failed to decode response: \(s)"
|
||||
case .transport(let s): return "Network error: \(s)"
|
||||
case .cancelled: return "Request was cancelled."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public protocol LLMClient: Sendable {
|
||||
func polish(_ text: String, systemPrompt: String) async throws -> String
|
||||
}
|
||||
|
||||
// MARK: - OpenAI-compatible implementation
|
||||
|
||||
public struct OpenAICompatibleClient: LLMClient {
|
||||
public let baseURL: String
|
||||
public let apiKey: String
|
||||
public let model: String
|
||||
public let session: URLSession
|
||||
|
||||
public init(
|
||||
baseURL: String,
|
||||
apiKey: String,
|
||||
model: String,
|
||||
session: URLSession = .shared
|
||||
) {
|
||||
self.baseURL = baseURL
|
||||
self.apiKey = apiKey
|
||||
self.model = model
|
||||
self.session = session
|
||||
}
|
||||
|
||||
public func polish(_ text: String, systemPrompt: String) async throws -> String {
|
||||
guard !apiKey.isEmpty else { throw LLMError.noAPIKey }
|
||||
|
||||
let urlString = baseURL.hasSuffix("/")
|
||||
? "\(baseURL)chat/completions"
|
||||
: "\(baseURL)/chat/completions"
|
||||
guard let url = URL(string: urlString) else { throw LLMError.invalidURL }
|
||||
|
||||
let request = LLMRequest(
|
||||
model: model,
|
||||
messages: [
|
||||
.system(systemPrompt),
|
||||
.user(text)
|
||||
],
|
||||
temperature: 0.3,
|
||||
maxTokens: nil
|
||||
)
|
||||
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
req.timeoutInterval = 15
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
req.httpBody = try encoder.encode(request)
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw LLMError.transport(underlying: "non-HTTP response")
|
||||
}
|
||||
if !(200..<300).contains(http.statusCode) {
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
throw LLMError.http(status: http.statusCode, body: body)
|
||||
}
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(LLMResponse.self, from: data)
|
||||
return decoded.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
} catch {
|
||||
throw LLMError.decoding(String(describing: error))
|
||||
}
|
||||
} catch let err as LLMError {
|
||||
throw err
|
||||
} catch is CancellationError {
|
||||
throw LLMError.cancelled
|
||||
} catch let urlError as URLError where urlError.code == .cancelled {
|
||||
throw LLMError.cancelled
|
||||
} catch {
|
||||
throw LLMError.transport(underlying: String(describing: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Factory
|
||||
|
||||
public enum LLMClientFactory {
|
||||
/// Build a client from the current `ProviderConfig`.
|
||||
public static func make(from config: ProviderConfig) -> LLMClient {
|
||||
OpenAICompatibleClient(
|
||||
baseURL: config.baseURL,
|
||||
apiKey: config.apiKey,
|
||||
model: config.model
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user