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,32 @@
// 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.shared"
/// Shared UserDefaults instance for cross-process config.
///
/// Falls back to `.standard` if the App Group isn't available (e.g.
/// the user hasn't created the App Group in the Apple Developer
/// portal, or Xcode hasn't downloaded a matching provisioning profile).
/// In that mode, the keyboard extension will *not* see config written
/// by the main app but the main app itself stays usable so the user
/// can fix the signing situation without the app crashing.
public static var defaults: UserDefaults {
if let d = UserDefaults(suiteName: identifier) {
return d
}
#if DEBUG
print("⚠️ App Group \(identifier) unavailable — falling back to .standard. " +
"Add the App Group in your Apple Developer account and Xcode " +
"Signing & Capabilities, then re-run.")
#endif
return .standard
}
}
+156
View File
@@ -0,0 +1,156 @@
// Theme.swift
// OSGKeyboard · Design System
//
// Single source of truth for colour, spacing, corner radius, typography.
// Inspired by Dieter Rams ("less but better") and Apple Human Interface:
// every surface has a single purpose, every token earns its place, and
// the visual hierarchy is carried by *whitespace + one accent*, never by
// extra colour.
import SwiftUI
// MARK: - Palette
public enum Palette {
// Backgrounds
public static let background = Color(red: 0.039, green: 0.039, blue: 0.043) // #0A0A0B
public static let surface = Color(red: 0.094, green: 0.094, blue: 0.106) // #18181B
public static let surfaceElevated = Color(red: 0.153, green: 0.153, blue: 0.169) // #27272A
public static let surfaceMuted = Color(red: 0.071, green: 0.071, blue: 0.082) // #121215
// Accents
public static let accent = Color(red: 0.353, green: 0.784, blue: 0.980) // #5AC8FA
public static let accentMuted = accent.opacity(0.18)
public static let accentGlow = accent.opacity(0.42)
// Semantic
public static let danger = Color(red: 1.000, green: 0.271, blue: 0.227) // #FF453A
public static let success = Color(red: 0.157, green: 0.812, blue: 0.412) // #28CF69
public static let warning = Color(red: 1.000, green: 0.749, blue: 0.094) // #FFBF18
// Text
public static let textPrimary = Color.white
public static let textSecondary = Color(white: 0.7)
public static let textTertiary = Color(white: 0.50)
public static let textOnAccent = Color.black
// Lines
public static let divider = Color.white.opacity(0.06)
public static let dividerStrong = Color.white.opacity(0.10)
// Recording state
public static let recordRed = Color(red: 1.000, green: 0.231, blue: 0.188) // #FF3B30
}
// MARK: - Spacing scale (4 pt grid)
public enum Spacing {
public static let xxs: CGFloat = 4
public static let xs: CGFloat = 8
public static let sm: CGFloat = 12
public static let md: CGFloat = 16
public static let lg: CGFloat = 20
public static let xl: CGFloat = 24
public static let xxl: CGFloat = 32
public static let xxxl: CGFloat = 40
public static let hero: CGFloat = 48
}
// MARK: - Corner radius scale
public enum Radius {
public static let small: CGFloat = 8
public static let medium: CGFloat = 12
public static let large: CGFloat = 16
public static let xl: CGFloat = 20
public static let xxl: CGFloat = 24
public static let pill: CGFloat = 999
}
// MARK: - Typography
public enum TypeStyle {
public static let caption2 = Font.system(size: 11, weight: .medium)
public static let caption = Font.system(size: 12, weight: .medium)
public static let footnote = Font.system(size: 13, weight: .regular)
public static let body = Font.system(size: 15, weight: .regular)
public static let bodyEmph = Font.system(size: 15, weight: .medium)
public static let headline = Font.system(size: 17, weight: .semibold)
public static let title3 = Font.system(size: 20, weight: .semibold)
public static let title2 = Font.system(size: 22, weight: .bold)
public static let title = Font.system(size: 28, weight: .bold)
public static let largeTitle = Font.system(size: 34, weight: .bold)
public static let mono = Font.system(size: 13, weight: .regular, design: .monospaced)
public static let monoSmall = Font.system(size: 11, weight: .regular, design: .monospaced)
}
// MARK: - Animation
public enum Motion {
public static let quick = Animation.spring(response: 0.25, dampingFraction: 0.85)
public static let soft = Animation.spring(response: 0.40, dampingFraction: 0.80)
public static let deliberate = Animation.spring(response: 0.55, dampingFraction: 0.78)
public static let breath = Animation.easeInOut(duration: 1.6).repeatForever(autoreverses: true)
public static let instant = Animation.linear(duration: 0.12)
}
// MARK: - Reusable view modifiers
public extension View {
/// Standard card surface used in the main app.
func cardSurface(padding: CGFloat = Spacing.md) -> some View {
self
.padding(padding)
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(Palette.divider, lineWidth: 0.5)
)
}
/// Muted pill (used for tags, locale indicators, etc.).
func pillChip(foreground: Color = Palette.textSecondary) -> some View {
self
.padding(.horizontal, Spacing.xs)
.padding(.vertical, 4)
.background(Palette.surfaceElevated, in: Capsule())
.foregroundStyle(foreground)
}
/// Primary CTA button.
func primaryButton() -> some View {
self
.font(TypeStyle.headline)
.frame(maxWidth: .infinity, minHeight: 50)
.background(Palette.accent, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.foregroundStyle(Palette.textOnAccent)
}
/// Secondary CTA button.
func secondaryButton() -> some View {
self
.font(TypeStyle.headline)
.frame(maxWidth: .infinity, minHeight: 50)
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(Palette.dividerStrong, lineWidth: 0.5)
)
.foregroundStyle(Palette.textPrimary)
}
/// Legacy alias for older call sites.
func cardStyle() -> some View { cardSurface() }
}
// MARK: - Backwards compat (legacy callers in old code)
public enum Theme {
public static let background = Palette.background
public static let card = Palette.surface
public static let accent = Palette.accent
public static let danger = Palette.danger
public static let textPrimary = Palette.textPrimary
public static let textSecondary = Palette.textSecondary
public static let divider = Palette.divider
}
+22
View File
@@ -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,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")
}
}
@@ -0,0 +1,106 @@
// 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
}
// MARK: - 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"
static let modeId = "config.modeId"
static let localeId = "config.localeId"
}
// MARK: - Reads
public var providerId: String {
defaults.string(forKey: Key.providerId) ?? "openai"
}
public var baseURL: String {
defaults.string(forKey: Key.baseURL) ?? LLMProvider.provider(id: providerId).defaultBaseURL
}
public var apiKey: String {
defaults.string(forKey: Key.apiKey) ?? ""
}
public var model: String {
defaults.string(forKey: Key.model) ?? LLMProvider.provider(id: providerId).defaultModel
}
public var systemPrompt: String {
defaults.string(forKey: Key.systemPrompt) ?? Self.defaultSystemPrompt(for: providerId)
}
public var modeId: String {
defaults.string(forKey: Key.modeId) ?? "polish"
}
public var localeId: String {
defaults.string(forKey: Key.localeId) ?? "auto"
}
// MARK: - Writes
public func setModeId(_ id: String) {
defaults.set(id, forKey: Key.modeId)
}
public func setLocaleId(_ id: String) {
defaults.set(id, forKey: Key.localeId)
}
// MARK: - Client
public func makeClient() -> LLMClient {
OpenAICompatibleClient(
baseURL: baseURL,
apiKey: apiKey,
model: model
)
}
// MARK: - Defaults
/// Per-provider default system prompt. We bias the prompt by the
/// provider's *primary* language so Chinese LLMs naturally return
/// Chinese for Chinese input, and English LLMs stay terse.
public static func defaultSystemPrompt(for providerId: String) -> String {
switch providerId {
case "zhipu", "moonshot", "qwen", "deepseek":
return """
你是一位语音输入润色助手。请将用户的口述改写为干净的中文(或英文)书面文字:
1) 保留原意,不编造事实;保持输入语言。
2) 添加恰当的标点、大小写、段落。
3) 当用户枚举""时,使用 markdown 列表。
4) 简洁,不超出原长 1.5 倍;可去掉无意义的口头禅(嗯、啊、那个)。
5) 只输出润色后的正文,不要解释、不要加引号。
"""
default:
return """
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. Drop filler words (um, uh, like).
5) Output in the same language as the input. No quotes, no explanation, no preamble.
"""
}
}
}
+125
View File
@@ -0,0 +1,125 @@
// 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)
case decoding(String)
case transport(String)
case cancelled
case rateLimited
public var errorDescription: String? {
switch self {
case .invalidURL: return "API 地址无效。请在设置中检查 Base URL。"
case .noAPIKey: return "未填写 API Key。"
case .http(let s): return "API 返回 HTTP \(s)。请稍后重试或联系服务方。"
case .decoding: return "解析 API 响应失败。"
case .transport: return "网络错误,请检查连接后重试。"
case .rateLimited: return "API 调用过于频繁,请稍候再试。"
case .cancelled: return "请求已取消。"
}
}
}
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("non-HTTP response")
}
if !(200..<300).contains(http.statusCode) {
#if DEBUG
// Log full body for debugging never expose to UI.
let body = String(data: data, encoding: .utf8) ?? ""
print("⚠️ LLM HTTP \(http.statusCode): \(body.prefix(500))")
#endif
if http.statusCode == 429 { throw LLMError.rateLimited }
throw LLMError.http(status: http.statusCode)
}
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(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
)
}
}