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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user