feat(macos): local ASR model manager, menu-bar polish, and release 0.5.2
Adds a bundled local ASR model catalog for the macOS app with one-click Sherpa Qwen3 / SenseVoice downloads (pause/resume, inline actions) and a shared model storage directory used by MLX Qwen3. Fixes the light-mode sidebar material and makes the menu-bar icon follow the system appearance with a refreshed status mark. Renames the built product to OSGKeyboard.app. Bumps version to 0.5.2 (build 19).
This commit is contained in:
@@ -92,6 +92,7 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
config.model = openAI.defaultModel
|
||||
}
|
||||
}
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public func setUILanguage(_ language: AppUILanguage) {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// BuiltinLexiconIndex.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// In-memory index over bundled `phrases.tsv` (~10k computer terms).
|
||||
// macOS local ASR consumes a Top-N subset; the full index also backs
|
||||
// polish supplements and future retrieval.
|
||||
|
||||
import Foundation
|
||||
|
||||
public final class BuiltinLexiconIndex: @unchecked Sendable {
|
||||
|
||||
public struct Term: Sendable, Equatable {
|
||||
public let word: String
|
||||
public let pinyin: String
|
||||
public let source: String
|
||||
public let weight: Int
|
||||
}
|
||||
|
||||
public static let shared = BuiltinLexiconIndex()
|
||||
|
||||
private let lock = NSLock()
|
||||
private var cachedTerms: [Term]?
|
||||
private let injectedURL: URL?
|
||||
|
||||
/// Production singleton loads from the app bundle.
|
||||
private init() {
|
||||
injectedURL = nil
|
||||
}
|
||||
|
||||
/// Test / preview hook with an explicit TSV file or inline fixture.
|
||||
init(fixtureURL: URL) {
|
||||
injectedURL = fixtureURL
|
||||
}
|
||||
|
||||
/// Parse TSV content without touching the bundle (unit tests).
|
||||
public static func parseTSV(_ content: String) -> [Term] {
|
||||
var terms: [Term] = []
|
||||
terms.reserveCapacity(256)
|
||||
|
||||
for (lineIndex, line) in content.split(whereSeparator: \.isNewline).enumerated() {
|
||||
if lineIndex == 0, line.hasPrefix("word\t") { continue }
|
||||
let columns = line.split(separator: "\t", omittingEmptySubsequences: false)
|
||||
guard columns.count >= 4 else { continue }
|
||||
let word = String(columns[0]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !word.isEmpty else { continue }
|
||||
let pinyin = String(columns[1])
|
||||
let source = String(columns[2])
|
||||
let weight = Int(columns[3]) ?? 1
|
||||
terms.append(Term(word: word, pinyin: pinyin, source: source, weight: weight))
|
||||
}
|
||||
return terms
|
||||
}
|
||||
|
||||
public func termCount() -> Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return loadTermsLocked().count
|
||||
}
|
||||
|
||||
/// Returns canonical words ranked for ASR bias injection.
|
||||
public func topTerms(
|
||||
limit: Int,
|
||||
minimumWeight: Int = 4,
|
||||
preferredSources: Set<String>? = nil
|
||||
) -> [String] {
|
||||
guard limit > 0 else { return [] }
|
||||
|
||||
lock.lock()
|
||||
let all = loadTermsLocked()
|
||||
lock.unlock()
|
||||
|
||||
let filtered = all.filter { term in
|
||||
guard term.weight >= minimumWeight else { return false }
|
||||
if let preferredSources, !preferredSources.isEmpty {
|
||||
return preferredSources.contains(term.source)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
let ranked = filtered.sorted { lhs, rhs in
|
||||
let leftScore = Self.rankingScore(lhs)
|
||||
let rightScore = Self.rankingScore(rhs)
|
||||
if leftScore != rightScore { return leftScore > rightScore }
|
||||
return lhs.word.localizedCaseInsensitiveCompare(rhs.word) == .orderedAscending
|
||||
}
|
||||
|
||||
var seen = Set<String>()
|
||||
var words: [String] = []
|
||||
words.reserveCapacity(min(limit, ranked.count))
|
||||
for term in ranked {
|
||||
let key = term.word.lowercased()
|
||||
guard seen.insert(key).inserted else { continue }
|
||||
words.append(term.word)
|
||||
if words.count >= limit { break }
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func loadTermsLocked() -> [Term] {
|
||||
if let cachedTerms { return cachedTerms }
|
||||
let loaded: [Term]
|
||||
if let injectedURL {
|
||||
loaded = Self.load(from: injectedURL)
|
||||
} else if let url = Self.locateBundledPhrasesURL() {
|
||||
loaded = Self.load(from: url)
|
||||
} else {
|
||||
loaded = []
|
||||
}
|
||||
cachedTerms = loaded
|
||||
return loaded
|
||||
}
|
||||
|
||||
private static func load(from url: URL) -> [Term] {
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let content = String(data: data, encoding: .utf8) else {
|
||||
return []
|
||||
}
|
||||
return parseTSV(content)
|
||||
}
|
||||
|
||||
private static func locateBundledPhrasesURL() -> URL? {
|
||||
let candidates: [Bundle] = [Bundle.main, Bundle(for: BuiltinLexiconIndex.self)]
|
||||
for bundle in candidates {
|
||||
if let url = bundle.url(
|
||||
forResource: "phrases",
|
||||
withExtension: "tsv",
|
||||
subdirectory: "CustomLanguageModel/v1"
|
||||
) {
|
||||
return url
|
||||
}
|
||||
if let url = bundle.url(forResource: "phrases", withExtension: "tsv") {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func rankingScore(_ term: Term) -> Int {
|
||||
var score = term.weight * 100
|
||||
if containsLatinLetters(term.word) { score += 50 }
|
||||
if term.word.count <= 8 { score += 10 }
|
||||
return score
|
||||
}
|
||||
|
||||
private static func containsLatinLetters(_ text: String) -> Bool {
|
||||
text.unicodeScalars.contains { scalar in
|
||||
scalar.isASCII && CharacterSet.letters.contains(scalar)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -384,19 +384,26 @@ public final class FlowContinuousCapture {
|
||||
|
||||
/// Re-activate capture after returning from background without
|
||||
/// reinstalling the tap (iOS may deactivate the audio session).
|
||||
public func reassertIfRunning() {
|
||||
guard isRunning else { return }
|
||||
@discardableResult
|
||||
public func reassertIfRunning() -> Bool {
|
||||
guard isRunning else { return false }
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try? session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .measurement,
|
||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .mixWithOthers]
|
||||
)
|
||||
try? session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
if !audioEngine.isRunning {
|
||||
try? audioEngine.start()
|
||||
do {
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .measurement,
|
||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .mixWithOthers]
|
||||
)
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
if !audioEngine.isRunning {
|
||||
try audioEngine.start()
|
||||
}
|
||||
notifyEngineLiveChanged()
|
||||
return engineIsLive
|
||||
} catch {
|
||||
notifyEngineLiveChanged()
|
||||
return false
|
||||
}
|
||||
notifyEngineLiveChanged()
|
||||
}
|
||||
|
||||
public func awaitAudioFlowing(
|
||||
|
||||
@@ -6,6 +6,158 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct FlowCommand: Codable, Equatable, Sendable {
|
||||
public enum Action: String, Codable, Sendable {
|
||||
case startRecording
|
||||
case stopRecording
|
||||
case abort
|
||||
}
|
||||
|
||||
public let protocolVersion: Int
|
||||
public let sessionId: UUID
|
||||
public let utteranceId: UUID
|
||||
public let commandSeq: Int64
|
||||
public let action: Action
|
||||
public let localeId: String
|
||||
public let createdAt: TimeInterval
|
||||
|
||||
public init(
|
||||
protocolVersion: Int = 1,
|
||||
sessionId: UUID,
|
||||
utteranceId: UUID,
|
||||
commandSeq: Int64,
|
||||
action: Action,
|
||||
localeId: String,
|
||||
createdAt: TimeInterval = Date().timeIntervalSince1970
|
||||
) {
|
||||
self.protocolVersion = protocolVersion
|
||||
self.sessionId = sessionId
|
||||
self.utteranceId = utteranceId
|
||||
self.commandSeq = commandSeq
|
||||
self.action = action
|
||||
self.localeId = localeId
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct FlowResult: Codable, Equatable, Sendable {
|
||||
public enum Status: String, Codable, Sendable {
|
||||
case partial
|
||||
case final
|
||||
case error
|
||||
case aborted
|
||||
case timeout
|
||||
}
|
||||
|
||||
public let protocolVersion: Int
|
||||
public let sessionId: UUID
|
||||
public let utteranceId: UUID
|
||||
public let commandSeq: Int64
|
||||
public let status: Status
|
||||
public let text: String?
|
||||
public let warning: String?
|
||||
public let errorKind: FlowSessionKeys.TranscriptionErrorKind?
|
||||
public let createdAt: TimeInterval
|
||||
|
||||
public init(
|
||||
protocolVersion: Int = 1,
|
||||
sessionId: UUID,
|
||||
utteranceId: UUID,
|
||||
commandSeq: Int64,
|
||||
status: Status,
|
||||
text: String? = nil,
|
||||
warning: String? = nil,
|
||||
errorKind: FlowSessionKeys.TranscriptionErrorKind? = nil,
|
||||
createdAt: TimeInterval = Date().timeIntervalSince1970
|
||||
) {
|
||||
self.protocolVersion = protocolVersion
|
||||
self.sessionId = sessionId
|
||||
self.utteranceId = utteranceId
|
||||
self.commandSeq = commandSeq
|
||||
self.status = status
|
||||
self.text = text
|
||||
self.warning = warning
|
||||
self.errorKind = errorKind
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct FlowAck: Codable, Equatable, Sendable {
|
||||
public let protocolVersion: Int
|
||||
public let sessionId: UUID
|
||||
public let utteranceId: UUID
|
||||
public let commandSeq: Int64
|
||||
public let consumedAt: TimeInterval
|
||||
|
||||
public init(
|
||||
protocolVersion: Int = 1,
|
||||
sessionId: UUID,
|
||||
utteranceId: UUID,
|
||||
commandSeq: Int64,
|
||||
consumedAt: TimeInterval = Date().timeIntervalSince1970
|
||||
) {
|
||||
self.protocolVersion = protocolVersion
|
||||
self.sessionId = sessionId
|
||||
self.utteranceId = utteranceId
|
||||
self.commandSeq = commandSeq
|
||||
self.consumedAt = consumedAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct FlowReadySnapshot: Codable, Equatable, Sendable {
|
||||
public enum Reason: String, Codable, Sendable {
|
||||
case ready
|
||||
case noSession
|
||||
case starting
|
||||
case audioEngineNotLive
|
||||
case waitingForAudioProof
|
||||
case recording
|
||||
case processing
|
||||
case permissionMissing
|
||||
case appGroupUnavailable
|
||||
case hostLost
|
||||
case error
|
||||
}
|
||||
|
||||
public let protocolVersion: Int
|
||||
public let sessionId: UUID?
|
||||
public let ready: Bool
|
||||
public let reason: Reason
|
||||
public let heartbeatAt: TimeInterval
|
||||
public let readyAt: TimeInterval?
|
||||
public let audioProofAt: TimeInterval?
|
||||
public let engineMode: String
|
||||
public let localeId: String
|
||||
public let busyUtteranceId: UUID?
|
||||
public let sessionExpiresAt: TimeInterval?
|
||||
|
||||
public init(
|
||||
protocolVersion: Int = 1,
|
||||
sessionId: UUID?,
|
||||
ready: Bool,
|
||||
reason: Reason,
|
||||
heartbeatAt: TimeInterval = Date().timeIntervalSince1970,
|
||||
readyAt: TimeInterval? = nil,
|
||||
audioProofAt: TimeInterval? = nil,
|
||||
engineMode: String,
|
||||
localeId: String,
|
||||
busyUtteranceId: UUID? = nil,
|
||||
sessionExpiresAt: TimeInterval? = nil
|
||||
) {
|
||||
self.protocolVersion = protocolVersion
|
||||
self.sessionId = sessionId
|
||||
self.ready = ready
|
||||
self.reason = reason
|
||||
self.heartbeatAt = heartbeatAt
|
||||
self.readyAt = readyAt
|
||||
self.audioProofAt = audioProofAt
|
||||
self.engineMode = engineMode
|
||||
self.localeId = localeId
|
||||
self.busyUtteranceId = busyUtteranceId
|
||||
self.sessionExpiresAt = sessionExpiresAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct FlowTranscriptionError: Equatable, Sendable {
|
||||
public let message: String
|
||||
public let kind: FlowSessionKeys.TranscriptionErrorKind
|
||||
@@ -36,6 +188,15 @@ public enum FlowSessionBridge {
|
||||
}
|
||||
}
|
||||
|
||||
private static func encode<T: Encodable>(_ value: T) -> Data? {
|
||||
try? JSONEncoder().encode(value)
|
||||
}
|
||||
|
||||
private static func decode<T: Decodable>(_ type: T.Type, from data: Data?) -> T? {
|
||||
guard let data else { return nil }
|
||||
return try? JSONDecoder().decode(type, from: data)
|
||||
}
|
||||
|
||||
/// Keyboard/read side: refresh App Group defaults after the extension was
|
||||
/// suspended so decisions are not based on stale in-process caches.
|
||||
public static func reloadFromDisk(defaults: UserDefaults? = nil) {
|
||||
@@ -45,10 +206,87 @@ public enum FlowSessionBridge {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Typed Flow protocol
|
||||
|
||||
public static func writeCommand(_ command: FlowCommand, defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
if let data = encode(command) {
|
||||
store.set(data, forKey: FlowSessionKeys.flowCommandPayload)
|
||||
}
|
||||
flush(store)
|
||||
FlowSessionDarwin.postCommandChanged()
|
||||
}
|
||||
|
||||
public static func latestCommand(defaults: UserDefaults? = nil) -> FlowCommand? {
|
||||
let store = resolvedDefaults(defaults)
|
||||
return decode(FlowCommand.self, from: store.data(forKey: FlowSessionKeys.flowCommandPayload))
|
||||
}
|
||||
|
||||
public static func writeResult(_ result: FlowResult, defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
if let data = encode(result) {
|
||||
store.set(data, forKey: FlowSessionKeys.flowResultPayload)
|
||||
}
|
||||
flush(store)
|
||||
FlowSessionDarwin.postTranscriptionChanged()
|
||||
}
|
||||
|
||||
public static func latestResult(defaults: UserDefaults? = nil) -> FlowResult? {
|
||||
let store = resolvedDefaults(defaults)
|
||||
return decode(FlowResult.self, from: store.data(forKey: FlowSessionKeys.flowResultPayload))
|
||||
}
|
||||
|
||||
public static func clearResult(defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowResultPayload)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
public static func writeAck(_ ack: FlowAck, defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
if let data = encode(ack) {
|
||||
store.set(data, forKey: FlowSessionKeys.flowAckPayload)
|
||||
}
|
||||
flush(store)
|
||||
}
|
||||
|
||||
public static func latestAck(defaults: UserDefaults? = nil) -> FlowAck? {
|
||||
let store = resolvedDefaults(defaults)
|
||||
return decode(FlowAck.self, from: store.data(forKey: FlowSessionKeys.flowAckPayload))
|
||||
}
|
||||
|
||||
public static func writeReadySnapshot(_ snapshot: FlowReadySnapshot, defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
if let data = encode(snapshot) {
|
||||
store.set(data, forKey: FlowSessionKeys.flowReadyPayload)
|
||||
}
|
||||
if snapshot.ready {
|
||||
store.set(true, forKey: FlowSessionKeys.flowHostReady)
|
||||
if let readyAt = snapshot.readyAt {
|
||||
store.set(readyAt, forKey: FlowSessionKeys.flowHostReadyAt)
|
||||
}
|
||||
} else {
|
||||
clearHostReady(defaults: store, notify: false)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
|
||||
}
|
||||
if let expires = snapshot.sessionExpiresAt {
|
||||
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
|
||||
}
|
||||
store.set(snapshot.heartbeatAt, forKey: FlowSessionKeys.flowHeartbeat)
|
||||
flush(store)
|
||||
FlowSessionDarwin.postHostReadyChanged()
|
||||
}
|
||||
|
||||
public static func readySnapshot(defaults: UserDefaults? = nil) -> FlowReadySnapshot? {
|
||||
let store = resolvedDefaults(defaults)
|
||||
return decode(FlowReadySnapshot.self, from: store.data(forKey: FlowSessionKeys.flowReadyPayload))
|
||||
}
|
||||
|
||||
// MARK: - Session lifecycle (host app)
|
||||
|
||||
public static func markSessionActive(
|
||||
duration: TimeInterval? = nil,
|
||||
sessionId: UUID? = nil,
|
||||
defaults: UserDefaults? = nil
|
||||
) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
@@ -59,8 +297,26 @@ public enum FlowSessionBridge {
|
||||
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
|
||||
store.set(now, forKey: FlowSessionKeys.lastActivityAt)
|
||||
writeHeartbeat(defaults: store)
|
||||
setRecordingState(.idle, defaults: store)
|
||||
clearTranscription(defaults: store)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowCommandPayload)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowResultPayload)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowAckPayload)
|
||||
if let sessionId {
|
||||
let snapshot = FlowReadySnapshot(
|
||||
sessionId: sessionId,
|
||||
ready: false,
|
||||
reason: .starting,
|
||||
heartbeatAt: now,
|
||||
engineMode: AppGroupConfiguration.load(fromAvailable: store).engineMode,
|
||||
localeId: AppGroupConfiguration.load(fromAvailable: store).localeId,
|
||||
sessionExpiresAt: expires
|
||||
)
|
||||
if let data = encode(snapshot) {
|
||||
store.set(data, forKey: FlowSessionKeys.flowReadyPayload)
|
||||
}
|
||||
} else {
|
||||
store.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
|
||||
}
|
||||
flush(store)
|
||||
}
|
||||
|
||||
@@ -69,8 +325,11 @@ public enum FlowSessionBridge {
|
||||
store.set(false, forKey: FlowSessionKeys.flowSessionActive)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowSessionExpires)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowHeartbeat)
|
||||
setRecordingState(.idle, defaults: store)
|
||||
clearTranscription(defaults: store)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowCommandPayload)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowResultPayload)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowAckPayload)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
|
||||
clearHostReady(defaults: store, notify: false)
|
||||
flush(store)
|
||||
}
|
||||
@@ -185,6 +444,15 @@ public enum FlowSessionBridge {
|
||||
/// True when the host has published a fresh ready contract (stricter than heartbeat alone).
|
||||
public static func isHostReady(defaults: UserDefaults? = nil) -> Bool {
|
||||
let store = resolvedDefaults(defaults)
|
||||
if let snapshot = readySnapshot(defaults: store) {
|
||||
guard snapshot.ready else { return false }
|
||||
guard isHostReachable(defaults: store) else { return false }
|
||||
if let readyAt = snapshot.readyAt {
|
||||
let skew = abs(snapshot.heartbeatAt - readyAt)
|
||||
guard skew <= FlowSessionKeys.hostReadyMaxHeartbeatSkew else { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
guard isHostReachable(defaults: store) else { return false }
|
||||
return store.bool(forKey: FlowSessionKeys.flowHostReady)
|
||||
}
|
||||
@@ -393,6 +661,10 @@ public enum FlowSessionBridge {
|
||||
store.removeObject(forKey: FlowSessionKeys.flowHeartbeat)
|
||||
store.removeObject(forKey: FlowSessionKeys.keyboardRecordingState)
|
||||
store.removeObject(forKey: FlowSessionKeys.transcriptionLanguage)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowCommandPayload)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowResultPayload)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowAckPayload)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
|
||||
clearTranscription(defaults: store)
|
||||
store.removeObject(forKey: FlowSessionKeys.audioLevels)
|
||||
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
|
||||
|
||||
@@ -8,6 +8,8 @@ import Foundation
|
||||
|
||||
public enum FlowSessionDarwin {
|
||||
public static let notificationName = "com.osgkeyboard.flow.session.changed"
|
||||
/// Posted when the keyboard writes a command for the host app.
|
||||
public static let commandNotificationName = "com.osgkeyboard.flow.command.changed"
|
||||
/// Posted when the host app writes a transcription result or error.
|
||||
public static let transcriptionNotificationName = "com.osgkeyboard.flow.transcription.changed"
|
||||
/// Posted when the host app publishes or clears the ready contract.
|
||||
@@ -23,6 +25,16 @@ public enum FlowSessionDarwin {
|
||||
)
|
||||
}
|
||||
|
||||
public static func postCommandChanged() {
|
||||
CFNotificationCenterPostNotification(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
CFNotificationName(commandNotificationName as CFString),
|
||||
nil,
|
||||
nil,
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
public static func postTranscriptionChanged() {
|
||||
CFNotificationCenterPostNotification(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
import Foundation
|
||||
|
||||
public enum FlowSessionKeys {
|
||||
public static let flowCommandPayload = "flow.commandPayload.v1"
|
||||
public static let flowResultPayload = "flow.resultPayload.v1"
|
||||
public static let flowAckPayload = "flow.ackPayload.v1"
|
||||
public static let flowReadyPayload = "flow.readyPayload.v1"
|
||||
public static let flowSessionActive = "flow.flowSessionActive"
|
||||
public static let flowSessionExpires = "flow.flowSessionExpires"
|
||||
public static let flowHeartbeat = "flow.flowHeartbeat"
|
||||
@@ -74,7 +78,7 @@ public enum FlowSessionKeys {
|
||||
}
|
||||
|
||||
/// Structured host → keyboard transcription failure kind.
|
||||
public enum TranscriptionErrorKind: String, Sendable, Equatable {
|
||||
public enum TranscriptionErrorKind: String, Sendable, Equatable, Codable {
|
||||
case noSpeech
|
||||
case recognitionInterrupted
|
||||
case audioUnavailable
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// LocalASRBiasAdapter.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Maps `PersonalDictionary` + builtin lexicon + runtime context into the
|
||||
// layered bias outputs consumed by local ASR, correction, and polish.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum LocalASRBiasAdapter {
|
||||
|
||||
/// Bundle IDs where computer-science vocabulary is especially likely.
|
||||
private static let codeEditorBundleIDs: Set<String> = [
|
||||
"com.apple.dt.Xcode",
|
||||
"com.microsoft.VSCode",
|
||||
"com.google.android.studio",
|
||||
"com.jetbrains.intellij",
|
||||
"com.jetbrains.AppCode",
|
||||
"com.sublimetext.4",
|
||||
"com.apple.Terminal",
|
||||
"com.googlecode.iterm2",
|
||||
"dev.warp.Warp-Stable",
|
||||
]
|
||||
|
||||
public static func adapt(
|
||||
_ request: LocalASRBiasRequest,
|
||||
lexicon: BuiltinLexiconIndex = .shared
|
||||
) -> LocalASRBiasPayload {
|
||||
let capabilities = request.capabilities
|
||||
let dictionary = request.dictionary
|
||||
|
||||
var selectedSources = ["user"]
|
||||
let preferredSources = Self.preferredLexiconSources(for: request.frontAppBundleId)
|
||||
if preferredSources != nil {
|
||||
selectedSources.append("builtin-computer")
|
||||
} else {
|
||||
selectedSources.append("builtin-top")
|
||||
}
|
||||
|
||||
let userSorted = dictionary.effectiveEntries.sorted { $0.usageCount > $1.usageCount }
|
||||
var mergedTerms: [String] = []
|
||||
var seen = Set<String>()
|
||||
func appendTerm(_ term: String) {
|
||||
let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
let key = trimmed.lowercased()
|
||||
guard seen.insert(key).inserted else { return }
|
||||
mergedTerms.append(trimmed)
|
||||
}
|
||||
|
||||
for entry in userSorted {
|
||||
appendTerm(entry.term)
|
||||
}
|
||||
let userTermCount = mergedTerms.count
|
||||
|
||||
let builtinWords = lexicon.topTerms(
|
||||
limit: request.builtinASRLimit,
|
||||
minimumWeight: 4,
|
||||
preferredSources: preferredSources
|
||||
)
|
||||
let beforeBuiltin = mergedTerms.count
|
||||
for word in builtinWords {
|
||||
appendTerm(word)
|
||||
}
|
||||
let builtinTermCount = mergedTerms.count - beforeBuiltin
|
||||
|
||||
var hardHotwords: [String] = []
|
||||
switch capabilities.hotwordMode {
|
||||
case .perRequest, .recognizerScoped:
|
||||
let cap = max(capabilities.maxHotwordCount, 1)
|
||||
hardHotwords = Self.hardHotwordList(from: mergedTerms, maxCount: cap)
|
||||
case .cloudVocabulary:
|
||||
hardHotwords = dictionary.asrHotwords(maxCount: max(capabilities.maxHotwordCount, 1))
|
||||
case .none, .promptOnly:
|
||||
break
|
||||
}
|
||||
|
||||
var promptBias: String?
|
||||
var truncated = false
|
||||
var truncationReason: String?
|
||||
|
||||
if capabilities.hotwordMode == .promptOnly, capabilities.maxPromptCharacters > 0 {
|
||||
let built = Self.buildPromptBias(
|
||||
dictionary: dictionary,
|
||||
builtinTerms: builtinWords,
|
||||
maxCharacters: capabilities.maxPromptCharacters
|
||||
)
|
||||
if built.count > capabilities.maxPromptCharacters {
|
||||
truncated = true
|
||||
truncationReason = "promptBias exceeded \(capabilities.maxPromptCharacters) characters"
|
||||
}
|
||||
promptBias = built.isEmpty ? nil : built
|
||||
}
|
||||
|
||||
let polishFragment = Self.buildPolishFragment(
|
||||
dictionary: dictionary,
|
||||
builtinTerms: builtinWords,
|
||||
maxTerms: request.builtinPolishLimit
|
||||
)
|
||||
|
||||
let correctionPairs = dictionary.localCorrectionPairs()
|
||||
|
||||
return LocalASRBiasPayload(
|
||||
hardHotwords: hardHotwords,
|
||||
promptBias: promptBias,
|
||||
corpusContext: promptBias,
|
||||
polishFragment: polishFragment,
|
||||
correctionPairs: correctionPairs,
|
||||
diagnostics: LocalASRBiasDiagnostics(
|
||||
userTermCount: userTermCount,
|
||||
builtinTermCount: builtinTermCount,
|
||||
truncated: truncated,
|
||||
truncationReason: truncationReason,
|
||||
selectedSources: selectedSources
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static func preferredLexiconSources(for bundleId: String?) -> Set<String>? {
|
||||
guard let bundleId, codeEditorBundleIDs.contains(bundleId) else { return nil }
|
||||
return ["computer_terms"]
|
||||
}
|
||||
|
||||
private static func hardHotwordList(from terms: [String], maxCount: Int) -> [String] {
|
||||
Array(terms.prefix(maxCount))
|
||||
}
|
||||
|
||||
private static func buildPromptBias(
|
||||
dictionary: PersonalDictionary,
|
||||
builtinTerms: [String],
|
||||
maxCharacters: Int
|
||||
) -> String {
|
||||
let userBias = dictionary.asrPromptBias(maxCharacters: maxCharacters)
|
||||
let userTermsLower = Set(dictionary.effectiveEntries.map { $0.term.lowercased() })
|
||||
let extras = builtinTerms.filter { !userTermsLower.contains($0.lowercased()) }
|
||||
guard !extras.isEmpty else { return userBias }
|
||||
|
||||
let extraBlock = "常见技术词汇:\(extras.prefix(80).joined(separator: "、"))"
|
||||
if userBias.isEmpty {
|
||||
return String(extraBlock.prefix(maxCharacters))
|
||||
}
|
||||
let combined = userBias + ";" + extraBlock
|
||||
return String(combined.prefix(maxCharacters))
|
||||
}
|
||||
|
||||
private static func buildPolishFragment(
|
||||
dictionary: PersonalDictionary,
|
||||
builtinTerms: [String],
|
||||
maxTerms: Int
|
||||
) -> String {
|
||||
let userTermsLower = Set(dictionary.effectiveEntries.map { $0.term.lowercased() })
|
||||
let extras = builtinTerms
|
||||
.filter { !userTermsLower.contains($0.lowercased()) }
|
||||
.prefix(maxTerms)
|
||||
guard !extras.isEmpty else { return "" }
|
||||
return "内置技术词汇参考(需原样保留):\(extras.joined(separator: "、"))"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// LocalASRBiasDiagnosticsStore.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Persists the most recent local ASR bias diagnostics for settings / debug UI.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct LocalASRBiasDiagnosticsSnapshot: Codable, Sendable, Equatable {
|
||||
public var capturedAt: Date
|
||||
public var modelId: String?
|
||||
public var backendLabel: String?
|
||||
public var diagnostics: LocalASRBiasDiagnostics
|
||||
public var hotwordCount: Int
|
||||
public var promptBiasLength: Int
|
||||
|
||||
public init(
|
||||
capturedAt: Date = Date(),
|
||||
modelId: String? = nil,
|
||||
backendLabel: String? = nil,
|
||||
diagnostics: LocalASRBiasDiagnostics,
|
||||
hotwordCount: Int = 0,
|
||||
promptBiasLength: Int = 0
|
||||
) {
|
||||
self.capturedAt = capturedAt
|
||||
self.modelId = modelId
|
||||
self.backendLabel = backendLabel
|
||||
self.diagnostics = diagnostics
|
||||
self.hotwordCount = hotwordCount
|
||||
self.promptBiasLength = promptBiasLength
|
||||
}
|
||||
}
|
||||
|
||||
public enum LocalASRBiasDiagnosticsStore {
|
||||
private static let defaultsKey = "mac.localASR.lastBiasDiagnostics"
|
||||
|
||||
public static func save(payload: LocalASRBiasPayload, modelId: String?, backendLabel: String?) {
|
||||
let snapshot = LocalASRBiasDiagnosticsSnapshot(
|
||||
modelId: modelId,
|
||||
backendLabel: backendLabel,
|
||||
diagnostics: payload.diagnostics,
|
||||
hotwordCount: payload.hardHotwords.count,
|
||||
promptBiasLength: payload.promptBias?.count ?? 0
|
||||
)
|
||||
guard let data = try? JSONEncoder().encode(snapshot) else { return }
|
||||
UserDefaults.standard.set(data, forKey: defaultsKey)
|
||||
}
|
||||
|
||||
public static func load() -> LocalASRBiasDiagnosticsSnapshot? {
|
||||
guard let data = UserDefaults.standard.data(forKey: defaultsKey) else { return nil }
|
||||
return try? JSONDecoder().decode(LocalASRBiasDiagnosticsSnapshot.self, from: data)
|
||||
}
|
||||
|
||||
public static func clear() {
|
||||
UserDefaults.standard.removeObject(forKey: defaultsKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// LocalASRInstalledManifestIO.swift
|
||||
// OSGKeyboard · Shared
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum LocalASRInstalledManifestIO {
|
||||
|
||||
public static func manifestURL(fileManager: FileManager = .default) -> URL {
|
||||
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
return appSupport
|
||||
.appendingPathComponent("OSGKeyboard/LocalASRModels/installed-manifest.json")
|
||||
}
|
||||
|
||||
public static func load(defaultModelId: String, fileManager: FileManager = .default) -> LocalASRInstalledManifest {
|
||||
let url = manifestURL(fileManager: fileManager)
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let manifest = try? JSONDecoder().decode(LocalASRInstalledManifest.self, from: data) else {
|
||||
return LocalASRInstalledManifest(selectedModelId: defaultModelId)
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
public static func save(_ manifest: LocalASRInstalledManifest, fileManager: FileManager = .default) throws {
|
||||
let url = manifestURL(fileManager: fileManager)
|
||||
try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let data = try encoder.encode(manifest)
|
||||
try data.write(to: url, options: .atomic)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// LocalASRModelDownloadClient.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// URLSession download with byte-level progress and pause/resume (macOS local model installs).
|
||||
|
||||
import Foundation
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
public struct LocalASRDownloadProgressUpdate: Sendable {
|
||||
public let bytesReceived: Int64
|
||||
public let bytesTotal: Int64
|
||||
|
||||
public var fraction: Double {
|
||||
guard bytesTotal > 0 else { return 0 }
|
||||
return min(1, max(0, Double(bytesReceived) / Double(bytesTotal)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Controls an in-flight URLSession download; supports pause via resume data.
|
||||
public final class LocalASRModelDownloadController: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
|
||||
private let destinationURL: URL
|
||||
private let onProgress: @Sendable (LocalASRDownloadProgressUpdate) -> Void
|
||||
private lazy var delegateSession: URLSession = {
|
||||
URLSession(configuration: .default, delegate: self, delegateQueue: nil)
|
||||
}()
|
||||
|
||||
private var remoteURL: URL?
|
||||
private var task: URLSessionDownloadTask?
|
||||
private var completionContinuation: CheckedContinuation<Void, Error>?
|
||||
private var isPausing = false
|
||||
private var finished = false
|
||||
|
||||
init(
|
||||
destinationURL: URL,
|
||||
onProgress: @escaping @Sendable (LocalASRDownloadProgressUpdate) -> Void
|
||||
) {
|
||||
self.destinationURL = destinationURL
|
||||
self.onProgress = onProgress
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// Runs until the archive is fully written to `destinationURL` (survives pause/resume).
|
||||
public func download(from remoteURL: URL) async throws {
|
||||
self.remoteURL = remoteURL
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
completionContinuation = continuation
|
||||
startTask(resumeData: nil)
|
||||
}
|
||||
}
|
||||
|
||||
public func pause() async throws -> Data {
|
||||
guard task != nil, !finished else {
|
||||
throw LocalASRModelManagerError.downloadFailed("No active download to pause.")
|
||||
}
|
||||
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Data, Error>) in
|
||||
isPausing = true
|
||||
task?.cancel(byProducingResumeData: { [weak self] data in
|
||||
guard let self else { return }
|
||||
self.isPausing = false
|
||||
if let data {
|
||||
continuation.resume(returning: data)
|
||||
} else {
|
||||
continuation.resume(throwing: LocalASRModelManagerError.downloadFailed("Pause failed."))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Continues a paused download; `download(from:)` must still be awaiting.
|
||||
public func resumeFromPause(_ resumeData: Data) {
|
||||
finished = false
|
||||
startTask(resumeData: resumeData)
|
||||
}
|
||||
|
||||
public func cancel() {
|
||||
finished = true
|
||||
task?.cancel()
|
||||
completionContinuation?.resume(throwing: CancellationError())
|
||||
completionContinuation = nil
|
||||
delegateSession.invalidateAndCancel()
|
||||
}
|
||||
|
||||
private func startTask(resumeData: Data?) {
|
||||
if let resumeData {
|
||||
task = delegateSession.downloadTask(withResumeData: resumeData)
|
||||
} else if let remoteURL {
|
||||
task = delegateSession.downloadTask(with: remoteURL)
|
||||
}
|
||||
task?.resume()
|
||||
}
|
||||
|
||||
// MARK: - URLSessionDownloadDelegate
|
||||
|
||||
public func urlSession(
|
||||
_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didWriteData bytesWritten: Int64,
|
||||
totalBytesWritten: Int64,
|
||||
totalBytesExpectedToWrite: Int64
|
||||
) {
|
||||
onProgress(
|
||||
LocalASRDownloadProgressUpdate(
|
||||
bytesReceived: totalBytesWritten,
|
||||
bytesTotal: max(totalBytesExpectedToWrite, 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public func urlSession(
|
||||
_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didFinishDownloadingTo location: URL
|
||||
) {
|
||||
guard !finished else { return }
|
||||
finished = true
|
||||
do {
|
||||
let fm = FileManager.default
|
||||
if fm.fileExists(atPath: destinationURL.path) {
|
||||
try fm.removeItem(at: destinationURL)
|
||||
}
|
||||
try fm.moveItem(at: location, to: destinationURL)
|
||||
completionContinuation?.resume()
|
||||
} catch {
|
||||
completionContinuation?.resume(
|
||||
throwing: LocalASRModelManagerError.downloadFailed(error.localizedDescription)
|
||||
)
|
||||
}
|
||||
completionContinuation = nil
|
||||
session.finishTasksAndInvalidate()
|
||||
}
|
||||
|
||||
public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
guard !finished else { return }
|
||||
if isPausing { return }
|
||||
if let error {
|
||||
finished = true
|
||||
completionContinuation?.resume(
|
||||
throwing: LocalASRModelManagerError.downloadFailed(error.localizedDescription)
|
||||
)
|
||||
completionContinuation = nil
|
||||
session.finishTasksAndInvalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum LocalASRModelDownloadClient {
|
||||
|
||||
public static func makeController(
|
||||
destinationURL: URL,
|
||||
onProgress: @escaping @Sendable (LocalASRDownloadProgressUpdate) -> Void
|
||||
) -> LocalASRModelDownloadController {
|
||||
LocalASRModelDownloadController(destinationURL: destinationURL, onProgress: onProgress)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,138 @@
|
||||
// LocalASRModelInstallState.swift
|
||||
// OSGKeyboard · Shared
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum LocalASRModelInstallState {
|
||||
|
||||
public static func rootDirectory(fileManager: FileManager = .default) -> URL {
|
||||
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
return appSupport.appendingPathComponent("OSGKeyboard/LocalASRModels", isDirectory: true)
|
||||
}
|
||||
|
||||
public static func installDirectory(for relativePath: String, fileManager: FileManager = .default) -> URL {
|
||||
rootDirectory(fileManager: fileManager).appendingPathComponent(relativePath, isDirectory: true)
|
||||
}
|
||||
|
||||
public static func isInstalled(
|
||||
_ model: LocalASRModelDefinition,
|
||||
manualMLXPath: String?,
|
||||
fileManager: FileManager = .default
|
||||
) -> Bool {
|
||||
switch model.installKind {
|
||||
case .manual:
|
||||
guard let required = model.requiredRelativeFiles, !required.isEmpty else { return false }
|
||||
let base = URL(fileURLWithPath: manualMLXPath ?? "", isDirectory: true)
|
||||
guard fileManager.fileExists(atPath: base.path) else { return false }
|
||||
return required.allSatisfy { fileManager.fileExists(atPath: base.appendingPathComponent($0).path) }
|
||||
case .archive:
|
||||
guard let relative = model.installRelativePath,
|
||||
let layout = model.layout,
|
||||
let baseName = model.archiveBaseName else { return false }
|
||||
let root = installDirectory(for: relative, fileManager: fileManager)
|
||||
.appendingPathComponent(baseName, isDirectory: true)
|
||||
return validateArchiveModel(at: root, model: model, layout: layout, fileManager: fileManager)
|
||||
case .runtime:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public static func modelRootURL(
|
||||
_ model: LocalASRModelDefinition,
|
||||
fileManager: FileManager = .default
|
||||
) -> URL? {
|
||||
guard model.installKind == .archive,
|
||||
let relative = model.installRelativePath,
|
||||
let baseName = model.archiveBaseName else { return nil }
|
||||
return installDirectory(for: relative, fileManager: fileManager)
|
||||
.appendingPathComponent(baseName, isDirectory: true)
|
||||
}
|
||||
|
||||
public static func resolveRuntimeBinary(
|
||||
runtime: LocalASRRuntimeDefinition,
|
||||
fileManager: FileManager = .default
|
||||
) -> URL? {
|
||||
let root = installDirectory(for: runtime.installRelativePath, fileManager: fileManager)
|
||||
for candidate in runtime.binaryCandidates {
|
||||
let direct = root.appendingPathComponent(candidate)
|
||||
if fileManager.isExecutableFile(atPath: direct.path) {
|
||||
return direct
|
||||
}
|
||||
}
|
||||
for candidate in runtime.binaryCandidates {
|
||||
let name = (candidate as NSString).lastPathComponent
|
||||
if let found = findExecutable(named: name, under: root, fileManager: fileManager) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func isRuntimeInstalled(
|
||||
_ runtime: LocalASRRuntimeDefinition,
|
||||
fileManager: FileManager = .default
|
||||
) -> Bool {
|
||||
resolveRuntimeBinary(runtime: runtime, fileManager: fileManager) != nil
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static func validateArchiveModel(
|
||||
at root: URL,
|
||||
model: LocalASRModelDefinition,
|
||||
layout: LocalASRModelLayout,
|
||||
fileManager: FileManager
|
||||
) -> Bool {
|
||||
switch model.backend {
|
||||
case .sherpaQwen3:
|
||||
guard let conv = layout.convFrontend,
|
||||
let encoder = layout.encoder,
|
||||
let decoder = layout.decoder,
|
||||
let tokenizer = layout.tokenizer else { return false }
|
||||
return fileManager.fileExists(atPath: root.appendingPathComponent(conv).path)
|
||||
&& fileManager.fileExists(atPath: root.appendingPathComponent(encoder).path)
|
||||
&& fileManager.fileExists(atPath: root.appendingPathComponent(decoder).path)
|
||||
&& fileManager.fileExists(atPath: root.appendingPathComponent(tokenizer, isDirectory: true).path)
|
||||
case .sherpaSenseVoice:
|
||||
guard let onnx = layout.senseVoiceModel,
|
||||
let tokens = layout.tokens else { return false }
|
||||
return fileManager.fileExists(atPath: root.appendingPathComponent(onnx).path)
|
||||
&& fileManager.fileExists(atPath: root.appendingPathComponent(tokens).path)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func findExecutable(
|
||||
named name: String,
|
||||
under root: URL,
|
||||
fileManager: FileManager
|
||||
) -> URL? {
|
||||
guard let enumerator = fileManager.enumerator(
|
||||
at: root,
|
||||
includingPropertiesForKeys: [.isExecutableKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else { return nil }
|
||||
for case let url as URL in enumerator {
|
||||
guard url.lastPathComponent == name else { continue }
|
||||
if fileManager.isExecutableFile(atPath: url.path) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func directoryByteCount(at url: URL, fileManager: FileManager = .default) -> Int64 {
|
||||
guard let enumerator = fileManager.enumerator(
|
||||
at: url,
|
||||
includingPropertiesForKeys: [.fileSizeKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else { return 0 }
|
||||
var total: Int64 = 0
|
||||
for case let fileURL as URL in enumerator {
|
||||
let size = (try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
|
||||
total += Int64(size)
|
||||
}
|
||||
return total
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
// LocalASRModelManager.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Installs local ASR model archives and Sherpa runtimes under Application Support.
|
||||
// Catalog is bundled; installed state is persisted in `installed-manifest.json`.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct LocalASRInstalledManifest: Codable, Sendable, Equatable {
|
||||
public var schemaVersion: Int
|
||||
public var selectedModelId: String
|
||||
public var installedModelIDs: [String]
|
||||
public var installedRuntimeIDs: [String]
|
||||
public var updatedAt: Date
|
||||
|
||||
public init(
|
||||
schemaVersion: Int = 1,
|
||||
selectedModelId: String,
|
||||
installedModelIDs: [String] = [],
|
||||
installedRuntimeIDs: [String] = [],
|
||||
updatedAt: Date = Date()
|
||||
) {
|
||||
self.schemaVersion = schemaVersion
|
||||
self.selectedModelId = selectedModelId
|
||||
self.installedModelIDs = installedModelIDs
|
||||
self.installedRuntimeIDs = installedRuntimeIDs
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
public enum LocalASRModelInstallPhase: String, Sendable, Equatable {
|
||||
case idle
|
||||
case downloading
|
||||
case paused
|
||||
case extracting
|
||||
case validating
|
||||
case finalizing
|
||||
case failed
|
||||
case completed
|
||||
}
|
||||
|
||||
public struct LocalASRModelInstallProgress: Sendable, Equatable {
|
||||
public var phase: LocalASRModelInstallPhase
|
||||
public var fraction: Double
|
||||
public var message: String
|
||||
public var bytesReceived: Int64?
|
||||
public var bytesTotal: Int64?
|
||||
public var activeItemId: String?
|
||||
|
||||
public init(
|
||||
phase: LocalASRModelInstallPhase,
|
||||
fraction: Double,
|
||||
message: String,
|
||||
bytesReceived: Int64? = nil,
|
||||
bytesTotal: Int64? = nil,
|
||||
activeItemId: String? = nil
|
||||
) {
|
||||
self.phase = phase
|
||||
self.fraction = fraction
|
||||
self.message = message
|
||||
self.bytesReceived = bytesReceived
|
||||
self.bytesTotal = bytesTotal
|
||||
self.activeItemId = activeItemId
|
||||
}
|
||||
|
||||
public static let idle = LocalASRModelInstallProgress(phase: .idle, fraction: 0, message: "")
|
||||
}
|
||||
|
||||
public enum LocalASRModelManagerError: Error, LocalizedError {
|
||||
case downloadFailed(String)
|
||||
case extractFailed(String)
|
||||
case validationFailed(String)
|
||||
case runtimeMissing
|
||||
case binaryMissing
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .downloadFailed(let detail): return "Download failed: \(detail)"
|
||||
case .extractFailed(let detail): return "Extract failed: \(detail)"
|
||||
case .validationFailed(let detail): return "Validation failed: \(detail)"
|
||||
case .runtimeMissing: return "Sherpa runtime is not installed."
|
||||
case .binaryMissing: return "Sherpa binary not found in runtime bundle."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public actor LocalASRModelManager {
|
||||
|
||||
public static let shared = LocalASRModelManager()
|
||||
|
||||
private let fileManager = FileManager.default
|
||||
private var progress = LocalASRModelInstallProgress.idle
|
||||
#if os(macOS)
|
||||
private var activeDownloadController: LocalASRModelDownloadController?
|
||||
private var pausedResumeData: Data?
|
||||
#endif
|
||||
|
||||
private init() {}
|
||||
|
||||
public func currentProgress() -> LocalASRModelInstallProgress {
|
||||
progress
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
public func pauseDownload() async throws {
|
||||
guard progress.phase == .downloading, let controller = activeDownloadController else { return }
|
||||
let resumeData = try await controller.pause()
|
||||
pausedResumeData = resumeData
|
||||
progress = LocalASRModelInstallProgress(
|
||||
phase: .paused,
|
||||
fraction: progress.fraction,
|
||||
message: progress.message,
|
||||
bytesReceived: progress.bytesReceived,
|
||||
bytesTotal: progress.bytesTotal,
|
||||
activeItemId: progress.activeItemId
|
||||
)
|
||||
}
|
||||
|
||||
public func resumeDownload() async throws {
|
||||
guard progress.phase == .paused,
|
||||
let resumeData = pausedResumeData,
|
||||
let controller = activeDownloadController else {
|
||||
throw LocalASRModelManagerError.downloadFailed("No paused download to resume.")
|
||||
}
|
||||
pausedResumeData = nil
|
||||
progress = LocalASRModelInstallProgress(
|
||||
phase: .downloading,
|
||||
fraction: progress.fraction,
|
||||
message: progress.message,
|
||||
bytesReceived: progress.bytesReceived,
|
||||
bytesTotal: progress.bytesTotal,
|
||||
activeItemId: progress.activeItemId
|
||||
)
|
||||
controller.resumeFromPause(resumeData)
|
||||
}
|
||||
|
||||
public func isDownloadPaused() -> Bool {
|
||||
progress.phase == .paused
|
||||
}
|
||||
#endif
|
||||
|
||||
public func rootDirectory() -> URL {
|
||||
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
return appSupport.appendingPathComponent("OSGKeyboard/LocalASRModels", isDirectory: true)
|
||||
}
|
||||
|
||||
public func manifestURL() -> URL {
|
||||
LocalASRInstalledManifestIO.manifestURL(fileManager: fileManager)
|
||||
}
|
||||
|
||||
public func loadManifest(defaultModelId: String) -> LocalASRInstalledManifest {
|
||||
LocalASRInstalledManifestIO.load(defaultModelId: defaultModelId, fileManager: fileManager)
|
||||
}
|
||||
|
||||
public func saveManifest(_ manifest: LocalASRInstalledManifest) throws {
|
||||
try LocalASRInstalledManifestIO.save(manifest, fileManager: fileManager)
|
||||
}
|
||||
|
||||
public func setSelectedModelId(_ modelId: String, catalog: LocalASRCatalogDocument) throws {
|
||||
var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
|
||||
manifest.selectedModelId = modelId
|
||||
manifest.updatedAt = Date()
|
||||
try saveManifest(manifest)
|
||||
}
|
||||
|
||||
public func installDirectory(for relativePath: String) -> URL {
|
||||
rootDirectory().appendingPathComponent(relativePath, isDirectory: true)
|
||||
}
|
||||
|
||||
public func isModelInstalled(_ model: LocalASRModelDefinition, manualMLXPath: String?) -> Bool {
|
||||
LocalASRModelInstallState.isInstalled(model, manualMLXPath: manualMLXPath, fileManager: fileManager)
|
||||
}
|
||||
|
||||
public func isRuntimeInstalled(_ runtime: LocalASRRuntimeDefinition) -> Bool {
|
||||
LocalASRModelInstallState.isRuntimeInstalled(runtime, fileManager: fileManager)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
public func resolveRuntimeBinary(runtime: LocalASRRuntimeDefinition) -> URL? {
|
||||
LocalASRModelInstallState.resolveRuntimeBinary(runtime: runtime, fileManager: fileManager)
|
||||
}
|
||||
|
||||
public func installModel(
|
||||
_ model: LocalASRModelDefinition,
|
||||
catalog: LocalASRCatalogDocument
|
||||
) async throws {
|
||||
guard model.installKind == .archive,
|
||||
let relative = model.installRelativePath,
|
||||
let baseName = model.archiveBaseName,
|
||||
let sources = model.sources,
|
||||
!sources.isEmpty else {
|
||||
throw LocalASRModelManagerError.validationFailed("Model is not downloadable.")
|
||||
}
|
||||
|
||||
progress = LocalASRModelInstallProgress(
|
||||
phase: .downloading,
|
||||
fraction: 0.05,
|
||||
message: model.displayName,
|
||||
activeItemId: model.id
|
||||
)
|
||||
if model.backend == .sherpaQwen3 || model.backend == .sherpaSenseVoice {
|
||||
try await ensureRuntimeInstalled(catalog: catalog)
|
||||
}
|
||||
let sortedSources = sources.sorted { $0.priority < $1.priority }
|
||||
var lastError: Error?
|
||||
for source in sortedSources {
|
||||
do {
|
||||
try await installArchive(
|
||||
from: source.url,
|
||||
installRelativePath: relative,
|
||||
archiveBaseName: baseName,
|
||||
layoutModel: model,
|
||||
itemId: model.id,
|
||||
displayName: model.displayName
|
||||
)
|
||||
var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
|
||||
if !manifest.installedModelIDs.contains(model.id) {
|
||||
manifest.installedModelIDs.append(model.id)
|
||||
}
|
||||
manifest.updatedAt = Date()
|
||||
try saveManifest(manifest)
|
||||
progress = LocalASRModelInstallProgress(
|
||||
phase: .completed,
|
||||
fraction: 1,
|
||||
message: model.displayName,
|
||||
activeItemId: model.id
|
||||
)
|
||||
return
|
||||
} catch {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
progress = LocalASRModelInstallProgress(
|
||||
phase: .failed,
|
||||
fraction: 0,
|
||||
message: lastError?.localizedDescription ?? "Download failed"
|
||||
)
|
||||
throw lastError ?? LocalASRModelManagerError.downloadFailed("All mirrors failed")
|
||||
}
|
||||
|
||||
public func installRuntime(
|
||||
_ runtime: LocalASRRuntimeDefinition,
|
||||
catalog: LocalASRCatalogDocument
|
||||
) async throws {
|
||||
guard let source = runtime.sources.sorted(by: { $0.priority < $1.priority }).first else {
|
||||
throw LocalASRModelManagerError.downloadFailed("No runtime source configured.")
|
||||
}
|
||||
progress = LocalASRModelInstallProgress(
|
||||
phase: .downloading,
|
||||
fraction: 0.05,
|
||||
message: runtime.displayName,
|
||||
activeItemId: runtime.id
|
||||
)
|
||||
try await installArchive(
|
||||
from: source.url,
|
||||
installRelativePath: runtime.installRelativePath,
|
||||
archiveBaseName: runtime.installRelativePath.split(separator: "/").last.map(String.init) ?? runtime.id,
|
||||
layoutModel: nil,
|
||||
expectedBinaryCandidates: runtime.binaryCandidates,
|
||||
itemId: runtime.id,
|
||||
displayName: runtime.displayName
|
||||
)
|
||||
guard isRuntimeInstalled(runtime) else {
|
||||
throw LocalASRModelManagerError.binaryMissing
|
||||
}
|
||||
var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
|
||||
if !manifest.installedRuntimeIDs.contains(runtime.id) {
|
||||
manifest.installedRuntimeIDs.append(runtime.id)
|
||||
}
|
||||
manifest.updatedAt = Date()
|
||||
try saveManifest(manifest)
|
||||
progress = LocalASRModelInstallProgress(phase: .completed, fraction: 1, message: runtime.displayName)
|
||||
}
|
||||
|
||||
public func modelRootURL(_ model: LocalASRModelDefinition) -> URL? {
|
||||
LocalASRModelInstallState.modelRootURL(model, fileManager: fileManager)
|
||||
}
|
||||
|
||||
public func installDirectoryURL(for model: LocalASRModelDefinition) -> URL? {
|
||||
guard let relative = model.installRelativePath else { return nil }
|
||||
return installDirectory(for: relative)
|
||||
}
|
||||
|
||||
public func deleteModel(
|
||||
_ model: LocalASRModelDefinition,
|
||||
catalog: LocalASRCatalogDocument
|
||||
) throws {
|
||||
guard model.installKind == .archive, let relative = model.installRelativePath else { return }
|
||||
let dir = installDirectory(for: relative)
|
||||
if fileManager.fileExists(atPath: dir.path) {
|
||||
try fileManager.removeItem(at: dir)
|
||||
}
|
||||
var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
|
||||
manifest.installedModelIDs.removeAll { $0 == model.id }
|
||||
if manifest.selectedModelId == model.id {
|
||||
manifest.selectedModelId = catalog.defaultModelId
|
||||
UserDefaults.standard.set(catalog.defaultModelId, forKey: LocalASRPreferenceKeys.selectedModelId)
|
||||
}
|
||||
manifest.updatedAt = Date()
|
||||
try saveManifest(manifest)
|
||||
if progress.activeItemId == model.id {
|
||||
progress = .idle
|
||||
}
|
||||
}
|
||||
|
||||
public func deleteRuntime(
|
||||
_ runtime: LocalASRRuntimeDefinition,
|
||||
catalog: LocalASRCatalogDocument
|
||||
) throws {
|
||||
let dir = installDirectory(for: runtime.installRelativePath)
|
||||
if fileManager.fileExists(atPath: dir.path) {
|
||||
try fileManager.removeItem(at: dir)
|
||||
}
|
||||
var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
|
||||
manifest.installedRuntimeIDs.removeAll { $0 == runtime.id }
|
||||
manifest.updatedAt = Date()
|
||||
try saveManifest(manifest)
|
||||
}
|
||||
|
||||
private func setProgress(_ update: LocalASRModelInstallProgress) {
|
||||
progress = update
|
||||
}
|
||||
|
||||
func updateDownloadProgress(
|
||||
itemId: String,
|
||||
displayName: String,
|
||||
update: LocalASRDownloadProgressUpdate
|
||||
) {
|
||||
reportDownloadProgress(itemId: itemId, displayName: displayName, update: update)
|
||||
}
|
||||
|
||||
private func reportDownloadProgress(
|
||||
itemId: String,
|
||||
displayName: String,
|
||||
update: LocalASRDownloadProgressUpdate
|
||||
) {
|
||||
// Download phase occupies 10%–55% of the overall install bar.
|
||||
let mapped = 0.10 + update.fraction * 0.45
|
||||
progress = LocalASRModelInstallProgress(
|
||||
phase: .downloading,
|
||||
fraction: mapped,
|
||||
message: displayName,
|
||||
bytesReceived: update.bytesReceived,
|
||||
bytesTotal: update.bytesTotal,
|
||||
activeItemId: itemId
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
#if os(macOS)
|
||||
private func installArchive(
|
||||
from urlString: String,
|
||||
installRelativePath: String,
|
||||
archiveBaseName: String,
|
||||
layoutModel: LocalASRModelDefinition?,
|
||||
expectedBinaryCandidates: [String]? = nil,
|
||||
itemId: String,
|
||||
displayName: String
|
||||
) async throws {
|
||||
guard let remoteURL = URL(string: urlString) else {
|
||||
throw LocalASRModelManagerError.downloadFailed("Invalid URL")
|
||||
}
|
||||
|
||||
let stagingRoot = rootDirectory().appendingPathComponent("staging/\(UUID().uuidString)", isDirectory: true)
|
||||
let destinationParent = installDirectory(for: installRelativePath)
|
||||
try fileManager.createDirectory(at: stagingRoot, withIntermediateDirectories: true)
|
||||
defer { try? fileManager.removeItem(at: stagingRoot) }
|
||||
|
||||
let archiveURL = stagingRoot.appendingPathComponent(remoteURL.lastPathComponent)
|
||||
progress = LocalASRModelInstallProgress(
|
||||
phase: .downloading,
|
||||
fraction: 0.10,
|
||||
message: displayName,
|
||||
activeItemId: itemId
|
||||
)
|
||||
|
||||
do {
|
||||
let controller = LocalASRModelDownloadClient.makeController(destinationURL: archiveURL) { update in
|
||||
Task {
|
||||
await LocalASRModelManager.shared.updateDownloadProgress(
|
||||
itemId: itemId,
|
||||
displayName: displayName,
|
||||
update: update
|
||||
)
|
||||
}
|
||||
}
|
||||
activeDownloadController = controller
|
||||
try await controller.download(from: remoteURL)
|
||||
activeDownloadController = nil
|
||||
pausedResumeData = nil
|
||||
} catch {
|
||||
activeDownloadController = nil
|
||||
pausedResumeData = nil
|
||||
throw LocalASRModelManagerError.downloadFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
progress = LocalASRModelInstallProgress(
|
||||
phase: .extracting,
|
||||
fraction: 0.58,
|
||||
message: displayName,
|
||||
activeItemId: itemId
|
||||
)
|
||||
try fileManager.createDirectory(at: destinationParent, withIntermediateDirectories: true)
|
||||
let extractOK = try await extractTarBz2(archiveURL: archiveURL, destination: destinationParent)
|
||||
guard extractOK else {
|
||||
throw LocalASRModelManagerError.extractFailed("tar extraction failed")
|
||||
}
|
||||
|
||||
progress = LocalASRModelInstallProgress(
|
||||
phase: .validating,
|
||||
fraction: 0.82,
|
||||
message: displayName,
|
||||
activeItemId: itemId
|
||||
)
|
||||
if let layoutModel {
|
||||
guard LocalASRModelInstallState.isInstalled(
|
||||
layoutModel,
|
||||
manualMLXPath: nil,
|
||||
fileManager: fileManager
|
||||
) else {
|
||||
throw LocalASRModelManagerError.validationFailed("Required model files missing after extract.")
|
||||
}
|
||||
}
|
||||
if let expectedBinaryCandidates {
|
||||
let runtimeRoot = destinationParent
|
||||
let found = expectedBinaryCandidates.contains { candidate in
|
||||
let direct = runtimeRoot.appendingPathComponent(candidate)
|
||||
if fileManager.isExecutableFile(atPath: direct.path) { return true }
|
||||
let name = (candidate as NSString).lastPathComponent
|
||||
return findExecutable(named: name, under: runtimeRoot) != nil
|
||||
}
|
||||
guard found else {
|
||||
throw LocalASRModelManagerError.binaryMissing
|
||||
}
|
||||
}
|
||||
|
||||
progress = LocalASRModelInstallProgress(
|
||||
phase: .finalizing,
|
||||
fraction: 0.95,
|
||||
message: displayName,
|
||||
activeItemId: itemId
|
||||
)
|
||||
try? fileManager.removeItem(at: archiveURL)
|
||||
}
|
||||
|
||||
public func ensureRuntimeInstalled(catalog: LocalASRCatalogDocument) async throws {
|
||||
guard let runtime = LocalASRModelCatalog.runtime(
|
||||
for: LocalASRModelCatalog.currentRuntimePlatform(),
|
||||
in: catalog
|
||||
) else {
|
||||
throw LocalASRModelManagerError.runtimeMissing
|
||||
}
|
||||
if isRuntimeInstalled(runtime) { return }
|
||||
try await installRuntime(runtime, catalog: catalog)
|
||||
}
|
||||
|
||||
private func findExecutable(named name: String, under root: URL) -> URL? {
|
||||
guard let enumerator = fileManager.enumerator(
|
||||
at: root,
|
||||
includingPropertiesForKeys: [.isExecutableKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else { return nil }
|
||||
for case let url as URL in enumerator {
|
||||
guard url.lastPathComponent == name else { continue }
|
||||
if fileManager.isExecutableFile(atPath: url.path) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func extractTarBz2(archiveURL: URL, destination: URL) async throws -> Bool {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/tar")
|
||||
process.arguments = ["-xjf", archiveURL.path, "-C", destination.path]
|
||||
process.standardOutput = FileHandle.nullDevice
|
||||
process.standardError = FileHandle.nullDevice
|
||||
process.terminationHandler = { proc in
|
||||
continuation.resume(returning: proc.terminationStatus == 0)
|
||||
}
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
continuation.resume(throwing: LocalASRModelManagerError.extractFailed(error.localizedDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// LocalASRPreferenceKeys.swift
|
||||
// OSGKeyboard · Shared
|
||||
|
||||
import Foundation
|
||||
|
||||
enum LocalASRPreferenceKeys {
|
||||
static let selectedModelId = "mac.localASR.selectedModelId"
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// LocalASRTranscriptCorrector.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Deterministic alias → canonical term replacement between raw ASR output
|
||||
// and the LLM polish step. Only applies whole-phrase matches.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum LocalASRTranscriptCorrector {
|
||||
|
||||
/// Applies high-confidence alias replacements (longest match first).
|
||||
public static func apply(
|
||||
_ text: String,
|
||||
pairs: [LocalASRCorrectionPair]
|
||||
) -> String {
|
||||
guard !text.isEmpty, !pairs.isEmpty else { return text }
|
||||
|
||||
let sorted = pairs.sorted { lhs, rhs in
|
||||
if lhs.alias.count != rhs.alias.count {
|
||||
return lhs.alias.count > rhs.alias.count
|
||||
}
|
||||
return lhs.alias.localizedCaseInsensitiveCompare(rhs.alias) == .orderedAscending
|
||||
}
|
||||
|
||||
var result = text
|
||||
for pair in sorted {
|
||||
result = replaceWholeMatches(
|
||||
in: result,
|
||||
alias: pair.alias,
|
||||
term: pair.term
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static func replaceWholeMatches(
|
||||
in text: String,
|
||||
alias: String,
|
||||
term: String
|
||||
) -> String {
|
||||
guard !alias.isEmpty, alias != term else { return text }
|
||||
|
||||
if alias.unicodeScalars.allSatisfy({ $0.isASCII }) {
|
||||
return replaceASCIIWord(in: text, alias: alias, term: term)
|
||||
}
|
||||
return text.replacingOccurrences(of: alias, with: term)
|
||||
}
|
||||
|
||||
private static func replaceASCIIWord(
|
||||
in text: String,
|
||||
alias: String,
|
||||
term: String
|
||||
) -> String {
|
||||
let escaped = NSRegularExpression.escapedPattern(for: alias)
|
||||
let pattern = "(?i)(?<![A-Za-z0-9_])\(escaped)(?![A-Za-z0-9_])"
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else {
|
||||
return text
|
||||
}
|
||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
return regex.stringByReplacingMatches(
|
||||
in: text,
|
||||
range: range,
|
||||
withTemplate: term
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -252,7 +252,10 @@ public actor PolishingService {
|
||||
providerId: String
|
||||
) -> String {
|
||||
let dictionary = store.personalDictionary
|
||||
let dictionaryBlock = dictionary.promptFragment()
|
||||
let dictionaryBlock = Self.mergedDictionaryBlock(
|
||||
dictionary: dictionary,
|
||||
supplement: context.dictionarySupplement
|
||||
)
|
||||
let contextGuideline = context.appContext.polishGuideline
|
||||
let intensityGuideline = context.intensity.promptGuideline
|
||||
let contract = Self.globalOutputContract(useChinese: shouldUseChineseGuidance(providerId: providerId))
|
||||
@@ -321,6 +324,17 @@ public actor PolishingService {
|
||||
}
|
||||
}
|
||||
|
||||
internal static func mergedDictionaryBlock(
|
||||
dictionary: PersonalDictionary,
|
||||
supplement: String?
|
||||
) -> String {
|
||||
let base = dictionary.promptFragment()
|
||||
let extra = supplement?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if base.isEmpty { return extra }
|
||||
if extra.isEmpty { return base }
|
||||
return base + "\n" + extra
|
||||
}
|
||||
|
||||
private func shouldUseChineseGuidance(providerId: String) -> Bool {
|
||||
switch providerId {
|
||||
case "zhipu", "moonshot", "qwen", "deepseek":
|
||||
|
||||
Reference in New Issue
Block a user