feat: macOS architecture, cloud ASR/LLM providers, and 6-step iOS onboarding
- Add macOS menu-bar dictation app with local ASR models (SenseVoice/Qwen3),
global Option hotkey, and bottom overlay
- Add cloud ASR/LLM providers (Anthropic, Volcengine, Bailian, and more) with
provider logos, model listing, and connection checks
- Add shared 7-day usage stats UI (UsageStatsCluster / SevenDayUsageChart)
- Add iOS onboarding step 6 for polish LLM setup; hide custom-language-model
diagnostic toggle behind DEBUG
- Unify iOS onboarding tagline with the macOS brand line ("开口即文字。")
- Rewrite README (Chinese-first, product-oriented) and refresh GitHub Pages
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
// ProviderToolRunnerState.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Pure state machine for Settings provider tool rows (validate / fetch models).
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct ProviderToolRunnerState: Equatable, Sendable {
|
||||
public var isRunning: Bool
|
||||
public var message: String?
|
||||
public var failed: Bool
|
||||
public var models: [String]
|
||||
|
||||
public init(
|
||||
isRunning: Bool = false,
|
||||
message: String? = nil,
|
||||
failed: Bool = false,
|
||||
models: [String] = []
|
||||
) {
|
||||
self.isRunning = isRunning
|
||||
self.message = message
|
||||
self.failed = failed
|
||||
self.models = models
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public enum ProviderToolRunner {
|
||||
public static func runValidate(
|
||||
runningMessage: String,
|
||||
successMessage: String,
|
||||
validate: () async throws -> Void
|
||||
) async -> ProviderToolRunnerState {
|
||||
var state = ProviderToolRunnerState(isRunning: true, message: runningMessage, failed: false)
|
||||
do {
|
||||
try await validate()
|
||||
state.isRunning = false
|
||||
state.message = successMessage
|
||||
state.failed = false
|
||||
} catch {
|
||||
state.isRunning = false
|
||||
state.failed = true
|
||||
state.message = (error as? LocalizedError)?.errorDescription ?? "\(error)"
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
public static func runFetchModels(
|
||||
runningMessage: String,
|
||||
loadedMessage: (Int) -> String,
|
||||
emptyMessage: String,
|
||||
currentModel: String,
|
||||
fetchModels: () async throws -> [String]
|
||||
) async -> (state: ProviderToolRunnerState, selectedModel: String?) {
|
||||
var state = ProviderToolRunnerState(isRunning: true, message: runningMessage, failed: false)
|
||||
do {
|
||||
let fetched = try await fetchModels()
|
||||
guard !fetched.isEmpty else {
|
||||
state.isRunning = false
|
||||
state.failed = true
|
||||
state.message = emptyMessage
|
||||
state.models = []
|
||||
return (state, nil)
|
||||
}
|
||||
|
||||
var resolved = fetched
|
||||
let trimmed = currentModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty, !resolved.contains(trimmed) {
|
||||
resolved.insert(trimmed, at: 0)
|
||||
}
|
||||
state.models = resolved
|
||||
state.isRunning = false
|
||||
state.failed = false
|
||||
state.message = loadedMessage(resolved.count)
|
||||
|
||||
let selected: String?
|
||||
if trimmed.isEmpty, let first = resolved.first {
|
||||
selected = first
|
||||
} else {
|
||||
selected = nil
|
||||
}
|
||||
return (state, selected)
|
||||
} catch {
|
||||
state.isRunning = false
|
||||
state.failed = true
|
||||
state.message = (error as? LocalizedError)?.errorDescription ?? "\(error)"
|
||||
state.models = []
|
||||
return (state, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum HardTimeout {
|
||||
/// Returns the first completed result; the losing task is cancelled.
|
||||
public static func run<T: Sendable>(
|
||||
seconds: TimeInterval,
|
||||
operation: @escaping @Sendable () async throws -> T
|
||||
) async throws -> T {
|
||||
try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask { try await operation() }
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
throw CancellationError()
|
||||
}
|
||||
guard let result = try await group.next() else {
|
||||
throw CancellationError()
|
||||
}
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-throwing variant for tasks that should fall back when time elapses.
|
||||
public static func value<T: Sendable>(
|
||||
seconds: TimeInterval,
|
||||
operation: @escaping @Sendable () async -> T,
|
||||
onTimeout: @escaping @Sendable () -> T
|
||||
) async -> T {
|
||||
await withTaskGroup(of: T.self) { group in
|
||||
group.addTask { await operation() }
|
||||
group.addTask {
|
||||
try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
return onTimeout()
|
||||
}
|
||||
let result = await group.next() ?? onTimeout()
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user