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,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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user