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:
2026-06-17 23:08:58 +08:00
commit 07067ca6c5
45 changed files with 3072 additions and 0 deletions
@@ -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
)
}
}
+119
View File
@@ -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
)
}
}