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:
Rocky
2026-07-11 19:10:20 +08:00
parent cdf833935a
commit cc8dd1070a
116 changed files with 6659 additions and 2634 deletions
+52 -64
View File
@@ -22,23 +22,32 @@ struct DashboardView: View {
}
var body: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: Spacing.lg) {
heroHeader
statCluster
dictationStage
// Scrollable like the other pages so the shared status footer always
// stays pinned to the window's bottom edge. `minHeight: viewport` keeps
// the balanced Spacer layout when the window is tall (no scrollbar) and
// lets the content scroll only when the window is too short to fit it.
GeometryReader { proxy in
ScrollView {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: Spacing.lg) {
heroHeader
statCluster
dictationStage
}
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.top, Spacing.sm)
// Leftover window height splits evenly above / below the mic
// bar so spacing stays balanced at any window size.
Spacer(minLength: Spacing.xs)
BottomDictationBar(viewModel: viewModel)
.padding(.horizontal, MacMetrics.pageHorizontalInset)
Spacer(minLength: Spacing.xs)
}
.frame(maxWidth: .infinity, minHeight: proxy.size.height)
}
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.top, Spacing.sm)
// Leftover window height splits evenly above / below the mic bar
// so spacing stays balanced at any window size.
Spacer(minLength: Spacing.xs)
BottomDictationBar(viewModel: viewModel)
.padding(.horizontal, MacMetrics.pageHorizontalInset)
Spacer(minLength: Spacing.xs)
}
.onAppear { stats.reloadFromDisk() }
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
@@ -76,50 +85,18 @@ struct DashboardView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - Stats (hero word count, full width but content-height
// never stretched to match a taller sibling and left with dead air)
// MARK: - Stats (7-day chart + cumulative grid shared cluster)
private var statCluster: some View {
VStack(spacing: Spacing.md) {
StatCard(
title: MacL10n.string("mac.stat.words", language: lang),
value: UsageStatisticsStore.formatCount(
stats.dictationCharacterCount,
language: lang
),
caption: MacL10n.string("mac.stat.transcribed", language: lang),
systemImage: "text.alignleft",
accent: true,
prominent: true
)
HStack(spacing: Spacing.md) {
StatCard(
title: MacL10n.string("mac.stat.dictationTime", language: lang),
value: UsageStatisticsStore.formatDuration(
stats.dictationDurationSeconds,
language: lang
),
caption: MacL10n.string("mac.stat.cumulativeDuration", language: lang),
systemImage: "waveform"
)
StatCard(
title: MacL10n.string("mac.stat.translation", language: lang),
value: UsageStatisticsStore.formatCount(
stats.translationCharacterCount,
language: lang
),
caption: MacL10n.string("mac.stat.cumulativeTranslation", language: lang),
systemImage: "character.bubble"
)
StatCard(
title: MacL10n.string("mac.stat.dictionary", language: lang),
value: "\(viewModel.dictionaryTermCount)",
caption: MacL10n.string("mac.stat.customTerms", language: lang),
systemImage: "character.book.closed"
)
}
}
UsageStatsCluster(
layout: .split,
language: lang,
points: stats.last7Days,
dictationCharacterCount: stats.dictationCharacterCount,
dictationDurationSeconds: stats.dictationDurationSeconds,
translationCharacterCount: stats.translationCharacterCount,
dictionaryTermCount: viewModel.dictionaryTermCount
)
}
// MARK: - Dictation stage
@@ -220,18 +197,29 @@ struct BottomDictationBar: View {
}
}
} label: {
HStack(spacing: 6) {
HStack(spacing: Spacing.sm) {
Image(systemName: "translate")
.foregroundStyle(palette.textSecondary)
Text(currentTranslationLabel)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: Spacing.xs)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9, weight: .semibold))
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.font(MacSettingsType.control)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 7)
.background(palette.surfaceElevated, in: Capsule())
.frame(minHeight: MacMetrics.settingsControlHeight)
.background(
palette.surfaceElevated,
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
.menuStyle(.borderlessButton)
.fixedSize()
+428 -99
View File
@@ -14,8 +14,33 @@ import SwiftUI
/// Fixed metrics that keep every desktop surface on the same grid.
enum MacMetrics {
/// Uniform max width for trailing text controls (API key / model field).
static let controlWidth: CGFloat = 240
/// Shared height for credential inputs and icon buttons matches the iOS
/// settings controls (38).
static let settingsControlHeight: CGFloat = 38
/// Fixed label column for provider rows: wider than iOS (150) so the label
/// text takes a larger share of the row and the trailing control narrows.
static let settingLabelWidth: CGFloat = 200
/// Uniform minimum height for every settings row, so rows read as an even
/// list regardless of whether they hold a 38pt control or a single-line
/// label. Content is centered within it; taller rows (status text, progress)
/// grow past it.
static let settingsRowMinHeight: CGFloat = 40
/// The single vertical rhythm for settings cards: the gap *between* rows AND
/// the padding between a card's top/bottom edge and its first/last row are
/// both this value, so the card breathes evenly. Applied as `VStack(spacing:)`
/// between rows and `.padding(.vertical:)` on the card body.
static let settingsRowGap: CGFloat = Spacing.md
/// Provider menu trigger width (legacy).
static let selectWidth: CGFloat = 200
/// Text-field max width for provider credential rows (narrower than the
/// old 360pt so long keys truncate instead of wrapping when the window shrinks).
static let fieldWidth: CGFloat = 280
/// Legacy alias used by older call sites; prefer `fieldWidth`.
static let controlWidth: CGFloat = fieldWidth
/// Horizontal inset inside settings cards matches History / Dictionary rows.
static let settingsCardInset: CGFloat = Spacing.md
/// Below this row width, provider rows stack label above control.
static let settingsCompactBreakpoint: CGFloat = 520
/// Sidebar width and the horizontal inset shared by brand, nav and footer.
static let sidebarWidth: CGFloat = 240
/// Horizontal inset for sidebar chrome (nav rows, footer). The brand logo
@@ -46,6 +71,55 @@ enum MacMetrics {
static let trafficLightInset: CGFloat = 28
}
// MARK: - Settings typography (mirrors the iOS settings rows)
/// Type scale that maps 1:1 to the iOS settings controls so the desktop and
/// phone read identically: row label / picker value at body (15), hints at
/// caption2 (11), section header at caption2 (uppercased at the call site).
enum MacSettingsType {
static let sectionTitle = TypeStyle.caption2
static let rowLabel = TypeStyle.body
static let control = TypeStyle.body
static let controlEmph = TypeStyle.bodyEmph
static let hint = TypeStyle.caption2
static let button = TypeStyle.body
}
// MARK: - iOS-style toggle
/// Pill switch mirroring the iOS settings toggle: accent track when on, neutral
/// when off, 16pt white knob, spring slide. Colours resolve through the active
/// OSG palette so it follows the app theme.
struct MacToggleStyle: ToggleStyle {
@Environment(\.themePalette) private var palette
@Environment(\.colorScheme) private var colorScheme
func makeBody(configuration: Configuration) -> some View {
Button {
configuration.isOn.toggle()
} label: {
ZStack(alignment: configuration.isOn ? .trailing : .leading) {
Capsule()
.fill(configuration.isOn ? palette.accent : offTrackColor)
Circle()
.fill(Color.white)
.frame(width: 16, height: 16)
.shadow(color: .black.opacity(0.25), radius: 1, y: 1)
.padding(2)
}
.frame(width: 36, height: 20)
.animation(Motion.quick, value: configuration.isOn)
}
.buttonStyle(.plain)
}
private var offTrackColor: Color {
colorScheme == .dark
? Color.white.opacity(0.18)
: Color.black.opacity(0.15)
}
}
// MARK: - Liquid Glass
private struct MacGlassSurface<S: Shape>: ViewModifier {
@@ -75,26 +149,296 @@ extension View {
// MARK: - Text field styling
/// Text-field chrome aligned with native macOS Form controls: compact
/// height, text-background fill, hairline border.
/// Theme-aware credential field chrome comfortable tap target,
/// monospaced-friendly body size, and a fill/border that stays clearly
/// distinct from the section card in both appearances.
private struct MacFieldStyleModifier: ViewModifier {
@Environment(\.themePalette) private var palette
@Environment(\.colorScheme) private var colorScheme
var monospaced: Bool = false
func body(content: Content) -> some View {
let shape = RoundedRectangle(cornerRadius: 6, style: .continuous)
// Mirrors the iOS credential field: recessed surface-elevated fill,
// medium radius, hairline border, comfortable 38pt tap target.
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
content
.textFieldStyle(.plain)
.font(TypeStyle.footnote)
.padding(.horizontal, 8)
.frame(height: 22)
.background(Color(nsColor: .textBackgroundColor), in: shape)
.font(monospaced ? TypeStyle.mono : TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.sm)
.frame(minHeight: MacMetrics.settingsControlHeight)
.background(fieldFill, in: shape)
.overlay(shape.stroke(palette.divider, lineWidth: 0.5))
}
/// Light mode: the section card reads near-white, so a recessed `surfaceElevated`
/// grey barely differs. Use the system text-input background (true white) so the
/// field reads as an editable well. Dark mode keeps the elevated grey, which sits
/// *lighter* than the card and already stands out.
private var fieldFill: Color {
#if os(macOS)
if colorScheme != .dark {
return Color(nsColor: .textBackgroundColor)
}
#endif
return palette.surfaceElevated
}
}
extension View {
/// Theme-aware text-field styling for the settings inputs.
func macFieldStyle() -> some View { modifier(MacFieldStyleModifier()) }
func macFieldStyle(monospaced: Bool = false) -> some View {
modifier(MacFieldStyleModifier(monospaced: monospaced))
}
}
// MARK: - Provider settings chrome
/// Section header + `MacCard` shell same outer alignment as History /
/// Dictionary (title and card share `pageHorizontalInset` from the parent).
struct MacSettingsSection<Content: View>: View {
@Environment(\.themePalette) private var palette
let title: String
var footer: String? = nil
@ViewBuilder var content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(title)
.font(MacSettingsType.sectionTitle)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
MacCard(padding: 0) {
VStack(alignment: .leading, spacing: 0) {
content()
}
// Card top/bottom breathe on the same rhythm as the gaps between
// rows (rows own their own inter-row spacing via their container).
.padding(.vertical, MacMetrics.settingsRowGap)
.frame(maxWidth: .infinity, alignment: .leading)
}
if let footer, !footer.isEmpty {
Text(footer)
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
/// iOS-style inline row: title (+ optional subtitle) on the left, control
/// right-aligned. Used by simple picker / toggle rows.
struct MacInlineRow<Control: View>: View {
@Environment(\.themePalette) private var palette
let title: String
var subtitle: String? = nil
@ViewBuilder var control: () -> Control
var body: some View {
HStack(alignment: .center, spacing: Spacing.md) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(title)
.font(MacSettingsType.rowLabel)
.foregroundStyle(palette.textPrimary)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(MacSettingsType.hint)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
Spacer(minLength: Spacing.sm)
control()
}
.padding(.horizontal, MacMetrics.settingsCardInset)
.frame(minHeight: MacMetrics.settingsRowMinHeight)
}
}
/// Backwards-compatible alias: title + optional subtitle + trailing control.
struct MacFormSubtitleRow<Control: View>: View {
let title: String
var subtitle: String? = nil
@ViewBuilder var control: () -> Control
var body: some View {
MacInlineRow(title: title, subtitle: subtitle) {
control()
}
}
}
/// Tappable navigation / action row inside a settings card.
struct MacFormLinkRow: View {
@Environment(\.themePalette) private var palette
let title: String
var showsChevron: Bool = true
var body: some View {
HStack(spacing: Spacing.sm) {
Text(title)
.font(MacSettingsType.rowLabel)
.foregroundStyle(palette.textPrimary)
Spacer(minLength: 0)
if showsChevron {
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
}
.padding(.horizontal, MacMetrics.settingsCardInset)
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.contentShape(Rectangle())
}
}
/// Single merged card body for LLM / ASR provider configuration sections.
struct MacSettingsProviderCard<Content: View>: View {
@ViewBuilder var content: () -> Content
var body: some View {
VStack(spacing: MacMetrics.settingsRowGap) {
content()
}
}
}
// MARK: - Responsive provider setting row
/// Label + control row for provider cards. Uses a two-column layout at
/// comfortable widths and stacks vertically when horizontal space is tight.
struct MacProviderSettingRow<Content: View>: View {
@Environment(\.themePalette) private var palette
let title: String
var subtitle: String? = nil
/// Retained for source compatibility; the control now fills its column.
var controlMaxWidth: CGFloat? = nil
/// Cross-axis alignment between the label column and the control. Provider
/// rows keep `.top` (model row grows a status line below its field); single
/// control rows can pass `.center` to vertically center label and control.
var verticalAlignment: VerticalAlignment = .top
@ViewBuilder var content: () -> Content
var body: some View {
HStack(alignment: verticalAlignment, spacing: Spacing.md) {
label
.frame(width: MacMetrics.settingLabelWidth, alignment: .leading)
content()
.frame(maxWidth: .infinity, alignment: .leading)
}
// Uniform row height (content centered); inter-row spacing is owned by the
// enclosing card, so the row adds no vertical padding of its own.
.frame(maxWidth: .infinity, minHeight: MacMetrics.settingsRowMinHeight, alignment: .leading)
.padding(.horizontal, MacMetrics.settingsCardInset)
}
private var label: some View {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(title)
.font(MacSettingsType.rowLabel)
.foregroundStyle(palette.textPrimary)
.fixedSize(horizontal: false, vertical: true)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(MacSettingsType.hint)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
// Align the title with the first 38pt control row, not with any
// status/help text that appears below the control.
.frame(minHeight: MacMetrics.settingsControlHeight, alignment: .leading)
}
}
/// Square icon button matching credential field height.
struct MacSettingsIconButton: View {
@Environment(\.themePalette) private var palette
let systemName: String
var help: String? = nil
var disabled: Bool = false
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(disabled ? palette.textTertiary : palette.textSecondary)
.frame(width: MacMetrics.settingsControlHeight, height: MacMetrics.settingsControlHeight)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
.buttonStyle(.plain)
.disabled(disabled)
.help(help ?? "")
}
}
/// Compact tool button for validate / fetch-models actions.
struct MacSettingsToolButton: View {
@Environment(\.themePalette) private var palette
let title: String
/// Background fill. Defaults to the neutral elevated surface.
var fill: Color? = nil
/// Text color. Defaults to primary text (tertiary when disabled).
var foreground: Color? = nil
/// Hairline divider border drawn for the neutral variant, hidden for
/// colored fills (delete / download) so the fill reads as the button.
var showsBorder: Bool = true
var disabled: Bool = false
let action: () -> Void
var body: some View {
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
Button(action: action) {
Text(title)
.font(MacSettingsType.button)
.foregroundStyle(resolvedForeground)
.padding(.horizontal, Spacing.md)
.frame(minHeight: 34)
.background(fill ?? palette.surfaceElevated, in: shape)
.overlay {
if showsBorder {
shape.stroke(palette.divider, lineWidth: 0.5)
}
}
}
.buttonStyle(.plain)
.disabled(disabled)
}
private var resolvedForeground: Color {
if disabled { return palette.textTertiary }
return foreground ?? palette.textPrimary
}
}
// MARK: - Legacy setting row
/// Fixed label column + left-aligned control. Prefer `MacProviderSettingRow`
/// for provider configuration cards.
struct MacSettingRow<Content: View>: View {
let title: String
var controlMaxWidth: CGFloat? = nil
@ViewBuilder var content: () -> Content
var body: some View {
MacProviderSettingRow(title: title, controlMaxWidth: controlMaxWidth) {
content()
}
}
}
// MARK: - Page header
@@ -169,95 +513,6 @@ struct MacCard<Content: View>: View {
}
}
// MARK: - Stat tile
struct StatCard: View {
@Environment(\.themePalette) private var palette
let title: String
let value: String
let caption: String
var systemImage: String?
var accent: Bool = false
/// Hero metric: wide horizontal layout that uses full-card width without
/// stretching to fill dead vertical space used for the primary word count.
var prominent: Bool = false
var body: some View {
MacCard(padding: prominent ? Spacing.md : Spacing.md) {
if prominent {
prominentBody
} else {
compactBody
}
}
}
private var compactBody: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
HStack {
Text(title.uppercased())
.font(TypeStyle.caption2)
.tracking(0.6)
.foregroundStyle(palette.textTertiary)
Spacer()
if let systemImage {
Image(systemName: systemImage)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(accent ? palette.accent : palette.textTertiary)
.symbolRenderingMode(.hierarchical)
}
}
Text(value)
.font(TypeStyle.title2)
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.7)
.contentTransition(.numericText())
.animation(Motion.soft, value: value)
Text(caption)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
/// Wide "hero bar" layout: icon badge + title/caption on the left, the
/// big number anchored right fills the full card width edge-to-edge
/// instead of a tall card with empty space below a small number.
private var prominentBody: some View {
HStack(spacing: Spacing.md) {
if let systemImage {
ZStack {
Circle()
.fill(palette.accentMuted)
.frame(width: 44, height: 44)
Image(systemName: systemImage)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(palette.accent)
.symbolRenderingMode(.hierarchical)
}
}
VStack(alignment: .leading, spacing: 2) {
Text(title.uppercased())
.font(TypeStyle.caption2)
.tracking(0.6)
.foregroundStyle(palette.textTertiary)
Text(caption)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
Spacer(minLength: Spacing.md)
Text(value)
.font(.system(size: 34, weight: .bold))
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.6)
.contentTransition(.numericText())
.animation(Motion.soft, value: value)
}
}
}
// MARK: - Live waveform
/// Compact bar visualiser reacting to the input level while recording.
@@ -330,6 +585,30 @@ struct MacStatusFooter: View {
var body: some View {
HStack(spacing: Spacing.sm) {
Spacer()
// Current models, shown just before the engine mode. Label text is
// the model name only; the full "provider · model" lives in the
// hover tooltip to keep the strip quiet.
Label(asrModel.name, systemImage: "waveform")
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: 150, alignment: .trailing)
.help(asrModel.tooltip)
.contentTransition(.opacity)
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.5))
Label(llmModel.name, systemImage: "sparkles")
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: 150, alignment: .trailing)
.help(llmModel.tooltip)
.contentTransition(.opacity)
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.5))
Label(
viewModel.isCloudMode
? MacL10n.string("mac.mode.cloud", language: lang)
@@ -360,6 +639,56 @@ struct MacStatusFooter: View {
.padding(.vertical, Spacing.sm)
.animation(Motion.quick, value: viewModel.isCloudMode)
.animation(Motion.quick, value: viewModel.config.translationTargetLocaleId)
.animation(Motion.quick, value: asrModel.name)
.animation(Motion.quick, value: llmModel.name)
}
// MARK: - Current model resolution
/// ASR: cloud shows the configured provider's model (or its default);
/// local shows the selected on-device model's display name.
private var asrModel: (name: String, tooltip: String) {
let config = viewModel.config
if viewModel.isCloudMode {
let providerName = ProviderDisplayName.name(for: config.asrProviderId, language: lang)
let model = config.asrModel.isEmpty
? CloudASRModelCatalog.defaultModel(for: config.asrProviderId)
: config.asrModel
return (model, "\(providerName) · \(model)")
}
let providerName = MacL10n.string("mac.mode.local", language: lang)
let model = MacLocalASRModelName.displayName(for: MacLocalASRPreferences.selectedModelId, language: lang)
return (model, "\(providerName) · \(model)")
}
/// LLM: cloud uses the configured provider; local routes through
/// `localModeProviderId` (built-in DeepSeek unless the user supplied a key).
private var llmModel: (name: String, tooltip: String) {
let config = viewModel.config
let providerId = viewModel.isCloudMode ? config.providerId : config.localModeProviderId
let providerName = ProviderDisplayName.name(for: providerId, language: lang)
let model: String
if providerId == config.providerId, !config.model.isEmpty {
model = config.model
} else {
model = LLMProvider.provider(id: providerId).defaultModel
}
return (model, "\(providerName) · \(model)")
}
}
/// Cached lookup from a local ASR model id to its catalog display name. The
/// bundled catalog is small and immutable, so decoding it once is enough.
enum MacLocalASRModelName {
private static let catalog: LocalASRCatalogDocument? = try? LocalASRModelCatalog.loadBundled()
static func displayName(for modelId: String, language: AppUILanguage) -> String {
if !modelId.isEmpty,
let catalog,
let model = LocalASRModelCatalog.model(modelId, in: catalog) {
return model.displayName
}
return modelId.isEmpty ? MacL10n.string("mac.mode.local", language: language) : modelId
}
}
+2 -1
View File
@@ -40,7 +40,8 @@ enum MacDictationPipeline {
/// Whether the active engine can surface `onPartial` text while recording.
static func supportsLivePartials(store: AppGroupStore) -> Bool {
if store.engineMode == "local" { return true }
return CloudASRModelCatalog.strategy(for: store.asrProviderId) != .localFallback
let strategy = CloudASRModelCatalog.strategy(for: store.asrProviderId)
return strategy != .localFallback
}
/// Runs ASR then polish. Polish failures return cleaned raw ASR plus a warning.
+23 -2
View File
@@ -92,7 +92,9 @@ final class MacDictationViewModel: ObservableObject {
self.config = ProviderConfig(defaults: defaults)
self.usageStatistics = UsageStatisticsStore(defaults: defaults)
self.autoPasteEnabled = defaults.object(forKey: StoredKeys.autoPaste) as? Bool ?? true
self.hotkeyEnabled = defaults.object(forKey: StoredKeys.hotkey) as? Bool ?? true
// Global hotkey is always on the settings toggle was removed, so any
// previously-stored "off" value is intentionally ignored.
self.hotkeyEnabled = true
self.hotkeyTrigger = MacHotkeyTrigger(
rawValue: defaults.string(forKey: StoredKeys.hotkeyTrigger) ?? ""
) ?? .rightOption
@@ -284,7 +286,7 @@ final class MacDictationViewModel: ObservableObject {
do {
let result: MacDictationResult
if let liveTask {
let capture = await liveTask.value
let capture = await Self.awaitLiveCapture(liveTask)
let trimmedLive = capture.raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !capture.shouldFallbackToBatch, !trimmedLive.isEmpty {
if self.transcript.isEmpty {
@@ -362,6 +364,25 @@ final class MacDictationViewModel: ObservableObject {
isStreamingPartial = false
}
/// Hard timeout so a hung realtime ASR drain cannot leave `isProcessing` stuck.
private static func awaitLiveCapture(
_ task: Task<MacLiveASRCaptureResult, Never>
) async -> MacLiveASRCaptureResult {
await HardTimeout.value(
seconds: FlowSessionKeys.cloudASRWaitTimeout,
operation: { await task.value },
onTimeout: {
task.cancel()
return MacLiveASRCaptureResult(
raw: "",
chunkWarning: nil,
localBias: nil,
shouldFallbackToBatch: true
)
}
)
}
/// Stops an in-flight prepare, or finishes an active recording.
private func cancelOrFinishRecording() {
if isRecording {
+6 -10
View File
@@ -98,7 +98,7 @@ struct MacDictionaryView: View {
private var list: some View {
// Full-bleed ScrollView scrollbar on the detail pane's right edge.
ScrollView {
LazyVStack(alignment: .leading, spacing: Spacing.md) {
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
if sections.isEmpty {
MacCard {
Text(MacL10n.string("mac.dict.noMatch", language: lang))
@@ -120,22 +120,18 @@ struct MacDictionaryView: View {
private func categorySection(
_ section: (category: PersonalDictionary.Entry.Category, items: [PersonalDictionary.Entry])
) -> some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(MacL10n.string(section.category.labelKey, language: lang))
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.font(MacSettingsType.sectionTitle)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
MacCard(padding: 0) {
VStack(spacing: 0) {
ForEach(Array(section.items.enumerated()), id: \.element.id) { index, entry in
ForEach(section.items, id: \.id) { entry in
row(entry)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
if index < section.items.count - 1 {
// Full-bleed like macOS list rows (not iOS inset separators).
Divider()
.overlay(palette.divider)
}
}
}
}
+6 -10
View File
@@ -83,7 +83,7 @@ struct MacHistoryView: View {
// Full-bleed ScrollView scrollbar on the detail pane's right edge.
// Horizontal inset lives on the content so cards align with the title.
ScrollView {
LazyVStack(alignment: .leading, spacing: Spacing.md) {
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
ForEach(historyStore.groupedByDay, id: \.day) { group in
daySection(group)
}
@@ -94,22 +94,18 @@ struct MacHistoryView: View {
}
private func daySection(_ group: (day: Date, items: [SpeechHistoryEntry])) -> some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(Self.dayFormatter.string(from: group.day))
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.font(MacSettingsType.sectionTitle)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
MacCard(padding: 0) {
VStack(spacing: 0) {
ForEach(Array(group.items.enumerated()), id: \.element.id) { index, entry in
ForEach(group.items, id: \.id) { entry in
row(entry)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
if index < group.items.count - 1 {
// Full-bleed like macOS list rows (not iOS inset separators).
Divider()
.overlay(palette.divider)
}
}
}
}
+43 -95
View File
@@ -1,10 +1,8 @@
// MacICloudSyncRows.swift
// OSGKeyboard · Mac
//
// iCloud sync toggles for settings and personal dictionary same KVS
// keys and merge rules as the iOS settings page. Rendered as native Form
// rows (Toggle + optional action / error) so they sit inside a grouped
// `Form` section and match System Settings exactly.
// iCloud sync toggle for settings, history, API keys, and personal
// dictionary same KVS keys and merge rules as the iOS settings page.
import SwiftUI
@@ -19,38 +17,51 @@ struct MacSettingsICloudSyncRow: View {
@State private var isSyncingNow = false
var body: some View {
Toggle(isOn: toggleBinding) {
VStack(alignment: .leading, spacing: 2) {
Text(MacL10n.string("mac.sync.settingsTitle", language: language))
MacProviderSettingRow(
title: MacL10n.string("mac.sync.settingsTitle", language: language),
verticalAlignment: .center
) {
HStack(alignment: .center, spacing: Spacing.sm) {
Toggle("", isOn: toggleBinding)
.labelsHidden()
.toggleStyle(MacToggleStyle())
.disabled(isApplyingToggle)
Text(MacL10n.string("mac.sync.settingsSubtitle", language: language))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
.tint(palette.accent)
.disabled(isApplyingToggle)
.onAppear { reloadFromStore() }
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
reloadFromStore()
}
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
reloadFromStore()
}
if isEnabled {
Button {
syncNow()
} label: {
HStack(spacing: Spacing.xs) {
if isSyncingNow { ProgressView().controlSize(.small) }
Text(MacL10n.string("mac.sync.syncNow", language: language))
HStack(spacing: Spacing.xs) {
MacSettingsToolButton(
title: MacL10n.string("mac.sync.syncNow", language: language),
disabled: isSyncingNow || isApplyingToggle
) {
syncNow()
}
if isSyncingNow { ProgressView().controlSize(.small) }
Spacer(minLength: 0)
}
.disabled(isSyncingNow || isApplyingToggle)
.padding(.horizontal, MacMetrics.settingsCardInset)
.padding(.bottom, Spacing.sm)
}
if let syncErrorMessage {
Text(syncErrorMessage)
.font(TypeStyle.caption)
.font(MacSettingsType.hint)
.foregroundStyle(palette.danger)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, MacMetrics.settingsCardInset)
.padding(.bottom, Spacing.sm)
}
}
@@ -74,6 +85,19 @@ struct MacSettingsICloudSyncRow: View {
Task {
do {
try await MacICloudSyncBootstrap.settingsSync.enableSync()
do {
try await MacICloudSyncBootstrap.dictionarySync.enableSync()
} catch let error as PersonalDictionaryCloudSyncError {
MacICloudSyncBootstrap.settingsSync.disableSync()
isEnabled = false
if case .payloadTooLarge = error {
syncErrorMessage = MacL10n.string("mac.sync.error.dictTooLarge", language: language)
} else {
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
}
isApplyingToggle = false
return
}
reloadFromStore()
} catch {
isEnabled = false
@@ -102,79 +126,3 @@ struct MacSettingsICloudSyncRow: View {
}
}
}
struct MacDictionaryICloudSyncRow: View {
let defaults: UserDefaults
let language: AppUILanguage
@Environment(\.themePalette) private var palette
@State private var isEnabled = false
@State private var syncErrorMessage: String?
@State private var isApplyingToggle = false
var body: some View {
Toggle(isOn: toggleBinding) {
VStack(alignment: .leading, spacing: 2) {
Text(MacL10n.string("mac.sync.dictTitle", language: language))
Text(MacL10n.string("mac.sync.dictSubtitle", language: language))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.tint(palette.accent)
.disabled(isApplyingToggle)
.onAppear { reloadFromStore() }
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
reloadFromStore()
}
if let syncErrorMessage {
Text(syncErrorMessage)
.font(TypeStyle.caption)
.foregroundStyle(palette.danger)
}
}
private var toggleBinding: Binding<Bool> {
Binding(
get: { isEnabled },
set: { newValue in
guard newValue != isEnabled else { return }
newValue ? enableSync() : disableSync()
}
)
}
private func reloadFromStore() {
isEnabled = AppGroupStore(defaults: defaults).personalDictionaryICloudSyncEnabled
}
private func enableSync() {
isApplyingToggle = true
syncErrorMessage = nil
Task {
do {
try await MacICloudSyncBootstrap.dictionarySync.enableSync()
reloadFromStore()
} catch let error as PersonalDictionaryCloudSyncError {
isEnabled = false
if case .payloadTooLarge = error {
syncErrorMessage = MacL10n.string("mac.sync.error.dictTooLarge", language: language)
} else {
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
}
} catch {
isEnabled = false
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
}
isApplyingToggle = false
}
}
private func disableSync() {
MacICloudSyncBootstrap.dictionarySync.disableSync()
isEnabled = false
syncErrorMessage = nil
}
}
+104 -72
View File
@@ -198,6 +198,9 @@ struct MacLocalASRModelSettingsView: View {
@ObservedObject var viewModel: MacDictationViewModel
@StateObject private var modelVM = MacLocalASRModelSettingsViewModel()
@Environment(\.themePalette) private var palette
/// Only one model installs at a time, so a single hover flag drives the
/// active download ring's pause/resume affordance.
@State private var isHoveringProgress = false
private var lang: AppUILanguage { viewModel.config.uiLanguage }
@@ -217,42 +220,54 @@ struct MacLocalASRModelSettingsView: View {
}
private func modelPickerSection(catalog: LocalASRCatalogDocument) -> some View {
Section {
if let runtime = modelVM.currentRuntime(in: catalog) {
LabeledContent(runtime.displayName) {
Text(
modelVM.isRuntimeInstalled(runtime)
? MacL10n.string("mac.localASR.installed", language: lang)
: MacL10n.string("mac.localASR.notInstalled", language: lang)
)
MacSettingsSection(title: MacL10n.string("mac.localASR.models", language: lang)) {
VStack(spacing: MacMetrics.settingsRowGap) {
if let runtime = modelVM.currentRuntime(in: catalog) {
MacFormSubtitleRow(title: runtime.displayName) {
Text(
modelVM.isRuntimeInstalled(runtime)
? MacL10n.string("mac.localASR.installed", language: lang)
: MacL10n.string("mac.localASR.notInstalled", language: lang)
)
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
}
}
}
ForEach(catalog.models) { model in
modelRow(model)
}
ForEach(Array(catalog.models.enumerated()), id: \.element.id) { _, model in
modelRow(model)
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.padding(.horizontal, MacMetrics.settingsCardInset)
}
if modelVM.isInstalling,
modelVM.installProgress.phase == .extracting
|| modelVM.installProgress.phase == .validating
|| modelVM.installProgress.phase == .finalizing {
ProgressView(value: modelVM.installProgress.fraction) {
Text(modelVM.progressLabel(for: modelVM.installProgress, language: lang))
if modelVM.isInstalling,
modelVM.installProgress.phase == .extracting
|| modelVM.installProgress.phase == .validating
|| modelVM.installProgress.phase == .finalizing {
ProgressView(value: modelVM.installProgress.fraction) {
Text(modelVM.progressLabel(for: modelVM.installProgress, language: lang))
.font(TypeStyle.caption)
}
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.padding(.horizontal, MacMetrics.settingsCardInset)
}
if !modelVM.statusMessage.isEmpty {
Text(modelVM.statusMessage)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.padding(.horizontal, MacMetrics.settingsCardInset)
}
}
if !modelVM.statusMessage.isEmpty {
Text(modelVM.statusMessage)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
HStack(spacing: 0) {
MacSettingsToolButton(title: MacL10n.string("mac.localASR.openStorage", language: lang)) {
modelVM.revealStorageRoot()
}
Spacer(minLength: 0)
}
.padding(.horizontal, MacMetrics.settingsCardInset)
}
Button(MacL10n.string("mac.localASR.openStorage", language: lang)) {
modelVM.revealStorageRoot()
}
} header: {
Text(MacL10n.string("mac.localASR.models", language: lang))
}
}
@@ -305,7 +320,7 @@ struct MacLocalASRModelSettingsView: View {
.animation(Motion.soft, value: installed)
}
}
.padding(.vertical, 2)
.padding(.vertical, 0)
}
private func modelBadge(_ title: String, emphasized: Bool) -> some View {
@@ -329,49 +344,39 @@ struct MacLocalASRModelSettingsView: View {
installing: Bool
) -> some View {
if installing {
HStack(spacing: Spacing.sm) {
circularInstallProgress(for: model)
if modelVM.installProgress.phase == .downloading
|| modelVM.installProgress.phase == .paused {
Button {
if modelVM.isDownloadPaused {
modelVM.resumeDownload()
} else {
modelVM.pauseDownload()
}
} label: {
Image(systemName: modelVM.isDownloadPaused ? "play.fill" : "pause.fill")
.font(.system(size: 12, weight: .semibold))
.frame(width: 28, height: 28)
}
.buttonStyle(.bordered)
.controlSize(.small)
.help(
modelVM.isDownloadPaused
? MacL10n.string("mac.localASR.resume", language: lang)
: MacL10n.string("mac.localASR.pause", language: lang)
)
}
}
installProgressControl(for: model)
.animation(Motion.quick, value: isHoveringProgress)
.animation(Motion.quick, value: modelVM.isDownloadPaused)
} else if installed {
Button(MacL10n.string("mac.localASR.delete", language: lang), role: .destructive) {
MacSettingsToolButton(
title: MacL10n.string("mac.localASR.delete", language: lang),
fill: palette.danger.opacity(0.15),
foreground: palette.danger,
showsBorder: false
) {
modelVM.deleteModel(model)
}
.buttonStyle(.bordered)
.controlSize(.small)
.tint(palette.danger)
} else {
Button(MacL10n.string("mac.localASR.download", language: lang)) {
MacSettingsToolButton(
title: MacL10n.string("mac.localASR.download", language: lang),
fill: palette.accent,
foreground: .white,
showsBorder: false
) {
modelVM.installModel(model)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}
private func circularInstallProgress(for model: LocalASRModelDefinition) -> some View {
/// Single download ring. Shows the percentage while downloading; on hover it
/// reveals a clickable pause icon, and once paused it keeps a resume (play)
/// icon so the state stays clear without a second button.
private func installProgressControl(for model: LocalASRModelDefinition) -> some View {
let phase = modelVM.installProgress.phase
let pausable = phase == .downloading || phase == .paused
let paused = modelVM.isDownloadPaused
let fraction: Double = {
if modelVM.installProgress.phase == .downloading || modelVM.installProgress.phase == .paused,
if pausable,
let received = modelVM.installProgress.bytesReceived,
let total = modelVM.installProgress.bytesTotal,
total > 0 {
@@ -379,6 +384,7 @@ struct MacLocalASRModelSettingsView: View {
}
return modelVM.installProgress.fraction
}()
return ZStack {
Circle()
.stroke(palette.textTertiary.opacity(0.25), lineWidth: 3)
@@ -387,20 +393,46 @@ struct MacLocalASRModelSettingsView: View {
.stroke(palette.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(Motion.instant, value: fraction)
if modelVM.installProgress.phase == .paused {
Image(systemName: "pause.fill")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(palette.textSecondary)
} else {
Text("\(Int(fraction * 100))%")
.font(.system(size: 9, weight: .medium, design: .rounded))
.foregroundStyle(palette.textSecondary)
}
progressCenter(pausable: pausable, paused: paused, fraction: fraction)
}
.frame(width: 36, height: 36)
.contentShape(Circle())
.onHover { hovering in
guard pausable else { return }
isHoveringProgress = hovering
}
.onTapGesture {
guard pausable else { return }
if paused { modelVM.resumeDownload() } else { modelVM.pauseDownload() }
}
.help(
pausable
? (paused
? MacL10n.string("mac.localASR.resume", language: lang)
: MacL10n.string("mac.localASR.pause", language: lang))
: modelVM.progressLabel(for: modelVM.installProgress, language: lang)
)
.accessibilityLabel(modelVM.progressLabel(for: modelVM.installProgress, language: lang))
}
@ViewBuilder
private func progressCenter(pausable: Bool, paused: Bool, fraction: Double) -> some View {
if pausable, paused {
Image(systemName: "play.fill")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(palette.accent)
} else if pausable, isHoveringProgress {
Image(systemName: "pause.fill")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(palette.textSecondary)
} else {
Text("\(Int(fraction * 100))%")
.font(.system(size: 9, weight: .medium, design: .rounded))
.foregroundStyle(palette.textSecondary)
}
}
private func modelSubtitle(_ model: LocalASRModelDefinition, installed: Bool) -> String {
let size = modelVM.formattedSize(model.sizeBytes)
if let usage = modelVM.installedDiskUsage(model) {
+8 -5
View File
@@ -422,12 +422,15 @@ struct MacOnboardingView: View {
private var cloudAPIFields: some View {
VStack(alignment: .leading, spacing: Spacing.md) {
Picker(MacL10n.string("mac.settings.service", language: lang), selection: providerBinding) {
ForEach(viewModel.selectableProviders) { provider in
Text(provider.name).tag(provider.id)
MacInlinePicker(
selection: providerBinding,
options: viewModel.selectableProviders.map {
MacInlinePickerOption(
value: $0.id,
label: ProviderDisplayName.name(for: $0.id, language: lang)
)
}
}
.labelsHidden()
)
.frame(maxWidth: .infinity, alignment: .leading)
SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-...")) {
-95
View File
@@ -1,95 +0,0 @@
// MacQwen3ASREngine.swift
// OSGKeyboard · Mac
//
// Singleton actor that loads, warms up, and runs Qwen3-ASR via mlx-swift-asr.
// Model load + Metal JIT warmup take several seconds call `prepareIfNeeded`
// at launch so the first dictation is fast.
import Foundation
import MLXASR
/// Lifecycle of the on-disk MLX model inside the app process.
enum MacQwen3EnginePhase: Sendable, Equatable {
case idle
case loading
case ready
case failed(String)
}
actor MacQwen3ASREngine {
static let shared = MacQwen3ASREngine()
private var stt: Qwen3ASRSTT?
private var loadedModelPath: String?
private(set) var phase: MacQwen3EnginePhase = .idle
private init() {}
/// Load and warm up the model when the path changes or nothing is loaded yet.
func prepareIfNeeded(modelPath: String) async throws {
if loadedModelPath == modelPath, stt != nil, phase == .ready { return }
phase = .loading
stt = nil
loadedModelPath = nil
let directory = URL(fileURLWithPath: modelPath, isDirectory: true)
do {
let instance = try await Qwen3ASRSTT.loadWithWarmup(from: directory)
stt = instance
loadedModelPath = modelPath
phase = .ready
} catch {
let detail = error.localizedDescription
phase = .failed(detail)
throw MacLocalASRError.qwen3LoadFailed(detail)
}
}
/// Transcribe mono 16 kHz float PCM. Ensures the model is loaded first.
func transcribe(
samples: [Float],
language: String?,
modelPath: String,
context: String? = nil
) async throws -> String {
try await prepareIfNeeded(modelPath: modelPath)
guard let stt else {
throw MacLocalASRError.qwen3LoadFailed("Engine not initialized")
}
let result = try await stt.transcribe(audio: samples, language: language, context: context)
let text = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else {
throw MacLocalASRError.emptyTranscript
}
return text
}
/// Drop cached weights (e.g. after the user changes the model folder).
func unload() {
stt = nil
loadedModelPath = nil
phase = .idle
}
}
enum MacQwen3LanguageHint {
/// Map persisted BCP-47 locale ids to Qwen3 prompt language names.
/// Returns `nil` for auto-detect.
static func from(locale: Locale) -> String? {
let raw = locale.identifier.lowercased()
if raw.isEmpty || raw == "auto" { return nil }
if raw.hasPrefix("zh") { return "Chinese" }
if raw.hasPrefix("en") { return "English" }
if raw.hasPrefix("ja") { return "Japanese" }
if raw.hasPrefix("ko") { return "Korean" }
if raw.hasPrefix("fr") { return "French" }
if raw.hasPrefix("de") { return "German" }
if raw.hasPrefix("es") { return "Spanish" }
if raw.hasPrefix("pt") { return "Portuguese" }
if raw.hasPrefix("ru") { return "Russian" }
if raw.hasPrefix("ar") { return "Arabic" }
return nil
}
}
-58
View File
@@ -1,58 +0,0 @@
// MacQwen3LocalASR.swift
// OSGKeyboard · Mac
//
// Qwen3-ASR-1.7B (MLX) via mlx-swift-asr. Expects a converted model directory
// containing config.json, model.safetensors, and tokenizer files.
import Foundation
enum MacQwen3LocalASR {
/// Transcribe with Qwen3-ASR MLX weights at `modelPath`.
static func transcribe(
samples: [Float],
sampleRate: Int,
locale: Locale,
modelPath: String,
bias: LocalASRBiasPayload? = nil
) async throws -> String {
guard modelDirectoryIsInstalled(at: modelPath) else {
throw MacLocalASRError.qwen3ModelMissing
}
guard sampleRate == 16_000 else {
throw MacLocalASRError.qwen3InferenceFailed(
"Qwen3-ASR expects 16 kHz audio (got \(sampleRate) Hz)"
)
}
let language = MacQwen3LanguageHint.from(locale: locale)
let context = bias?.promptBias?.trimmingCharacters(in: .whitespacesAndNewlines)
let promptContext = (context?.isEmpty == false) ? context : nil
do {
return try await MacQwen3ASREngine.shared.transcribe(
samples: samples,
language: language,
modelPath: modelPath,
context: promptContext
)
} catch let error as MacLocalASRError {
throw error
} catch {
throw MacLocalASRError.qwen3InferenceFailed(error.localizedDescription)
}
}
private static func modelDirectoryIsInstalled(at path: String) -> Bool {
var isDir: ObjCBool = false
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue else {
return false
}
let fm = FileManager.default
let config = (path as NSString).appendingPathComponent("config.json")
let weights = (path as NSString).appendingPathComponent("model.safetensors")
guard fm.fileExists(atPath: config), fm.fileExists(atPath: weights) else {
return false
}
let names = (try? fm.contentsOfDirectory(atPath: path)) ?? []
return names.contains("vocab.json") && names.contains("merges.txt")
}
}
+5 -5
View File
@@ -38,7 +38,7 @@ struct MacRootView: View {
private var sidebar: some View {
VStack(spacing: 0) {
brandHeader
VStack(spacing: 4) {
VStack(spacing: 6) {
ForEach(MacSection.allCases) { section in
MacSidebarRow(
section: section,
@@ -70,8 +70,8 @@ struct MacRootView: View {
}
.padding(.leading, MacMetrics.sidebarContentInset)
.padding(.trailing, MacMetrics.sidebarInset)
.padding(.top, Spacing.lg)
.padding(.bottom, Spacing.md)
.padding(.top, 30)
.padding(.bottom, 30)
}
private var devicesFooter: some View {
@@ -127,10 +127,10 @@ private struct MacSidebarRow: View {
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 7)
.padding(.vertical, 9)
.background(
rowBackground,
in: RoundedRectangle(cornerRadius: 7, style: .continuous)
in: RoundedRectangle(cornerRadius: 8, style: .continuous)
)
.contentShape(Rectangle())
}
+543
View File
@@ -0,0 +1,543 @@
// MacSettingsComponents.swift
// OSGKeyboard · Mac
//
// Provider configuration rows for the Settings Form merged card layout,
// responsive label/control rows, and theme-aware credential chrome.
import SwiftUI
#if os(macOS)
import AppKit
#endif
// MARK: - Width-locked inline picker
/// Option for `MacInlinePicker`: a hashable value plus its display label.
struct MacInlinePickerOption<Value: Hashable>: Identifiable {
let value: Value
let label: String
var id: Value { value }
}
/// Boxed dropdown trigger that mirrors `.macFieldStyle()` chrome so every picker
/// reads as an editable field: the selected value on the left, a trailing
/// up/down chevron inside the *same* well. Holds a `minWidth` field footprint and
/// expands up to its column when the caller offers width (provider rows).
private struct MacPickerFieldBox: View {
@Environment(\.themePalette) private var palette
@Environment(\.colorScheme) private var colorScheme
let text: String
var body: some View {
// Same shape/fill/border rules as `MacFieldStyleModifier` so the dropdown
// sits flush with the credential text fields around it.
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
HStack(spacing: Spacing.sm) {
Text(text)
.font(MacSettingsType.control)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: Spacing.xs)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.padding(.horizontal, Spacing.sm)
// `minWidth` gives short values (e.g. "") a proper field footprint;
// `maxWidth: .infinity` lets fixed-label rows (provider picker) fill their
// column, while `.fixedSize()` callers (inline rows) hug to the minimum.
.frame(minWidth: 132, maxWidth: .infinity, minHeight: MacMetrics.settingsControlHeight, alignment: .leading)
.background(fieldFill, in: shape)
.overlay(shape.stroke(palette.divider, lineWidth: 0.5))
.contentShape(shape)
}
/// Light mode uses the true text-input white so the box reads as an editable
/// well; dark mode keeps the elevated grey (mirrors `MacFieldStyleModifier`).
private var fieldFill: Color {
#if os(macOS)
if colorScheme != .dark {
return Color(nsColor: .textBackgroundColor)
}
#endif
return palette.surfaceElevated
}
}
/// Dropdown that renders as an editable-looking field (see `MacPickerFieldBox`).
/// A `.menu` `Picker` renders as a native pop-up that ignores `.frame` and drops
/// custom labels; `Menu` instead renders its `label:` as the real control. Kept at
/// `.fixedSize()` so it hugs its content and is right-aligned by the enclosing
/// row's `Spacer`, matching the toggles/status text that share `MacInlineRow`.
struct MacInlinePicker<Value: Hashable>: View {
@Binding var selection: Value
let options: [MacInlinePickerOption<Value>]
/// When true the boxed field fills its column and is left-aligned matches
/// the two-column provider rows (see `MacProviderPickerRow`). When false it
/// hugs its content so the enclosing row's `Spacer` can right-align it.
var fillsWidth: Bool = false
var body: some View {
if fillsWidth {
menuButton
.frame(maxWidth: .infinity, alignment: .leading)
} else {
menuButton
.fixedSize()
}
}
private var menuButton: some View {
Menu {
ForEach(options) { option in
Button {
selection = option.value
} label: {
if option.value == selection {
Label(option.label, systemImage: "checkmark")
} else {
Text(option.label)
}
}
}
} label: {
MacPickerFieldBox(text: selectedLabel)
}
// `.button` menu style + `.plain` button style renders the custom
// `MacPickerFieldBox` label verbatim; `.borderlessButton` would instead
// draw macOS's own borderless pop-up chrome (accent chevrons, no box).
.menuStyle(.button)
.buttonStyle(.plain)
.menuIndicator(.hidden)
}
private var selectedLabel: String {
options.first { $0.value == selection }?.label ?? ""
}
}
// MARK: - Provider picker
struct MacProviderPickerRow: View {
let title: String
let providers: [LLMProvider]
@Binding var selection: String
var body: some View {
MacProviderSettingRow(title: title) {
Menu {
ForEach(providers) { provider in
Button {
selection = provider.id
} label: {
let name = ProviderDisplayName.name(for: provider.id)
if provider.id == selection {
Label(name, systemImage: "checkmark")
} else {
Text(name)
}
}
}
} label: {
MacPickerFieldBox(text: selectedName)
}
// See `MacInlinePicker`: `.button` + `.plain` keeps our boxed label
// instead of macOS's borderless pop-up chrome.
.menuStyle(.button)
.buttonStyle(.plain)
.menuIndicator(.hidden)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private var selectedName: String {
let id = providers.first { $0.id == selection }?.id ?? selection
return ProviderDisplayName.name(for: id)
}
}
// MARK: - Credential field
struct MacCredentialField: View {
let title: String
let placeholder: String
@Binding var text: String
var isSecret: Bool = false
var isMonospaced: Bool = true
var defaultValue: String?
var trailing: AnyView?
@State private var revealed = false
var body: some View {
MacProviderSettingRow(title: title) {
HStack(spacing: Spacing.xs) {
input
.frame(maxWidth: .infinity, alignment: .leading)
if let defaultValue, text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
MacSettingsIconButton(systemName: "checkmark", help: "Fill default") {
text = defaultValue
}
}
if let trailing {
trailing
}
if isSecret {
MacSettingsIconButton(
systemName: revealed ? "eye.slash" : "eye",
help: revealed ? "Hide" : "Show"
) {
revealed.toggle()
}
}
MacSettingsIconButton(systemName: "doc.on.doc", help: "Copy", disabled: text.isEmpty) {
#if os(macOS)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
#endif
}
}
}
}
@ViewBuilder
private var input: some View {
Group {
if isSecret && !revealed {
SecureField(text: $text, prompt: Text(verbatim: placeholder)) {
Text(title)
}
} else {
TextField(text: $text, prompt: Text(verbatim: placeholder)) {
Text(title)
}
}
}
.labelsHidden()
.autocorrectionDisabled(true)
.lineLimit(1)
.truncationMode(.middle)
.macFieldStyle(monospaced: isMonospaced)
}
}
// MARK: - Thinking toggle
struct MacProviderThinkingRow: View {
@Environment(\.themePalette) private var palette
let title: String
var subtitle: String? = nil
@Binding var isOn: Bool
var body: some View {
MacProviderSettingRow(title: title, verticalAlignment: .center) {
HStack(alignment: .center, spacing: Spacing.sm) {
Toggle("", isOn: $isOn)
.labelsHidden()
.toggleStyle(MacToggleStyle())
.accessibilityLabel(title)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
}
// MARK: - Model picker (editable field + fetch icon)
private struct MacModelComboFieldWidthKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
struct MacProviderModelRow: View {
@Environment(\.themePalette) private var palette
@Environment(\.colorScheme) private var colorScheme
let title: String
let placeholder: String
@Binding var model: String
let apiKey: String
let fetchModels: () async throws -> [String]
let language: AppUILanguage
@State private var models: [String] = []
@State private var isRunning = false
@State private var message: String?
@State private var failed = false
@State private var isDropdownOpen = false
@State private var fieldWidth: CGFloat = 0
private let chevronWidth: CGFloat = 28
private let dropdownMaxHeight: CGFloat = 240
private var trimmedAPIKey: String {
apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// Match `MacFieldStyleModifier` fill so the combo well sits flush with
/// neighboring credential fields.
private var fieldFill: Color {
#if os(macOS)
if colorScheme != .dark {
return Color(nsColor: .textBackgroundColor)
}
#endif
return palette.surfaceElevated
}
var body: some View {
MacProviderSettingRow(title: title) {
VStack(alignment: .leading, spacing: Spacing.xs) {
HStack(spacing: Spacing.xs) {
comboField
MacSettingsIconButton(
systemName: "arrow.triangle.2.circlepath",
help: MacL10n.string("mac.settings.fetchModels", language: language),
disabled: isRunning || trimmedAPIKey.isEmpty
) {
isDropdownOpen = false
Task { await runFetchModels() }
}
if isRunning {
ProgressView()
.controlSize(.small)
}
}
statusMessage
}
}
}
/// Editable model id + trailing chevron. Model list is a true popover so the
/// settings row height stays fixed (not an in-flow panel that grows the card).
private var comboField: some View {
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
return ZStack(alignment: .trailing) {
TextField(text: $model, prompt: Text(verbatim: placeholder)) {
Text(title)
}
.labelsHidden()
.textFieldStyle(.plain)
.autocorrectionDisabled(true)
.font(TypeStyle.mono)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.truncationMode(.middle)
.padding(.leading, Spacing.sm)
.padding(.trailing, chevronWidth + Spacing.sm)
.frame(maxWidth: .infinity, minHeight: MacMetrics.settingsControlHeight, alignment: .leading)
.background(fieldFill, in: shape)
.overlay(shape.stroke(palette.divider, lineWidth: 0.5))
Button {
isDropdownOpen.toggle()
} label: {
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(palette.textTertiary)
.frame(width: chevronWidth, height: MacMetrics.settingsControlHeight)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.trailing, Spacing.sm)
.help(MacL10n.string("mac.settings.selectModel", language: language))
.accessibilityLabel(MacL10n.string("mac.settings.selectModel", language: language))
}
.background(
GeometryReader { geo in
Color.clear.preference(key: MacModelComboFieldWidthKey.self, value: geo.size.width)
}
)
.onPreferenceChange(MacModelComboFieldWidthKey.self) { fieldWidth = $0 }
.popover(isPresented: $isDropdownOpen, attachmentAnchor: .rect(.bounds), arrowEdge: .bottom) {
dropdownPanel
.frame(width: max(fieldWidth, 180))
.padding(Spacing.xs)
}
}
private var dropdownPanel: some View {
Group {
if models.isEmpty {
Text(MacL10n.string("mac.settings.modelsEmptyHint", language: language))
.font(MacSettingsType.hint)
.foregroundStyle(palette.textTertiary)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, Spacing.sm)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
ScrollView {
VStack(alignment: .leading, spacing: Spacing.xxs) {
ForEach(models, id: \.self) { modelId in
dropdownRow(modelId)
}
}
}
.frame(maxHeight: dropdownMaxHeight)
}
}
}
private func dropdownRow(_ modelId: String) -> some View {
let isSelected = modelId == model
return Button {
model = modelId
message = MacL10n.format(
"mac.settings.modelSelected",
language: language,
modelId
)
failed = false
isDropdownOpen = false
} label: {
HStack(spacing: Spacing.sm) {
Text(modelId)
.font(TypeStyle.mono)
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
if isSelected {
Image(systemName: "checkmark")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(palette.accent)
}
}
.padding(.horizontal, Spacing.sm)
.padding(.vertical, Spacing.sm)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Radius.small, style: .continuous)
.fill(isSelected ? palette.accentMuted : Color.clear)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
@ViewBuilder
private var statusMessage: some View {
if let message {
Text(message)
.font(MacSettingsType.hint)
.foregroundStyle(failed ? palette.danger : palette.accent)
.lineLimit(3)
.fixedSize(horizontal: false, vertical: true)
}
}
@MainActor
private func runFetchModels() async {
guard !trimmedAPIKey.isEmpty else {
failed = true
message = SharedL10n.string("providerTools.error.missingAPIKey", language: language)
return
}
isRunning = true
failed = false
defer { isRunning = false }
let outcome = await ProviderToolRunner.runFetchModels(
runningMessage: MacL10n.string("mac.settings.loadingModels", language: language),
loadedMessage: { MacL10n.format("mac.settings.modelsLoaded", language: language, $0) },
emptyMessage: SharedL10n.string("providerTools.error.empty", language: language),
currentModel: model,
fetchModels: fetchModels
)
models = outcome.state.models
message = outcome.state.message
failed = outcome.state.failed
if let selected = outcome.selectedModel {
model = selected
}
}
}
// MARK: - Connection validate
struct MacProviderToolsRow: View {
@Environment(\.themePalette) private var palette
let title: String
let validate: () async throws -> Void
let language: AppUILanguage
@State private var isRunning = false
@State private var message: String?
@State private var failed = false
var body: some View {
MacProviderSettingRow(title: title) {
HStack(alignment: .center, spacing: Spacing.sm) {
MacSettingsToolButton(
title: MacL10n.string("mac.settings.validate", language: language),
disabled: isRunning
) {
Task { await runValidate() }
}
if isRunning {
ProgressView()
.controlSize(.small)
} else if let message {
Text(message)
.font(MacSettingsType.hint)
.foregroundStyle(failed ? palette.danger : palette.accent)
.lineLimit(3)
.truncationMode(.tail)
}
}
}
}
@MainActor
private func runValidate() async {
isRunning = true
failed = false
defer { isRunning = false }
let outcome = await ProviderToolRunner.runValidate(
runningMessage: MacL10n.string("mac.settings.validating", language: language),
successMessage: MacL10n.string("mac.settings.validateSuccess", language: language),
validate: validate
)
message = outcome.message
failed = outcome.failed
}
}
// MARK: - Caption note row
struct MacProviderNoteRow: View {
@Environment(\.themePalette) private var palette
let text: String
var body: some View {
Text(text)
.font(MacSettingsType.hint)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, minHeight: MacMetrics.settingsRowMinHeight, alignment: .leading)
.padding(.horizontal, MacMetrics.settingsCardInset)
}
}
+276 -290
View File
@@ -1,10 +1,8 @@
// MacSettingsView.swift
// OSGKeyboard · Mac
//
// Settings uses native grouped `Form` for correct control layout (Picker /
// Toggle / LabeledContent). Title and Form share the same plain
// `pageHorizontalInset` padding (Form scroll margins are zeroed first) so
// card chrome lines up with the page title.
// Settings uses native grouped `Form`. LLM / ASR provider blocks use a merged
// `MacSettingsProviderCard` with responsive rows and theme-aware controls.
import SwiftUI
#if os(macOS)
@@ -20,8 +18,6 @@ struct MacSettingsView: View {
@AppStorage(MacOnboardingState.storageKey)
private var hasCompletedMacOnboarding = true
@State private var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
@State private var showProviderPicker = false
@State private var showAsrProviderPicker = false
private var lang: AppUILanguage { viewModel.config.uiLanguage }
private let recognitionLocales: [(id: String, key: String, fallback: String)] = [
@@ -41,33 +37,25 @@ struct MacSettingsView: View {
subtitle: MacL10n.string("mac.page.settings.subtitle", language: lang)
)
Form {
generalSection
recognitionSection
polishProviderSection
if viewModel.config.engineMode == "cloud" {
asrProviderSection
.transition(.opacity)
ScrollView {
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
generalSection
recognitionSection
if viewModel.config.engineMode == "cloud" {
asrProviderSection
.transition(.opacity)
}
if viewModel.config.engineMode == "local" {
MacLocalASRModelSettingsView(viewModel: viewModel)
.transition(.opacity)
}
polishProviderSection
inputSection
legalSection
}
if viewModel.config.engineMode == "local" {
MacLocalASRModelSettingsView(viewModel: viewModel)
.transition(.opacity)
}
inputSection
legalSection
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.bottom, Spacing.md)
}
.formStyle(.grouped)
// Zero Form's own scroll margins, then inset via padding so the
// section cards line up with MacPageHeader (contentMargins alone
// does not match plain padding on macOS).
//
// grouped Form adds its own built-in section inset on top of our
// padding, so cards sat ~`groupedFormSectionInset` wider than the
// History page. Subtract that inset here so the card OUTER edge
// lands on `pageHorizontalInset` (40pt), matching History and the
// page title's left edge.
.contentMargins(.horizontal, 0, for: .scrollContent)
.padding(.horizontal, MacMetrics.pageHorizontalInset - MacMetrics.groupedFormSectionInset)
.tint(palette.accent)
.scrollContentBackground(.hidden)
.background(palette.background)
@@ -80,79 +68,83 @@ struct MacSettingsView: View {
// MARK: - General
private var generalSection: some View {
Section(MacL10n.string("mac.settings.general", language: lang)) {
Picker(MacL10n.string("mac.settings.appearance", language: lang), selection: $appearanceRaw) {
ForEach(MacAppearancePreference.allCases) { pref in
Text(MacL10n.string(pref.labelKey, language: lang)).tag(pref.rawValue)
MacSettingsSection(title: MacL10n.string("mac.settings.general", language: lang)) {
VStack(spacing: MacMetrics.settingsRowGap) {
MacProviderSettingRow(title: MacL10n.string("mac.settings.appearance", language: lang)) {
MacInlinePicker(
selection: $appearanceRaw,
options: MacAppearancePreference.allCases.map {
MacInlinePickerOption(value: $0.rawValue, label: MacL10n.string($0.labelKey, language: lang))
},
fillsWidth: true
)
}
}
Picker(MacL10n.string("mac.settings.interfaceLanguage", language: lang), selection: interfaceLanguageBinding) {
ForEach(AppUILanguage.allCases, id: \.self) { language in
Text(MacL10n.string(language.labelKey, language: lang)).tag(language.rawValue)
MacProviderSettingRow(title: MacL10n.string("mac.settings.interfaceLanguage", language: lang)) {
MacInlinePicker(
selection: interfaceLanguageBinding,
options: AppUILanguage.allCases.map {
MacInlinePickerOption(value: $0.rawValue, label: MacL10n.string($0.labelKey, language: lang))
},
fillsWidth: true
)
}
}
Picker(MacL10n.string("mac.settings.recognitionLanguage", language: lang), selection: recognitionLanguageBinding) {
ForEach(recognitionLocales, id: \.id) { locale in
Text(localeLabel(locale)).tag(locale.id)
MacProviderSettingRow(title: MacL10n.string("mac.settings.recognitionLanguage", language: lang)) {
MacInlinePicker(
selection: recognitionLanguageBinding,
options: recognitionLocales.map {
MacInlinePickerOption(value: $0.id, label: localeLabel($0))
},
fillsWidth: true
)
}
}
MacSettingsICloudSyncRow(defaults: viewModel.defaults, language: lang)
MacDictionaryICloudSyncRow(defaults: viewModel.defaults, language: lang)
MacSettingsICloudSyncRow(defaults: viewModel.defaults, language: lang)
}
}
}
// MARK: - Polish LLM
private var polishProviderSection: some View {
Section(MacL10n.string("mac.settings.polishProvider", language: lang)) {
providerPickerRow(
title: MacL10n.string("mac.settings.service", language: lang),
provider: currentPolishProvider,
isPresented: $showProviderPicker
) {
providerPickerList(
MacSettingsSection(title: MacL10n.string("mac.settings.polishProvider", language: lang)) {
MacSettingsProviderCard {
MacProviderPickerRow(
title: MacL10n.string("mac.settings.service", language: lang),
providers: viewModel.polishSelectableProviders,
selectedId: viewModel.config.providerId
) { provider in
viewModel.selectProvider(provider)
showProviderPicker = false
}
}
LabeledContent {
SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-…")) {
Text(MacL10n.string("mac.settings.apiKey", language: lang))
}
.labelsHidden()
.macFieldStyle()
.frame(maxWidth: MacMetrics.controlWidth)
} label: {
Text(MacL10n.string("mac.settings.apiKey", language: lang))
}
LabeledContent {
TextField(text: $viewModel.config.baseURL, prompt: Text(verbatim: "")) {
Text(MacL10n.string("mac.settings.baseURL", language: lang))
}
.labelsHidden()
.macFieldStyle()
.frame(maxWidth: MacMetrics.controlWidth)
} label: {
Text(MacL10n.string("mac.settings.baseURL", language: lang))
}
LabeledContent {
TextField(text: $viewModel.config.model, prompt: Text(verbatim: "")) {
Text(MacL10n.string("mac.settings.model", language: lang))
}
.labelsHidden()
.macFieldStyle()
.frame(maxWidth: MacMetrics.controlWidth)
} label: {
Text(MacL10n.string("mac.settings.model", language: lang))
selection: polishProviderBinding
)
MacCredentialField(
title: MacL10n.string("mac.settings.apiKey", language: lang),
placeholder: "sk-…",
text: $viewModel.config.apiKey,
isSecret: true
)
MacCredentialField(
title: MacL10n.string("mac.settings.baseURL", language: lang),
placeholder: currentPolishProvider.defaultBaseURL,
text: $viewModel.config.baseURL
)
MacProviderModelRow(
title: MacL10n.string("mac.settings.model", language: lang),
placeholder: currentPolishProvider.defaultModel,
model: $viewModel.config.model,
apiKey: viewModel.config.apiKey,
fetchModels: fetchMacLLMModels,
language: lang
)
.id(viewModel.config.providerId)
MacProviderThinkingRow(
title: MacL10n.string("mac.settings.thinking", language: lang),
subtitle: MacL10n.string("mac.settings.thinkingSubtitle", language: lang),
isOn: $viewModel.config.llmThinkingEnabled
)
MacProviderToolsRow(
title: MacL10n.string("mac.settings.connectionCheck", language: lang),
validate: validateMacLLM,
language: lang
)
}
}
}
@@ -160,154 +152,148 @@ struct MacSettingsView: View {
// MARK: - Cloud ASR
private var asrProviderSection: some View {
Section(MacL10n.string("mac.settings.asrProvider", language: lang)) {
providerPickerRow(
title: MacL10n.string("mac.settings.asrService", language: lang),
provider: currentAsrProvider,
isPresented: $showAsrProviderPicker
) {
providerPickerList(
MacSettingsSection(title: MacL10n.string("mac.settings.asrProvider", language: lang)) {
MacSettingsProviderCard {
MacProviderPickerRow(
title: MacL10n.string("mac.settings.asrService", language: lang),
providers: viewModel.asrSelectableProviders,
selectedId: viewModel.config.asrProviderId
) { provider in
viewModel.selectAsrProvider(provider)
showAsrProviderPicker = false
selection: asrProviderBinding
)
if viewModel.config.asrProviderId == "volcengine" {
volcengineAsrRows
} else {
genericAsrRows
}
}
LabeledContent {
SecureField(text: $viewModel.config.asrApiKey, prompt: Text(verbatim: "sk-…")) {
Text(MacL10n.string("mac.settings.apiKey", language: lang))
}
.labelsHidden()
.macFieldStyle()
.frame(maxWidth: MacMetrics.controlWidth)
} label: {
Text(MacL10n.string("mac.settings.asrApiKey", language: lang))
}
if CloudASRModelCatalog.strategy(for: viewModel.config.asrProviderId) == .prompt {
LabeledContent {
TextField(text: $viewModel.config.asrBaseURL, prompt: Text(verbatim: "")) {
Text(MacL10n.string("mac.settings.baseURL", language: lang))
}
.labelsHidden()
.macFieldStyle()
.frame(maxWidth: MacMetrics.controlWidth)
} label: {
Text(MacL10n.string("mac.settings.baseURL", language: lang))
}
}
LabeledContent {
TextField(text: $viewModel.config.asrModel, prompt: Text(verbatim: "")) {
Text(MacL10n.string("mac.settings.asrModel", language: lang))
}
.labelsHidden()
.macFieldStyle()
.frame(maxWidth: MacMetrics.controlWidth)
} label: {
Text(MacL10n.string("mac.settings.asrModel", language: lang))
MacProviderToolsRow(
title: MacL10n.string("mac.settings.connectionCheck", language: lang),
validate: validateMacASR,
language: lang
)
}
}
}
private func providerPickerRow<Content: View>(
title: String,
provider: LLMProvider,
isPresented: Binding<Bool>,
@ViewBuilder picker: @escaping () -> Content
) -> some View {
LabeledContent(title) {
Button {
isPresented.wrappedValue = true
} label: {
HStack(spacing: 6) {
providerLogo(provider.id)
Text(provider.name)
.foregroundStyle(palette.textPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.popover(isPresented: isPresented, arrowEdge: .bottom, content: picker)
@ViewBuilder
private var volcengineAsrRows: some View {
MacCredentialField(
title: MacL10n.string("mac.settings.volcengineAppId", language: lang),
placeholder: "APP ID",
text: Binding(
get: { macVolcengineFields.appID },
set: { updateMacVolcengine(appID: $0) }
),
isSecret: true
)
MacCredentialField(
title: MacL10n.string("mac.settings.volcengineAccessToken", language: lang),
placeholder: "Access Token",
text: Binding(
get: { macVolcengineFields.accessToken },
set: { updateMacVolcengine(accessToken: $0) }
),
isSecret: true
)
MacCredentialField(
title: MacL10n.string("mac.settings.volcengineResourceId", language: lang),
placeholder: CloudASRModelCatalog.defaultModel(for: "volcengine"),
text: Binding(
get: { macVolcengineFields.resourceID },
set: { updateMacVolcengine(resourceID: $0) }
),
defaultValue: CloudASRModelCatalog.defaultModel(for: "volcengine")
)
MacProviderNoteRow(text: MacL10n.string("mac.settings.volcengineNote", language: lang))
}
@ViewBuilder
private var genericAsrRows: some View {
if CloudASRModelCatalog.showsASREndpointField(for: viewModel.config.asrProviderId) {
MacCredentialField(
title: MacL10n.string("mac.settings.baseURL", language: lang),
placeholder: currentAsrProvider.defaultBaseURL,
text: $viewModel.config.asrBaseURL
)
}
MacCredentialField(
title: MacL10n.string("mac.settings.asrApiKey", language: lang),
placeholder: "sk-…",
text: $viewModel.config.asrApiKey,
isSecret: true
)
MacProviderModelRow(
title: MacL10n.string("mac.settings.asrModel", language: lang),
placeholder: CloudASRModelCatalog.defaultModel(for: viewModel.config.asrProviderId),
model: $viewModel.config.asrModel,
apiKey: viewModel.config.asrApiKey,
fetchModels: fetchMacASRModels,
language: lang
)
.id(viewModel.config.asrProviderId)
}
// MARK: - Recognition method
private var recognitionSection: some View {
Section(MacL10n.string("mac.settings.recognition", language: lang)) {
methodRow(
MacSettingsSection(title: MacL10n.string("mac.settings.recognition", language: lang)) {
VStack(spacing: MacMetrics.settingsRowGap) {
methodRow(
title: MacL10n.string("mac.settings.cloudEngine", language: lang),
subtitle: MacL10n.string("mac.settings.cloudEngineDesc", language: lang),
systemImage: "cloud",
selected: viewModel.config.engineMode == "cloud"
) { withAnimation(Motion.soft) { viewModel.setEngineMode("cloud") } }
methodRow(
methodRow(
title: MacL10n.string("mac.settings.localEngine", language: lang),
subtitle: MacL10n.string("mac.settings.localEngineDesc", language: lang),
systemImage: "cpu",
selected: viewModel.config.engineMode == "local"
) { withAnimation(Motion.soft) { viewModel.setEngineMode("local") } }
}
}
}
// MARK: - Hotkey / paste
private var inputSection: some View {
Section(MacL10n.string("mac.settings.input", language: lang)) {
Toggle(isOn: hotkeyBinding) {
rowLabel(
MacL10n.string("mac.settings.hotkey", language: lang),
subtitle: MacL10n.string("mac.settings.hotkeyDesc", language: lang)
)
}
Picker(selection: hotkeyTriggerBinding) {
ForEach(MacHotkeyTrigger.allCases) { trigger in
Text(MacL10n.string(trigger.labelKey, language: lang))
.tag(trigger.rawValue)
}
} label: {
rowLabel(
MacL10n.string("mac.settings.hotkeyTrigger", language: lang),
subtitle: MacL10n.string("mac.settings.hotkeyTriggerDesc", language: lang)
)
}
.disabled(!viewModel.hotkeyEnabled)
Toggle(isOn: autoPasteBinding) {
rowLabel(
MacL10n.string("mac.settings.autoPaste", language: lang),
subtitle: MacL10n.string("mac.settings.autoPasteDesc", language: lang)
)
}
LabeledContent {
HStack(spacing: Spacing.sm) {
Label(
accessibilityTrusted ? accessibilityStatusGranted : accessibilityStatusNeeded,
systemImage: accessibilityTrusted ? "checkmark.circle.fill" : "exclamationmark.circle"
MacSettingsSection(title: MacL10n.string("mac.settings.input", language: lang)) {
VStack(spacing: MacMetrics.settingsRowGap) {
MacProviderSettingRow(title: MacL10n.string("mac.settings.hotkeyTrigger", language: lang)) {
MacInlinePicker(
selection: hotkeyTriggerBinding,
options: MacHotkeyTrigger.allCases.map {
MacInlinePickerOption(value: $0.rawValue, label: MacL10n.string($0.labelKey, language: lang))
},
fillsWidth: true
)
.font(TypeStyle.caption)
.foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning)
.contentTransition(.opacity)
.animation(Motion.quick, value: accessibilityTrusted)
}
MacProviderSettingRow(
title: MacL10n.string("mac.settings.autoPaste", language: lang),
verticalAlignment: .center
) {
Toggle("", isOn: autoPasteBinding)
.labelsHidden()
.toggleStyle(MacToggleStyle())
}
MacProviderSettingRow(
title: MacL10n.string("mac.settings.accessibility", language: lang),
verticalAlignment: .center
) {
HStack(spacing: Spacing.sm) {
MacSettingsToolButton(title: MacL10n.string("mac.settings.openAccessibility", language: lang)) {
openAccessibilitySettings()
}
Button(MacL10n.string("mac.settings.openAccessibility", language: lang)) {
openAccessibilitySettings()
Label(
accessibilityTrusted ? accessibilityStatusGranted : accessibilityStatusNeeded,
systemImage: accessibilityTrusted ? "checkmark.circle.fill" : "exclamationmark.circle"
)
.font(TypeStyle.caption)
.foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning)
.contentTransition(.opacity)
.animation(Motion.quick, value: accessibilityTrusted)
}
}
} label: {
rowLabel(
MacL10n.string("mac.settings.accessibility", language: lang),
subtitle: MacL10n.string("mac.settings.accessibilityDesc", language: lang)
)
}
}
}
@@ -315,37 +301,32 @@ struct MacSettingsView: View {
// MARK: - Legal
private var legalSection: some View {
Section(MacL10n.string("mac.settings.about", language: lang)) {
NavigationLink {
MacPrivacyPolicyView(uiLanguage: lang)
} label: {
Text(MacL10n.string("mac.settings.privacyPolicy", language: lang))
}
NavigationLink {
MacOpenSourceLicensesView(uiLanguage: lang)
} label: {
Text(MacL10n.string("mac.settings.thirdPartyLicenses", language: lang))
}
Button(MacL10n.string("mac.settings.restartOnboarding", language: lang)) {
hasCompletedMacOnboarding = false
MacSettingsSection(title: MacL10n.string("mac.settings.about", language: lang)) {
VStack(spacing: MacMetrics.settingsRowGap) {
NavigationLink {
MacPrivacyPolicyView(uiLanguage: lang)
} label: {
MacFormLinkRow(title: MacL10n.string("mac.settings.privacyPolicy", language: lang))
}
.buttonStyle(.plain)
NavigationLink {
MacOpenSourceLicensesView(uiLanguage: lang)
} label: {
MacFormLinkRow(title: MacL10n.string("mac.settings.thirdPartyLicenses", language: lang))
}
.buttonStyle(.plain)
Button(MacL10n.string("mac.settings.restartOnboarding", language: lang)) {
hasCompletedMacOnboarding = false
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity, minHeight: MacMetrics.settingsRowMinHeight, alignment: .leading)
.padding(.horizontal, MacMetrics.settingsCardInset)
}
}
}
// MARK: - Row helpers
private func rowLabel(_ title: String, subtitle: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
Text(subtitle)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private func methodRow(
title: String,
subtitle: String,
@@ -372,6 +353,8 @@ struct MacSettingsView: View {
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
.contentTransition(.symbolEffect(.replace))
}
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.padding(.horizontal, MacMetrics.settingsCardInset)
.animation(Motion.quick, value: selected)
.contentShape(Rectangle())
}
@@ -390,60 +373,59 @@ struct MacSettingsView: View {
?? LLMProvider.presets[0]
}
private func providerPickerList(
providers: [LLMProvider],
selectedId: String,
onSelect: @escaping (LLMProvider) -> Void
) -> some View {
VStack(spacing: 0) {
ForEach(providers) { provider in
Button {
onSelect(provider)
} label: {
HStack(spacing: Spacing.sm) {
providerLogo(provider.id)
Text(provider.name)
.foregroundStyle(palette.textPrimary)
Spacer(minLength: Spacing.md)
if provider.id == selectedId {
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.accent)
}
}
.padding(.horizontal, Spacing.md)
.frame(height: 34)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
.padding(.vertical, Spacing.xs)
.frame(width: 260)
private var macVolcengineFields: VolcengineASRFields {
VolcengineASRFields.parse(
apiKey: viewModel.config.asrApiKey,
resourceFallback: viewModel.config.asrModel.isEmpty
? CloudASRModelCatalog.defaultModel(for: "volcengine")
: viewModel.config.asrModel
)
}
/// Brand mark tinted to the current label colour (black on light / white
/// on dark). Template rendering + an explicit frame make the vector assets
/// resolve at a text-matched size inside the pop-up menu without a size
/// hint they collapse to zero and disappear.
@ViewBuilder
private func providerLogo(_ providerId: String) -> some View {
if let asset = ProviderLogo.assetName(for: providerId) {
Image(asset)
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.foregroundStyle(palette.textPrimary)
private func updateMacVolcengine(appID: String? = nil, accessToken: String? = nil, resourceID: String? = nil) {
var fields = macVolcengineFields
if let appID { fields.appID = appID }
if let accessToken { fields.accessToken = accessToken }
if let resourceID {
fields.resourceID = resourceID
viewModel.config.asrModel = resourceID
}
viewModel.config.asrApiKey = fields.encodedAPIKey
}
@ViewBuilder
private func providerLabel(_ provider: LLMProvider) -> some View {
Label {
Text(provider.name)
} icon: {
providerLogo(provider.id)
}
private func validateMacLLM() async throws {
let client = LLMClientFactory.make(
providerId: viewModel.config.providerId,
baseURL: viewModel.config.baseURL,
apiKey: viewModel.config.apiKey,
model: viewModel.config.model,
thinkingEnabled: viewModel.config.llmThinkingEnabled
)
_ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
}
private func fetchMacLLMModels() async throws -> [String] {
try await ProviderModelService.listLLMModels(
providerId: viewModel.config.providerId,
baseURL: viewModel.config.baseURL,
apiKey: viewModel.config.apiKey,
currentModel: viewModel.config.model
)
}
private func validateMacASR() async throws {
let persisted = AppGroupStore(defaults: viewModel.defaults)
let store = LiveConfigurationStore(config: viewModel.config, fallback: persisted)
try await CloudASRConnectionCheck.validate(store: store)
}
private func fetchMacASRModels() async throws -> [String] {
try await ProviderModelService.listASRModels(
providerId: viewModel.config.asrProviderId,
baseURL: viewModel.config.asrBaseURL,
apiKey: viewModel.config.asrApiKey,
currentModel: viewModel.config.asrModel
)
}
// MARK: - Bindings
@@ -455,17 +437,28 @@ struct MacSettingsView: View {
)
}
private var providerBinding: Binding<String> {
private var polishProviderBinding: Binding<String> {
Binding(
get: { viewModel.config.providerId },
set: { newId in
if let provider = viewModel.selectableProviders.first(where: { $0.id == newId }) {
if let provider = viewModel.polishSelectableProviders.first(where: { $0.id == newId }) {
viewModel.selectProvider(provider)
}
}
)
}
private var asrProviderBinding: Binding<String> {
Binding(
get: { viewModel.config.asrProviderId },
set: { newId in
if let provider = viewModel.asrSelectableProviders.first(where: { $0.id == newId }) {
viewModel.selectAsrProvider(provider)
}
}
)
}
private var recognitionLanguageBinding: Binding<String> {
Binding(
get: {
@@ -478,13 +471,6 @@ struct MacSettingsView: View {
)
}
private var hotkeyBinding: Binding<Bool> {
Binding(
get: { viewModel.hotkeyEnabled },
set: { viewModel.setHotkeyEnabled($0) }
)
}
private var hotkeyTriggerBinding: Binding<String> {
Binding(
get: { viewModel.hotkeyTrigger.rawValue },
+20
View File
@@ -231,3 +231,23 @@ enum MacSherpaONNXRunner {
}
}
}
enum MacQwen3LanguageHint {
/// Map persisted BCP-47 locale ids to Qwen3 prompt language names.
/// Returns `nil` for auto-detect.
static func from(locale: Locale) -> String? {
let raw = locale.identifier.lowercased()
if raw.isEmpty || raw == "auto" { return nil }
if raw.hasPrefix("zh") { return "Chinese" }
if raw.hasPrefix("en") { return "English" }
if raw.hasPrefix("ja") { return "Japanese" }
if raw.hasPrefix("ko") { return "Korean" }
if raw.hasPrefix("fr") { return "French" }
if raw.hasPrefix("de") { return "German" }
if raw.hasPrefix("es") { return "Spanish" }
if raw.hasPrefix("pt") { return "Portuguese" }
if raw.hasPrefix("ru") { return "Russian" }
if raw.hasPrefix("ar") { return "Arabic" }
return nil
}
}