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