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,21 @@
|
||||
// AppGroup.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// App Group identifier shared between main app and keyboard extension.
|
||||
// UserDefaults(suiteName:) and file containers use this.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AppGroup {
|
||||
/// App Group container identifier (must match entitlements in both targets)
|
||||
public static let identifier = "group.com.osgkeyboard.ios"
|
||||
|
||||
/// Shared UserDefaults instance for cross-process config
|
||||
public static var defaults: UserDefaults {
|
||||
guard let d = UserDefaults(suiteName: identifier) else {
|
||||
assertionFailure("App Group \(identifier) not configured in entitlements")
|
||||
return .standard
|
||||
}
|
||||
return d
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?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>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,63 @@
|
||||
// LLMProvider.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Provider preset: a known cloud LLM with sensible defaults.
|
||||
// User picks one of these on first launch, or defines a Custom one.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let defaultBaseURL: String
|
||||
public let defaultModel: String
|
||||
public let apiKeyURL: URL?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
defaultBaseURL: String,
|
||||
defaultModel: String,
|
||||
apiKeyURL: URL? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.defaultBaseURL = defaultBaseURL
|
||||
self.defaultModel = defaultModel
|
||||
self.apiKeyURL = apiKeyURL
|
||||
}
|
||||
|
||||
public static let presets: [LLMProvider] = [
|
||||
.init(
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
defaultBaseURL: "https://api.openai.com/v1",
|
||||
defaultModel: "gpt-4o-mini",
|
||||
apiKeyURL: URL(string: "https://platform.openai.com/api-keys")
|
||||
),
|
||||
.init(
|
||||
id: "deepseek",
|
||||
name: "DeepSeek",
|
||||
defaultBaseURL: "https://api.deepseek.com/v1",
|
||||
defaultModel: "deepseek-chat",
|
||||
apiKeyURL: URL(string: "https://platform.deepseek.com/api_keys")
|
||||
),
|
||||
.init(
|
||||
id: "qwen",
|
||||
name: "Qwen (DashScope, OpenAI-compatible)",
|
||||
defaultBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
defaultModel: "qwen-plus",
|
||||
apiKeyURL: URL(string: "https://dashscope.console.aliyun.com/apiKey")
|
||||
),
|
||||
.init(
|
||||
id: "custom",
|
||||
name: "Custom (OpenAI-compatible)",
|
||||
defaultBaseURL: "",
|
||||
defaultModel: ""
|
||||
)
|
||||
]
|
||||
|
||||
public static func provider(id: String) -> LLMProvider {
|
||||
presets.first(where: { $0.id == id }) ?? .presets[0]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// LLMRequest.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// OpenAI-compatible chat completion request/response models.
|
||||
// Compatible with OpenAI, DeepSeek, Qwen DashScope, and any provider that
|
||||
// implements POST {baseURL}/chat/completions.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct LLMRequest: Codable, Sendable {
|
||||
public let model: String
|
||||
public let messages: [Message]
|
||||
public let temperature: Double?
|
||||
public let maxTokens: Int?
|
||||
|
||||
public enum Message: Codable, Sendable {
|
||||
case system(String)
|
||||
case user(String)
|
||||
case assistant(String)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case role, content
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
switch self {
|
||||
case .system(let s):
|
||||
try c.encode("system", forKey: .role); try c.encode(s, forKey: .content)
|
||||
case .user(let s):
|
||||
try c.encode("user", forKey: .role); try c.encode(s, forKey: .content)
|
||||
case .assistant(let s):
|
||||
try c.encode("assistant", forKey: .role); try c.encode(s, forKey: .content)
|
||||
}
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let role = try c.decode(String.self, forKey: .role)
|
||||
let content = try c.decode(String.self, forKey: .content)
|
||||
switch role {
|
||||
case "system": self = .system(content)
|
||||
case "user": self = .user(content)
|
||||
case "assistant": self = .assistant(content)
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(forKey: .role, in: c,
|
||||
debugDescription: "Unknown role \(role)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public init(
|
||||
model: String,
|
||||
messages: [Message],
|
||||
temperature: Double? = 0.3,
|
||||
maxTokens: Int? = nil
|
||||
) {
|
||||
self.model = model
|
||||
self.messages = messages
|
||||
self.temperature = temperature
|
||||
self.maxTokens = maxTokens
|
||||
}
|
||||
}
|
||||
|
||||
public struct LLMResponse: Codable, Sendable {
|
||||
public let id: String?
|
||||
public let choices: [Choice]
|
||||
|
||||
public struct Choice: Codable, Sendable {
|
||||
public let index: Int
|
||||
public let message: LLMRequest.Message
|
||||
public let finishReason: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case index, message
|
||||
case finishReason = "finish_reason"
|
||||
}
|
||||
}
|
||||
|
||||
public var content: String {
|
||||
switch choices.first?.message {
|
||||
case .system(let s), .user(let s), .assistant(let s):
|
||||
return s
|
||||
case .none:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// ProviderConfig.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// User's LLM configuration. Persisted in App Group UserDefaults so both
|
||||
// the main app and keyboard extension read the same values.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
public static let shared = ProviderConfig()
|
||||
|
||||
// Storage keys
|
||||
private enum Key {
|
||||
static let providerId = "config.providerId"
|
||||
static let baseURL = "config.baseURL"
|
||||
static let apiKey = "config.apiKey"
|
||||
static let model = "config.model"
|
||||
static let systemPrompt = "config.systemPrompt"
|
||||
}
|
||||
|
||||
@Published public var providerId: String {
|
||||
didSet { defaults.set(providerId, forKey: Key.providerId) }
|
||||
}
|
||||
|
||||
@Published public var baseURL: String {
|
||||
didSet { defaults.set(baseURL, forKey: Key.baseURL) }
|
||||
}
|
||||
|
||||
@Published public var apiKey: String {
|
||||
didSet { defaults.set(apiKey, forKey: Key.apiKey) }
|
||||
}
|
||||
|
||||
@Published public var model: String {
|
||||
didSet { defaults.set(model, forKey: Key.model) }
|
||||
}
|
||||
|
||||
@Published public var systemPrompt: String {
|
||||
didSet { defaults.set(systemPrompt, forKey: Key.systemPrompt) }
|
||||
}
|
||||
|
||||
public var isConfigured: Bool {
|
||||
!baseURL.isEmpty && !apiKey.isEmpty && !model.isEmpty
|
||||
}
|
||||
|
||||
public let defaultSystemPrompt = """
|
||||
You are a voice-input polishing assistant. The user has spoken informally; rewrite their dictation as clean written text:
|
||||
1) Preserve the user's original intent and meaning; do not invent facts.
|
||||
2) Add proper punctuation, capitalization, and paragraph breaks.
|
||||
3) When the user enumerates items ("first ... second ... third"), output a markdown list.
|
||||
4) Keep the output concise — do not exceed 1.5x the spoken length.
|
||||
5) Output in the same language as the input.
|
||||
"""
|
||||
|
||||
private let defaults: UserDefaults
|
||||
|
||||
public init(defaults: UserDefaults = AppGroup.defaults) {
|
||||
self.defaults = defaults
|
||||
self.providerId = defaults.string(forKey: Key.providerId) ?? "openai"
|
||||
self.baseURL = defaults.string(forKey: Key.baseURL) ?? LLMProvider.provider(id: "openai").defaultBaseURL
|
||||
self.apiKey = defaults.string(forKey: Key.apiKey) ?? ""
|
||||
self.model = defaults.string(forKey: Key.model) ?? LLMProvider.provider(id: "openai").defaultModel
|
||||
self.systemPrompt = defaults.string(forKey: Key.systemPrompt) ?? defaultSystemPrompt
|
||||
}
|
||||
|
||||
public func apply(preset: LLMProvider) {
|
||||
providerId = preset.id
|
||||
if !preset.defaultBaseURL.isEmpty {
|
||||
baseURL = preset.defaultBaseURL
|
||||
}
|
||||
if !preset.defaultModel.isEmpty {
|
||||
model = preset.defaultModel
|
||||
}
|
||||
}
|
||||
|
||||
public func reset() {
|
||||
providerId = "openai"
|
||||
let preset = LLMProvider.provider(id: "openai")
|
||||
baseURL = preset.defaultBaseURL
|
||||
apiKey = ""
|
||||
model = preset.defaultModel
|
||||
systemPrompt = defaultSystemPrompt
|
||||
}
|
||||
}
|
||||
@@ -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