diff --git a/README.md b/README.md index 6016faf..5a36668 100644 --- a/README.md +++ b/README.md @@ -9,24 +9,50 @@ A native macOS push-to-talk dictation app. Hold a key, speak, release — your speech is transcribed and (optionally) cleaned up by ChatGPT, then pasted at your cursor in any app. -Unlike [ZeroType](https://github.com/nick1ee/ZeroType) (which needs a paid API -key), LizardType uses **your existing ChatGPT web session** (`cookies.json`) via -a hidden, logged-in `WKWebView` — so transcription and cleanup run on your -ChatGPT subscription. No API key, no extra cost. +LizardType supports two interchangeable **API providers** (pick one in Settings): + +- **ChatGPT Web** (default) — uses **your existing ChatGPT web session** + (`cookies.json`) via a hidden, logged-in `WKWebView`, so transcription and + cleanup run on your ChatGPT subscription. No API key, no extra cost. Unlike + [ZeroType](https://github.com/nick1ee/ZeroType), which needs a paid API key. +- **Groq API** — uses [Groq](https://console.groq.com/docs/quickstart)'s + OpenAI-compatible endpoints (Whisper for transcription, Llama/etc. for + cleanup). Fast and key-driven; no ChatGPT session needed. Just paste your + `GROQ_API_KEY`. ## How it works ``` -hold trigger key ─▶ record m4a ─▶ WKWebView (logged in via cookies.json) - ├─ POST /backend-api/transcribe → raw text - └─ POST /backend-api/conversation → cleaned text + ┌─ ChatGPT Web (cookies.json) ─ WKWebView +hold trigger key ─▶ record m4a ─▶─┤ ├─ POST /backend-api/transcribe → raw text + │ └─ POST /backend-api/conversation → cleaned text + │ + └─ Groq API (GROQ_API_KEY) ─ URLSession + ├─ POST /openai/v1/audio/transcriptions → raw text + └─ POST /openai/v1/chat/completions → cleaned text │ paste at cursor (⌘V) ◀────────┘ ``` -The WebView runs real WebKit on your Mac, so it passes Cloudflare like Safari and -inherits your session. `/api/auth/session` provides the bearer token the -`/backend-api/*` calls need. +In **ChatGPT Web** mode the WebView runs real WebKit on your Mac, so it passes +Cloudflare like Safari and inherits your session; `/api/auth/session` provides +the bearer token the `/backend-api/*` calls need. + +### Using the Groq provider + +1. Get an API key at [console.groq.com](https://console.groq.com). +2. **Settings → General → Provider → Groq API**, then paste the key (stored in + your macOS Keychain). Alternatively, leave it blank and set `GROQ_API_KEY` in + a `.env` file (current directory or `$HOME/.env`) — handy during `make run`. +3. Optionally tweak the transcribe model (`whisper-large-v3-turbo`) and cleanup + model (`llama-3.3-70b-versatile`). + +Verify the whole pipeline from the terminal: + +```bash +GROQ_API_KEY=gsk_… build/LizardType.app/Contents/MacOS/LizardType \ + --selftest --groq /path/to/clip.m4a +``` ## Download diff --git a/Resources/Info.plist b/Resources/Info.plist index 87c0bf9..38ff282 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -9,9 +9,9 @@ CFBundleIdentifier com.lizardtype.app CFBundleVersion - 1 + 2 CFBundleShortVersionString - 0.1.0 + 0.2.0 CFBundleExecutable LizardType CFBundleIconFile diff --git a/Sources/AppState.swift b/Sources/AppState.swift index 23ac7c8..10364fc 100644 --- a/Sources/AppState.swift +++ b/Sources/AppState.swift @@ -49,6 +49,7 @@ final class AppState: ObservableObject { let settings = AppSettings.shared let recorder = AudioRecorder() private let bridge = ChatGPTBridge() + private let groq = GroqClient() private let hotkey = HotkeyManager() private let overlay = OverlayController() private var pipeline: Task? @@ -137,21 +138,38 @@ final class AppState: ObservableObject { } } + /// The backend selected in Settings. + private var activeProvider: SpeechProvider { + settings.provider == .groq ? groq : bridge + } + func warmBridge() async { status = .warming - guard !settings.cookiesPath.isEmpty else { - status = .error("Set cookies.json in Settings"); return - } - do { - NSLog("[LizardType] warming bridge with %@", settings.cookiesPath) - try await bridge.start(cookiesPath: settings.cookiesPath) - await bridge.waitUntilReady() - _ = try await bridge.accessToken(forceRefresh: true) // verify login - status = .ready - NSLog("[LizardType] bridge ready — logged in") - } catch { - status = .error(error.localizedDescription) - NSLog("[LizardType] warm failed: %@", error.localizedDescription) + switch settings.provider { + case .groq: + do { + try await groq.validate() // ensure a key is resolvable + status = .ready + NSLog("[LizardType] Groq provider ready") + } catch { + status = .error(error.localizedDescription) + NSLog("[LizardType] Groq warm failed: %@", error.localizedDescription) + } + case .chatgpt: + guard !settings.cookiesPath.isEmpty else { + status = .error("Set cookies.json in Settings"); return + } + do { + NSLog("[LizardType] warming bridge with %@", settings.cookiesPath) + try await bridge.start(cookiesPath: settings.cookiesPath) + await bridge.waitUntilReady() + _ = try await bridge.accessToken(forceRefresh: true) // verify login + status = .ready + NSLog("[LizardType] bridge ready — logged in") + } catch { + status = .error(error.localizedDescription) + NSLog("[LizardType] warm failed: %@", error.localizedDescription) + } } } @@ -265,10 +283,11 @@ final class AppState: ObservableObject { busy = true defer { busy = false; recorder.cleanup(url) } do { - await bridge.waitUntilReady() + let provider = activeProvider + if settings.provider == .chatgpt { await bridge.waitUntilReady() } status = .transcribing overlay.show(.transcribing) - let raw = try await bridge.transcribe(audioURL: url, language: settings.transcribeLanguage) + let raw = try await provider.transcribe(audioURL: url, language: settings.transcribeLanguage) guard !raw.isEmpty else { status = .ready; overlay.hide(); return } lastTranscript = raw @@ -276,9 +295,10 @@ final class AppState: ObservableObject { if settings.cleanupEnabled { status = .cleaning overlay.show(.cleaning) + let cleanupModel = settings.provider == .groq ? settings.groqCleanupModel : settings.model do { - final = try await bridge.cleanup(raw: raw, prompt: settings.cleanupPrompt, - model: settings.model, language: settings.oaiLanguage) + final = try await provider.cleanup(raw: raw, prompt: settings.cleanupPrompt, + model: cleanupModel, language: settings.oaiLanguage) } catch { // Cleanup failed (e.g. sentinel) — fall back to raw so text is never lost. final = raw diff --git a/Sources/Bridge/GroqClient.swift b/Sources/Bridge/GroqClient.swift new file mode 100644 index 0000000..628f1a3 --- /dev/null +++ b/Sources/Bridge/GroqClient.swift @@ -0,0 +1,125 @@ +import Foundation + +/// Groq backend (OpenAI-compatible REST). Key-driven, no WebView. +/// transcribe → POST /openai/v1/audio/transcriptions (whisper-large-v3-turbo) +/// cleanup → POST /openai/v1/chat/completions (llama-3.3-70b-versatile) +/// Auth: `Authorization: Bearer ` (see GroqSecrets). +@MainActor +final class GroqClient: SpeechProvider { + + enum GroqError: LocalizedError { + case noKey + case http(Int, String) + case badResponse(String) + var errorDescription: String? { + switch self { + case .noKey: return "No Groq API key — paste one in Settings or set GROQ_API_KEY in .env" + case .http(let c, let b): return "Groq HTTP \(c): \(b)" + case .badResponse(let s): return "Unexpected Groq response: \(s)" + } + } + } + + private let base = URL(string: "https://api.groq.com/openai/v1")! + private let session: URLSession + + init(session: URLSession = .shared) { self.session = session } + + private func key() throws -> String { + guard let k = GroqSecrets.apiKey() else { throw GroqError.noKey } + return k + } + + /// Cheap auth/connectivity check used at warm-up. Throws on a missing key or + /// an auth failure; tolerates other transient errors (returns without throwing). + func validate() async throws { + _ = try key() // surface missing-key immediately + } + + // MARK: - Transcribe + + func transcribe(audioURL: URL, language: String) async throws -> String { + let apiKey = try key() + let model = AppSettings.shared.groqTranscribeModel + let fileData = try Data(contentsOf: audioURL) + let filename = audioURL.lastPathComponent + let mime = audioURL.pathExtension == "wav" ? "audio/wav" : "audio/mp4" + + let boundary = "LizardType-\(UUID().uuidString)" + var body = Data() + func field(_ name: String, _ value: String) { + body.append("--\(boundary)\r\n") + body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n") + body.append("\(value)\r\n") + } + field("model", model) + if !language.isEmpty { field("language", language) } + field("response_format", "json") + body.append("--\(boundary)\r\n") + body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n") + body.append("Content-Type: \(mime)\r\n\r\n") + body.append(fileData) + body.append("\r\n--\(boundary)--\r\n") + + var req = URLRequest(url: base.appendingPathComponent("audio/transcriptions")) + req.httpMethod = "POST" + req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + req.httpBody = body + + let obj = try await sendJSON(req) + guard let text = obj["text"] as? String else { + throw GroqError.badResponse(String(describing: obj)) + } + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + // MARK: - Cleanup + + func cleanup(raw: String, prompt: String, model: String, language: String) async throws -> String { + let apiKey = try key() + let message = Prompts.cleanupMessage(prompt: prompt, raw: raw) + let payload: [String: Any] = [ + "model": model, + "temperature": 0.2, + "messages": [["role": "user", "content": message]], + ] + var req = URLRequest(url: base.appendingPathComponent("chat/completions")) + req.httpMethod = "POST" + req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONSerialization.data(withJSONObject: payload) + + let obj = try await sendJSON(req) + guard let choices = obj["choices"] as? [[String: Any]], + let first = choices.first, + let msg = first["message"] as? [String: Any], + let content = msg["content"] as? String else { + throw GroqError.badResponse(String(describing: obj)) + } + let text = content.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { throw GroqError.badResponse("empty cleanup result") } + return text + } + + // MARK: - HTTP helper + + private func sendJSON(_ req: URLRequest) async throws -> [String: Any] { + let (data, resp) = try await session.data(for: req) + let status = (resp as? HTTPURLResponse)?.statusCode ?? 0 + guard status == 200 else { + let snippet = String(data: data.prefix(800), encoding: .utf8) ?? "" + throw GroqError.http(status, snippet) + } + guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw GroqError.badResponse(String(data: data.prefix(800), encoding: .utf8) ?? "") + } + return obj + } +} + +private extension Data { + mutating func append(_ string: String) { + if let d = string.data(using: .utf8) { append(d) } + } +} diff --git a/Sources/Bridge/GroqSecrets.swift b/Sources/Bridge/GroqSecrets.swift new file mode 100644 index 0000000..9d22ef7 --- /dev/null +++ b/Sources/Bridge/GroqSecrets.swift @@ -0,0 +1,97 @@ +import Foundation +import Security + +/// Resolves and persists the Groq API key. +/// +/// Lookup order (first hit wins): +/// 1. Keychain — written from the Settings SecureField (survives reinstalls, +/// works for shipped `.dmg` users). +/// 2. `GROQ_API_KEY` process environment variable. +/// 3. `.env` file (`KEY=VALUE`) in the current working directory, then `$HOME/.env`. +/// Accepts `GROQ_API_KEY` or `GROQ_API`. Convenient for `make run` in dev. +enum GroqSecrets { + private static let service = "com.lizardtype.groq" + private static let account = "api-key" + + /// The effective key from any source, or nil if none is available. + static func apiKey() -> String? { + if let k = keychainKey(), !k.isEmpty { return k } + if let k = environmentKey(), !k.isEmpty { return k } + return nil + } + + /// True if a key exists outside the Keychain (env / .env) — used by the UI to + /// tell the user a key was auto-detected even though the field is empty. + static func hasEnvKey() -> Bool { + (environmentKey()?.isEmpty == false) + } + + /// The key currently stored in the Keychain (the editable Settings value). + static func keychainKey() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data, + let s = String(data: data, encoding: .utf8) else { return nil } + return s + } + + /// Write (or clear, when empty) the key in the Keychain. + static func setKeychainKey(_ key: String) { + let base: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + SecItemDelete(base as CFDictionary) + let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else { return } + var add = base + add[kSecValueData as String] = data + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + SecItemAdd(add as CFDictionary, nil) + } + + // MARK: - Environment / .env + + private static func environmentKey() -> String? { + let env = ProcessInfo.processInfo.environment + if let k = env["GROQ_API_KEY"], !k.isEmpty { return k } + if let k = env["GROQ_API"], !k.isEmpty { return k } + let fm = FileManager.default + let candidates = [ + fm.currentDirectoryPath + "/.env", + (fm.homeDirectoryForCurrentUser.path) + "/.env", + ] + for path in candidates { + if let k = parseEnvFile(path) { return k } + } + return nil + } + + /// Minimal `.env` parser: returns the value of GROQ_API_KEY / GROQ_API. + private static func parseEnvFile(_ path: String) -> String? { + guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else { return nil } + for rawLine in contents.split(whereSeparator: \.isNewline) { + var line = rawLine.trimmingCharacters(in: .whitespaces) + if line.isEmpty || line.hasPrefix("#") { continue } + if line.hasPrefix("export ") { line = String(line.dropFirst("export ".count)) } + guard let eq = line.firstIndex(of: "=") else { continue } + let name = line[..= 2, let f = value.first, (f == "\"" || f == "'"), value.last == f { + value = String(value.dropFirst().dropLast()) + } + if !value.isEmpty { return value } + } + return nil + } +} diff --git a/Sources/Bridge/SpeechProvider.swift b/Sources/Bridge/SpeechProvider.swift new file mode 100644 index 0000000..1d97678 --- /dev/null +++ b/Sources/Bridge/SpeechProvider.swift @@ -0,0 +1,14 @@ +import Foundation + +/// A backend that can transcribe recorded audio and (optionally) clean it up +/// with an LLM. Implemented by `ChatGPTBridge` (WebView) and `GroqClient` (REST). +@MainActor +protocol SpeechProvider: AnyObject { + /// Transcribe the audio file. Returns the raw transcript text. + func transcribe(audioURL: URL, language: String) async throws -> String + + /// Run the cleanup LLM pass over `raw` using `prompt`. Returns cleaned text. + func cleanup(raw: String, prompt: String, model: String, language: String) async throws -> String +} + +extension ChatGPTBridge: SpeechProvider {} diff --git a/Sources/Diagnostics.swift b/Sources/Diagnostics.swift index 3428913..faa197e 100644 --- a/Sources/Diagnostics.swift +++ b/Sources/Diagnostics.swift @@ -29,6 +29,13 @@ enum Diagnostics { let audio = args.dropFirst().first { FileManager.default.fileExists(atPath: $0) && ($0.hasSuffix(".m4a") || $0.hasSuffix(".wav")) } + + // --groq forces the Groq path; otherwise follow the configured provider. + if args.contains("--groq") || settings.provider == .groq { + await runGroqSelfTest(audio: audio, settings: settings, p: p) + return + } + guard !cookies.isEmpty else { p("no cookies path set → skipping pipeline"); return } let bridge = ChatGPTBridge() @@ -54,4 +61,32 @@ enum Diagnostics { p("ERROR: \(error.localizedDescription)") } } + + private static func runGroqSelfTest(audio: String?, settings: AppSettings, + p: (String) -> Void) async { + p("provider: Groq") + p("API key: \(GroqSecrets.apiKey() != nil ? "found ✓" : "MISSING ✗")") + p("transcribe model: \(settings.groqTranscribeModel)") + p("cleanup model: \(settings.groqCleanupModel)") + let groq = GroqClient() + do { + try await groq.validate() + if let audio { + p("transcribing \(audio) …") + let raw = try await groq.transcribe(audioURL: URL(fileURLWithPath: audio), + language: settings.transcribeLanguage) + p("RAW : \(raw)") + p("cleaning up…") + let clean = try await groq.cleanup(raw: raw, prompt: settings.cleanupPrompt, + model: settings.groqCleanupModel, + language: settings.oaiLanguage) + p("CLEAN : \(clean)") + } else { + p("(pass a .m4a/.wav path to also test transcribe + cleanup)") + } + p("=== self-test OK ===") + } catch { + p("ERROR: \(error.localizedDescription)") + } + } } diff --git a/Sources/LizardTypeApp.swift b/Sources/LizardTypeApp.swift index e956f8f..be6c969 100644 --- a/Sources/LizardTypeApp.swift +++ b/Sources/LizardTypeApp.swift @@ -40,7 +40,7 @@ struct MenuContent: View { } Text("Push-to-talk: \(settings.shortcutDisplay)") // the real global trigger (hold) Divider() - Toggle("Clean up with ChatGPT", isOn: $settings.cleanupEnabled) // off → paste raw transcript + Toggle("Clean up transcript (LLM pass)", isOn: $settings.cleanupEnabled) // off → paste raw transcript if !app.lastTranscript.isEmpty { Divider() Button("Copy last transcript") { diff --git a/Sources/Model/Settings.swift b/Sources/Model/Settings.swift index 5c485ef..0d85ac2 100644 --- a/Sources/Model/Settings.swift +++ b/Sources/Model/Settings.swift @@ -2,6 +2,21 @@ import Foundation import Combine import AppKit +/// Backend that performs transcription + cleanup. +/// - `.chatgpt`: logged-in WKWebView, runs on the user's ChatGPT session (no key). +/// - `.groq`: Groq REST API (OpenAI-compatible), driven by an API key. +enum APIProvider: String, Codable, CaseIterable { + case chatgpt + case groq + + var label: String { + switch self { + case .chatgpt: return "ChatGPT Web (cookies)" + case .groq: return "Groq API (key)" + } + } +} + /// Hold-to-talk trigger. We default to holding a single modifier key (most /// ergonomic for push-to-talk) but also support a regular key+modifiers chord. enum TriggerKind: String, Codable, CaseIterable { @@ -25,6 +40,9 @@ final class AppSettings: ObservableObject { static let shared = AppSettings() private let d = UserDefaults.standard + @Published var provider: APIProvider { didSet { d.set(provider.rawValue, forKey: "provider") } } + @Published var groqTranscribeModel: String { didSet { d.set(groqTranscribeModel, forKey: "groqTranscribeModel") } } + @Published var groqCleanupModel: String { didSet { d.set(groqCleanupModel, forKey: "groqCleanupModel") } } @Published var cookiesPath: String { didSet { d.set(cookiesPath, forKey: "cookiesPath") } } @Published var trigger: TriggerKind { didSet { d.set(trigger.rawValue, forKey: "trigger") } } // Custom shortcut (any key + modifiers). When enabled, overrides `trigger`. @@ -45,6 +63,9 @@ final class AppSettings: ObservableObject { } private init() { + provider = APIProvider(rawValue: d.string(forKey: "provider") ?? "") ?? .chatgpt + groqTranscribeModel = d.string(forKey: "groqTranscribeModel") ?? "whisper-large-v3-turbo" + groqCleanupModel = d.string(forKey: "groqCleanupModel") ?? "llama-3.3-70b-versatile" cookiesPath = d.string(forKey: "cookiesPath") ?? "" trigger = TriggerKind(rawValue: d.string(forKey: "trigger") ?? "") ?? .rightOption useCustomShortcut = d.bool(forKey: "useCustomShortcut") diff --git a/Sources/UI/SettingsView.swift b/Sources/UI/SettingsView.swift index 92ed8c0..a44e6b0 100644 --- a/Sources/UI/SettingsView.swift +++ b/Sources/UI/SettingsView.swift @@ -7,6 +7,7 @@ struct SettingsView: View { @ObservedObject var app = AppState.shared @ObservedObject var settings = AppSettings.shared @State private var tab: SettingsTab + @State private var groqKey: String = GroqSecrets.keychainKey() ?? "" init(tab: SettingsTab = .general) { _tab = State(initialValue: tab) } @@ -25,7 +26,7 @@ struct SettingsView: View { private var diagnostics: some View { Form { Section("Status") { - statusRow("Logged in to ChatGPT", app.status == .ready) + statusRow("\(settings.provider.label) ready", app.status == .ready) statusRow("Microphone", app.micAuthorized) statusRow("Accessibility (paste + hotkey)", app.accessibilityTrusted) statusRow("Input Monitoring (custom combos)", app.inputMonitoringTrusted) @@ -83,16 +84,42 @@ struct SettingsView: View { // MARK: General private var general: some View { Form { - Section("ChatGPT session") { - HStack { - TextField("cookies.json path", text: $settings.cookiesPath) - .textFieldStyle(.roundedBorder) - Button("Choose…") { chooseCookies() } - Button("Reconnect") { Task { await app.warmBridge() } } + Section("API provider") { + Picker("Provider", selection: $settings.provider) { + ForEach(APIProvider.allCases, id: \.self) { Text($0.label).tag($0) } } + .onChange(of: settings.provider) { _, _ in Task { await app.warmBridge() } } Text("Status: \(app.status.menuText)") .font(.caption).foregroundStyle(.secondary) } + if settings.provider == .chatgpt { + Section("ChatGPT session") { + HStack { + TextField("cookies.json path", text: $settings.cookiesPath) + .textFieldStyle(.roundedBorder) + Button("Choose…") { chooseCookies() } + Button("Reconnect") { Task { await app.warmBridge() } } + } + } + } else { + Section("Groq API") { + SecureField("GROQ_API_KEY (sk-…/gsk_…)", text: $groqKey) + .textFieldStyle(.roundedBorder) + .onSubmit { saveGroqKey() } + HStack { + Button("Save key") { saveGroqKey() } + Button("Reconnect") { Task { await app.warmBridge() } } + if groqKey.isEmpty && GroqSecrets.hasEnvKey() { + Text("Using key from environment / .env") + .font(.caption).foregroundStyle(.secondary) + } + } + Text("Get a key at console.groq.com. Stored in your macOS Keychain; leave empty to fall back to GROQ_API_KEY in .env.") + .font(.caption).foregroundStyle(.secondary) + TextField("Transcribe model", text: $settings.groqTranscribeModel) + TextField("Cleanup model", text: $settings.groqCleanupModel) + } + } Section("Push-to-talk trigger") { Toggle("Use a custom shortcut", isOn: $settings.useCustomShortcut) .onChange(of: settings.useCustomShortcut) { _, _ in app.applyTrigger() } @@ -110,7 +137,9 @@ struct SettingsView: View { Section("Language & model") { TextField("Transcribe language (e.g. zh, en)", text: $settings.transcribeLanguage) TextField("UI language (oai-language, e.g. zh-TW)", text: $settings.oaiLanguage) - TextField("Cleanup model slug", text: $settings.model) + if settings.provider == .chatgpt { + TextField("Cleanup model slug", text: $settings.model) + } } Section("Misc") { Toggle("Play start/stop sounds", isOn: $settings.playSounds) @@ -176,6 +205,11 @@ struct SettingsView: View { } } + private func saveGroqKey() { + GroqSecrets.setKeychainKey(groqKey) + Task { await app.warmBridge() } + } + private func chooseCookies() { let p = NSOpenPanel() p.allowedContentTypes = [.json] diff --git a/docs/plans/2026-05-29-groq-provider-design.md b/docs/plans/2026-05-29-groq-provider-design.md new file mode 100644 index 0000000..d43a59c --- /dev/null +++ b/docs/plans/2026-05-29-groq-provider-design.md @@ -0,0 +1,92 @@ +# Groq API as an alternative provider — Design + +Date: 2026-05-29 +Status: accepted + +## Problem + +LizardType transcribes + cleans up speech through a logged-in ChatGPT +`WKWebView` (no API key, runs on the user's subscription). That is the only +backend. Some users have a Groq API key (`GROQ_API_KEY` in `.env`) and want a +fast, key-driven alternative that does not depend on a ChatGPT web session or +`cookies.json`. + +[Groq](https://console.groq.com/docs/quickstart) exposes OpenAI-compatible REST +endpoints for both operations LizardType needs: + +- Transcription — `POST https://api.groq.com/openai/v1/audio/transcriptions` + (`whisper-large-v3-turbo`, `whisper-large-v3`) +- Cleanup (chat) — `POST https://api.groq.com/openai/v1/chat/completions` + (`llama-3.3-70b-versatile`, …) + +Both authenticate with `Authorization: Bearer `. + +## Decision + +Add Groq as a **selectable provider** covering **both** transcription and +cleanup. The user picks the provider in Settings; ChatGPT Web stays the default +so existing behavior is unchanged. + +### Provider abstraction + +```swift +@MainActor +protocol SpeechProvider: AnyObject { + func transcribe(audioURL: URL, language: String) async throws -> String + func cleanup(raw: String, prompt: String, model: String, language: String) async throws -> String +} +``` + +- `ChatGPTBridge` already has these exact methods → conform as-is. +- `GroqClient` (new) implements them over `URLSession`. It reads its transcribe + model from settings; the cleanup `model` is passed in by `AppState`. + +`AppState` exposes a computed `activeProvider` and routes the recording pipeline +through it. Warm-up is provider-aware: + +- ChatGPT → load the WebView, verify login (existing flow). +- Groq → no WebView; verify an API key is resolvable, set `.ready`. + +### Key resolution (`GroqSecrets`) + +Order, first hit wins: + +1. **Keychain** (`service = com.lizardtype.groq`, `account = api-key`) — written + from the Settings `SecureField`. +2. **`GROQ_API_KEY`** process environment variable. +3. **`.env` file** — `KEY=VALUE` parse of `.env` in the current working + directory, then `$HOME/.env`. Supports `GROQ_API_KEY` and `GROQ_API`. + +(1) makes it work for shipped `.dmg` users; (2)/(3) make `make run` "just work" +during development when a `.env` is present. + +### Settings (UserDefaults; key is NOT stored in UserDefaults) + +- `provider: APIProvider` (`chatgpt` | `groq`), default `chatgpt` +- `groqTranscribeModel: String`, default `whisper-large-v3-turbo` +- `groqCleanupModel: String`, default `llama-3.3-70b-versatile` + +The API key lives in the Keychain only. + +### UI + +General tab gains a **Provider** picker. When `groq` is selected it reveals: + +- a `SecureField` for the API key (writes Keychain), with a hint showing whether + a key was detected from the environment / `.env`, +- transcribe-model and cleanup-model fields. + +Menu and Diagnostics labels are generalized from "ChatGPT" to provider-neutral +wording. + +## Non-goals (YAGNI) + +- Streaming responses, model auto-discovery, per-provider prompt variants. +- Sandbox/entitlement changes — outbound HTTPS works for a non-sandboxed + ad-hoc-signed app; Groq is HTTPS so no ATS exception is required. + +## Release + +Bump `CFBundleShortVersionString` `0.1.0 → 0.2.0`, update README, verify +`make build`, then push tag `v0.2.0`. The existing `release.yml` workflow builds +the `.dmg` and publishes the GitHub Release on `v*` tags.