feat: comprehensive rewrite — push-to-talk pipeline, Typeless UI, Chinese

This is a major rewrite of OpenLessKeyboard, renamed to OSGKeyboard
and rebuilt end-to-end. 59 files changed (+3205/-1550).

Architecture
------------
- Rename project, targets, directories from OpenLess* to OSGKeyboard*
  (OpenLess / OpenLessKeyboard / OpenLessShared / OpenLessTests).
- AudioCaptureService rewritten as @unchecked Sendable class with
  OSAllocatedUnfairLock instead of an actor, so it survives Swift 6
  strict-concurrency checks while still serialising engine + converter
  state correctly.
- Single design system (Palette / Spacing / Radius / TypeStyle /
  Motion) lifted into OSGKeyboardShared so the host app and the
  keyboard extension stay in lock-step.

Push-to-talk — first-principles fix
-----------------------------------
- App Group + audio-input entitlements were stripped by Xcode's
  Automatic Signing. They are now declared in project.yml so
  'xcodegen generate' re-emits them every time. iOS Developer
  Account is untouched; only the App Group capability was added.
- State machine uses a real stored `phase` (was a derived shim
  that locked out every press after the first because
  recordStream was never nilled after the pipeline finished).
- Microphone permission is requested inside pressBegan (async
  Task) so the press flow optimistically enters .recording;
  permission denial surfaces a short error and returns to idle.
- Replaced LongPressGesture(0.15s) with a DragGesture +
  TapGesture pair separated by pressArmed, so a single tap no
  longer fires both onPressBegan and onTap simultaneously.
- Real RMS / peak level meter from the AVAudioEngine tap (was a
  pseudo-random walk); the visible waveform is now driven by
  actual audio.
- SFSpeechRecognizer(locale:) with selectable ASR locales
  (auto / zh-Hans / zh-Hant / en-US / ja-JP / ko-KR) for
  first-class Chinese / English / Japanese / Korean dictation,
  with on-device recognition when supported.
- AVAudioSession now deactivates on stop so other apps' audio
  routing is restored.

Keyboard UI — Typeless-inspired layout
---------------------------------------
- Hero area is 280 pt with a 96 pt record disc, breathing outer
  ring, and a 12-bar waveform driven by the real RMS.
- inputView.allowsSelfSizing + a heightAnchor constraint so iOS
  no longer crops the keyboard under the Spotlight bar / home
  indicator.
- Top bar: mode chip (Off / 转写 / 润色) + locale chip
  (Auto / 简体 / 繁體 / EN / 日 / 한) + status badge + ⚙.
- Bottom bar: globe / delete / 空格 / return — all 40 pt and
  balanced.
- RecordButton onPressEnded is now safe to fire from a quick
  press; pressArmed prevents double-firing.

LLM / Polishing
---------------
- LLMClient: stopped leaking the server response body in errors
  (server body is now logged at debug, never surfaced to UI);
  added a dedicated .rateLimited case for 429.
- PolishingService timeout 8s → 12s to accommodate slower
  domestic LLM providers.
- AppGroupStore.defaultSystemPrompt is now provider-aware
  (Chinese for zhipu/moonshot/qwen/deepseek, English otherwise).

Onboarding & Settings
---------------------
- Re-themed OnboardingView / HomeView / SettingsView on the
  new design system.
- ProviderPickerSection now shows 6 providers (OpenAI, DeepSeek,
  Qwen DashScope, 智谱 GLM, 月之暗面 Moonshot, Custom) with
  blurb + selected accent.
- PickerRow for Mode and ASR locale; System Prompt editor with
  reset-to-default.
- API settings page "Get an API key" used SwiftUI Link, which
  has a hit-test bug on iOS 18 that ate gestures from adjacent
  TextFields (manifested as "typing jumps to a website"). It is
  now an explicit Button + contentShape + .submitLabel(.done) on
  the fields.

Polish & tests
--------------
- LLMClientTests: 4 unit tests passing (ProviderConfig
  persistence + OpenAI request/response + HTTP error + missing
  key); test App Group renamed to the correct identifier.
- ProviderConfig.apply now captures the previous provider id
  *before* mutating, so switching providers actually resets the
  system prompt to the new default.

Build
-----
- Swift 6 strict concurrency, iOS 18.0 deployment target.
- Tested on Xcode 26 + iPhone 17 Pro simulator. A real device on
  iOS 27 beta aborts with __abort_with_payload (dispatch
  library ABI mismatch); use an iOS 18 real device or the
  iOS 26 simulator for now.

🤖 Generated with Claude Code
This commit is contained in:
Rocky
2026-06-18 01:22:12 +08:00
parent 07067ca6c5
commit bec36befa2
59 changed files with 3205 additions and 1550 deletions
@@ -0,0 +1,34 @@
// AudioBufferSnapshot.swift
// OSGKeyboard · Shared
//
// Sendable wrapper around a Float32 audio buffer's raw samples.
// The snapshot is the only thing that crosses actor / concurrency
// boundaries; the recognizer re-creates an `AVAudioPCMBuffer` on its
// own side and consumes it locally (never yielding it back out).
import Foundation
import AVFoundation
public struct AudioBufferSnapshot: Sendable {
public let samples: [Float]
public let sampleRate: Double
public init(samples: [Float], sampleRate: Double) {
self.samples = samples
self.sampleRate = sampleRate
}
/// Construct from an `AVAudioPCMBuffer` by copying out the channel data.
public init(buffer: AVAudioPCMBuffer) {
guard let channelData = buffer.floatChannelData else {
self.samples = []
self.sampleRate = buffer.format.sampleRate
return
}
let n = Int(buffer.frameLength)
var copy = [Float](repeating: 0, count: n)
memcpy(&copy, channelData[0], n * MemoryLayout<Float>.size)
self.samples = copy
self.sampleRate = buffer.format.sampleRate
}
}
@@ -0,0 +1,87 @@
// 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?
/// Optional short blurb shown under the provider name in the picker.
public let blurb: String?
public init(
id: String,
name: String,
defaultBaseURL: String,
defaultModel: String,
apiKeyURL: URL? = nil,
blurb: String? = nil
) {
self.id = id
self.name = name
self.defaultBaseURL = defaultBaseURL
self.defaultModel = defaultModel
self.apiKeyURL = apiKeyURL
self.blurb = blurb
}
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"),
blurb: "GPT-4o mini · 多语言"
),
.init(
id: "deepseek",
name: "DeepSeek",
defaultBaseURL: "https://api.deepseek.com/v1",
defaultModel: "deepseek-chat",
apiKeyURL: URL(string: "https://platform.deepseek.com/api_keys"),
blurb: "deepseek-chat · 中文友好"
),
.init(
id: "qwen",
name: "Qwen (DashScope)",
defaultBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
defaultModel: "qwen-plus",
apiKeyURL: URL(string: "https://dashscope.console.aliyun.com/apiKey"),
blurb: "通义千问 · OpenAI 兼容"
),
.init(
id: "zhipu",
name: "智谱 GLM",
defaultBaseURL: "https://open.bigmodel.cn/api/paas/v4",
defaultModel: "glm-4-flash",
apiKeyURL: URL(string: "https://bigmodel.cn/usercenter/apikeys"),
blurb: "GLM-4-Flash · 中文优化"
),
.init(
id: "moonshot",
name: "月之暗面 Moonshot",
defaultBaseURL: "https://api.moonshot.cn/v1",
defaultModel: "moonshot-v1-8k",
apiKeyURL: URL(string: "https://platform.moonshot.cn/console/api-keys"),
blurb: "Kimi · 长上下文"
),
.init(
id: "custom",
name: "Custom (OpenAI-compatible)",
defaultBaseURL: "",
defaultModel: "",
blurb: "自建 / 任意 OpenAI 兼容端点"
)
]
public static func provider(id: String) -> LLMProvider {
presets.first(where: { $0.id == id }) ?? .presets[0]
}
}
+88
View File
@@ -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,100 @@
// 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()
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"
static let modeId = "config.modeId"
static let localeId = "config.localeId"
}
@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) }
}
@Published public var modeId: String {
didSet { defaults.set(modeId, forKey: Key.modeId) }
}
@Published public var localeId: String {
didSet { defaults.set(localeId, forKey: Key.localeId) }
}
public var isConfigured: Bool {
!baseURL.isEmpty && !apiKey.isEmpty && !model.isEmpty
}
/// The system prompt the user *sees* in the editor fall back to the
/// provider-aware default from `AppGroupStore` when nothing is set.
public var defaultSystemPrompt: String {
AppGroupStore.defaultSystemPrompt(for: providerId)
}
private let defaults: UserDefaults
public init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
let pid = defaults.string(forKey: Key.providerId) ?? "openai"
let preset = LLMProvider.provider(id: pid)
self.providerId = pid
self.baseURL = defaults.string(forKey: Key.baseURL) ?? preset.defaultBaseURL
self.apiKey = defaults.string(forKey: Key.apiKey) ?? ""
self.model = defaults.string(forKey: Key.model) ?? preset.defaultModel
self.systemPrompt = defaults.string(forKey: Key.systemPrompt)
?? AppGroupStore.defaultSystemPrompt(for: pid)
self.modeId = defaults.string(forKey: Key.modeId) ?? "polish"
self.localeId = defaults.string(forKey: Key.localeId) ?? "auto"
}
public func apply(preset: LLMProvider) {
// Capture the *previous* provider id BEFORE we mutate, so the
// system-prompt reset check below can compare against the actual
// prior default.
let oldId = providerId
providerId = preset.id
if !preset.defaultBaseURL.isEmpty {
baseURL = preset.defaultBaseURL
}
if !preset.defaultModel.isEmpty {
model = preset.defaultModel
}
// When switching providers, reset the system prompt to the new
// provider's default otherwise the user is left editing a
// Chinese prompt on a US-English model.
if systemPrompt.isEmpty
|| systemPrompt == AppGroupStore.defaultSystemPrompt(for: oldId) {
systemPrompt = AppGroupStore.defaultSystemPrompt(for: preset.id)
}
}
public func reset() {
providerId = "openai"
let preset = LLMProvider.provider(id: "openai")
baseURL = preset.defaultBaseURL
apiKey = ""
model = preset.defaultModel
systemPrompt = AppGroupStore.defaultSystemPrompt(for: "openai")
}
}