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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(©, 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]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user