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