feat: add streaming cloud ASR, polish routing, and settings card layout

Unify Bailian/Volcengine/OpenAI realtime streaming, ABE polish routing with
fun styles, and a shared card-page Settings hierarchy; bump to 1.1 (build 32).
This commit is contained in:
Rocky
2026-07-28 20:25:17 +08:00
parent 956331a2af
commit d656bac8c3
58 changed files with 4153 additions and 1396 deletions
+4
View File
@@ -57,6 +57,10 @@ enum MacMetrics {
/// window edge; only the content inside is inset.
/// Doubled from `Spacing.lg` so title + cards breathe from the edges.
static let pageHorizontalInset: CGFloat = Spacing.lg * 2
/// Minimum polish-style card width: at the default window the detail
/// pane (~540pt after sidebar + insets) fits three columns; narrowing
/// drops to two, widening adds a fourth+.
static let polishStyleCardMinWidth: CGFloat = 170
/// Built-in horizontal inset macOS grouped `Form` adds around its section
/// cards, on top of any padding we apply. Subtracted from
/// `pageHorizontalInset` on the Settings Form so its card outer edge lands
+37 -2
View File
@@ -37,8 +37,8 @@ enum MacDictationPipeline {
if store.engineMode == "local" {
return MacLocalASRService.usesMLXLiveStreaming()
}
let strategy = CloudASRModelCatalog.strategy(for: store.asrProviderId)
return strategy != .localFallback
return CloudASRModelCatalog.supportsTrueStreamingASR(for: store.asrProviderId)
|| CloudASRModelCatalog.strategy(for: store.asrProviderId) != .localFallback
}
/// Runs ASR then polish. Polish failures return cleaned raw ASR plus a warning.
@@ -108,6 +108,41 @@ enum MacDictationPipeline {
}
do {
if store.engineMode == "cloud",
CloudASRModelCatalog.supportsTrueStreamingASR(for: store.asrProviderId),
let streamingClient = CloudASRClientFactory.make(store: store) as? CloudASRStreamingCapable {
try? await streamingClient.prepare(dictionary: store.personalDictionary)
let pipeline = StreamingUtterancePipeline(
client: streamingClient,
locale: locale,
dictionary: store.personalDictionary
)
let outcome = await pipeline.transcribe(stream: stream, onPartial: onPartial)
switch outcome {
case .success(let success):
return MacLiveASRCaptureResult(
raw: success.text,
chunkWarning: success.chunkWarnings.first,
localBias: localBias,
shouldFallbackToBatch: false
)
case .failure:
return MacLiveASRCaptureResult(
raw: "",
chunkWarning: nil,
localBias: localBias,
shouldFallbackToBatch: true
)
case .cancelled:
return MacLiveASRCaptureResult(
raw: "",
chunkWarning: nil,
localBias: localBias,
shouldFallbackToBatch: true
)
}
}
let adapter = try makeChunkASRAdapter(store: store)
if let cloudAdapter = adapter as? MacCloudASRChunkAdapter {
try? await cloudAdapter.prepare()
+38 -4
View File
@@ -12,6 +12,8 @@ struct MacHistoryView: View {
@Environment(\.themePalette) private var palette
@State private var showClearConfirmation = false
@State private var showDeleteDayConfirmation = false
@State private var dayPendingDelete: Date?
private var lang: AppUILanguage { viewModel.config.uiLanguage }
@@ -75,6 +77,23 @@ struct MacHistoryView: View {
} message: {
Text(MacL10n.string("mac.history.clearMessage", language: lang))
}
.confirmationDialog(
MacL10n.string("mac.history.clearDayTitle", language: lang),
isPresented: $showDeleteDayConfirmation,
titleVisibility: .visible
) {
Button(MacL10n.string("mac.history.clearDayConfirm", language: lang), role: .destructive) {
if let day = dayPendingDelete {
withAnimation(Motion.soft) { historyStore.deleteEntries(on: day) }
}
dayPendingDelete = nil
}
Button(MacL10n.string("mac.cancel", language: lang), role: .cancel) {
dayPendingDelete = nil
}
} message: {
Text(MacL10n.string("mac.history.clearDayMessage", language: lang))
}
}
// MARK: - List
@@ -95,10 +114,25 @@ struct MacHistoryView: View {
private func daySection(_ group: (day: Date, items: [SpeechHistoryEntry])) -> some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(Self.dayFormatter.string(from: group.day))
.font(MacSettingsType.sectionTitle)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
HStack(alignment: .center, spacing: Spacing.sm) {
Text(Self.dayFormatter.string(from: group.day))
.font(MacSettingsType.sectionTitle)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
Spacer(minLength: 0)
Button {
dayPendingDelete = group.day
showDeleteDayConfirmation = true
} label: {
Text(MacL10n.string("mac.delete", language: lang))
.font(MacSettingsType.sectionTitle)
.foregroundStyle(palette.danger)
}
.buttonStyle(.plain)
.accessibilityLabel(MacL10n.string("mac.history.clearDayButton", language: lang))
}
MacCard(padding: 0) {
VStack(spacing: 0) {
@@ -256,8 +256,6 @@ struct MacLocalASRModelSettingsView: View {
}
downloadSourceRow
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.padding(.horizontal, MacMetrics.settingsCardInset)
HStack(spacing: 0) {
MacSettingsToolButton(title: MacL10n.string("mac.localASR.openStorage", language: lang)) {
@@ -271,24 +269,30 @@ struct MacLocalASRModelSettingsView: View {
}
private var downloadSourceRow: some View {
HStack(spacing: Spacing.sm) {
Text(MacL10n.string("mac.localASR.downloadSource", language: lang))
.foregroundStyle(palette.textSecondary)
Spacer(minLength: 0)
Picker("", selection: Binding(
get: { modelVM.downloadSource },
set: { modelVM.setDownloadSource($0) }
)) {
Text(MacL10n.string("mac.localASR.downloadSource.auto", language: lang))
.tag(LocalASRDownloadSourcePreference.auto)
Text(MacL10n.string("mac.localASR.downloadSource.hfMirror", language: lang))
.tag(LocalASRDownloadSourcePreference.hfMirror)
Text(MacL10n.string("mac.localASR.downloadSource.huggingface", language: lang))
.tag(LocalASRDownloadSourcePreference.huggingface)
}
.labelsHidden()
.pickerStyle(.menu)
.fixedSize()
MacProviderSettingRow(
title: MacL10n.string("mac.localASR.downloadSource", language: lang)
) {
MacInlinePicker(
selection: Binding(
get: { modelVM.downloadSource },
set: { modelVM.setDownloadSource($0) }
),
options: [
MacInlinePickerOption(
value: LocalASRDownloadSourcePreference.auto,
label: MacL10n.string("mac.localASR.downloadSource.auto", language: lang)
),
MacInlinePickerOption(
value: LocalASRDownloadSourcePreference.hfMirror,
label: MacL10n.string("mac.localASR.downloadSource.hfMirror", language: lang)
),
MacInlinePickerOption(
value: LocalASRDownloadSourcePreference.huggingface,
label: MacL10n.string("mac.localASR.downloadSource.huggingface", language: lang)
),
],
fillsWidth: true
)
.disabled(modelVM.isInstalling)
}
}
+86 -67
View File
@@ -11,6 +11,7 @@ struct MacPolishStylesView: View {
@Environment(\.themePalette) private var palette
@State private var editingPack: PolishStylePack?
@State private var viewingPack: PolishStylePack?
@State private var showEditor = false
@State private var errorMessage: String?
@@ -24,10 +25,12 @@ struct MacPolishStylesView: View {
_ = viewModel.polishStylesRevision
return store.activePolishStyleId
}
/// At the default window (~540pt content), ~170pt min yields 3 columns;
/// narrower 2, wider 4+. Cards stretch equally (no max width).
private var columns: [GridItem] {
[
GridItem(
.adaptive(minimum: 240, maximum: 360),
.adaptive(minimum: MacMetrics.polishStyleCardMinWidth),
spacing: Spacing.md,
alignment: .top
),
@@ -57,7 +60,11 @@ struct MacPolishStylesView: View {
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
styleSection(
title: MacL10n.string("mac.styles.builtin", language: lang),
packs: PolishStylePackCatalog.builtins
packs: PolishStylePackCatalog.BuiltinStyleGroup.practical.packs
)
styleSection(
title: MacL10n.string("mac.styles.fun", language: lang),
packs: PolishStylePackCatalog.BuiltinStyleGroup.fun.packs
)
if !catalog.entries.isEmpty {
styleSection(
@@ -76,6 +83,9 @@ struct MacPolishStylesView: View {
save(pack)
}
}
.sheet(item: $viewingPack) { pack in
MacPolishStylePromptDetailSheet(pack: pack, language: lang)
}
.alert(
MacL10n.string("mac.styles.error", language: lang),
isPresented: Binding(
@@ -118,13 +128,21 @@ struct MacPolishStylesView: View {
MacPolishStyleCard(
name: pack.displayName(language: lang),
subtitle: subtitle(for: pack),
iconName: iconName(for: pack),
isSelected: pack.id == activeID,
isUserStyle: pack.kind == .user,
language: lang,
activate: {
activate(pack)
},
// Builtin view prompt; custom edit (matches iOS).
primaryAction: {
if pack.kind == .builtin {
viewingPack = pack
} else {
editingPack = pack
showEditor = true
}
},
duplicate: {
editingPack = PolishStylePack(
name: "\(pack.displayName(language: lang)) \(MacL10n.string("mac.styles.copy", language: lang))",
@@ -132,27 +150,12 @@ struct MacPolishStylesView: View {
)
showEditor = true
},
edit: {
editingPack = pack
showEditor = true
},
delete: {
delete(pack)
}
)
}
private func iconName(for pack: PolishStylePack) -> String {
switch pack.id {
case "builtin.structured": return "list.bullet.rectangle"
case "builtin.formal": return "briefcase"
case "builtin.dating": return "heart.text.square"
case "builtin.chat": return "bubble.left.and.bubble.right"
case "builtin.light": return "wand.and.sparkles"
default: return "text.badge.star"
}
}
private func subtitle(for pack: PolishStylePack) -> String {
if pack.kind == .user {
return MacL10n.string("mac.styles.customDescription", language: lang)
@@ -202,13 +205,12 @@ struct MacPolishStylesView: View {
private struct MacPolishStyleCard: View {
let name: String
let subtitle: String
let iconName: String
let isSelected: Bool
let isUserStyle: Bool
let language: AppUILanguage
let activate: () -> Void
let primaryAction: () -> Void
let duplicate: () -> Void
let edit: () -> Void
let delete: () -> Void
@Environment(\.themePalette) private var palette
@@ -220,22 +222,19 @@ private struct MacPolishStyleCard: View {
ZStack(alignment: .topTrailing) {
Button(action: activate) {
VStack(alignment: .leading, spacing: Spacing.sm) {
Image(systemName: iconName)
.font(.system(size: 24, weight: .medium))
.foregroundStyle(isSelected ? palette.accent : palette.textSecondary)
Spacer(minLength: Spacing.xs)
Text(name)
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.padding(.trailing, 32)
Text(subtitle)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.lineLimit(2)
.lineLimit(3)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, minHeight: 132, alignment: .leading)
.padding(Spacing.md)
@@ -243,8 +242,22 @@ private struct MacPolishStyleCard: View {
}
.buttonStyle(.plain)
actionButtons
.padding(Spacing.sm)
// Builtin: eye view prompt; custom: pencil edit.
Button(action: primaryAction) {
Image(systemName: isUserStyle ? "pencil" : "eye")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.textSecondary)
.frame(width: 28, height: 28)
.background(palette.background.opacity(isHovering ? 0.9 : 0.72), in: Circle())
}
.padding(Spacing.sm)
.buttonStyle(.plain)
.accessibilityLabel(
MacL10n.string(
isUserStyle ? "mac.styles.edit" : "mac.styles.viewPrompt",
language: language
)
)
if isSelected {
Image(systemName: "checkmark.circle.fill")
@@ -271,54 +284,60 @@ private struct MacPolishStyleCard: View {
.animation(Motion.quick, value: isHovering)
.animation(Motion.quick, value: isSelected)
.onHover { isHovering = $0 }
}
private var actionButtons: some View {
HStack(spacing: Spacing.xxs) {
cardActionButton(
systemImage: "plus.square.on.square",
accessibilityLabel: MacL10n.string("mac.styles.copy", language: language),
action: duplicate
)
.contextMenu {
Button(MacL10n.string("mac.styles.copy", language: language), action: duplicate)
if isUserStyle {
cardActionButton(
systemImage: "pencil",
accessibilityLabel: MacL10n.string("mac.styles.edit", language: language),
action: edit
)
cardActionButton(
systemImage: "trash",
accessibilityLabel: MacL10n.string("mac.delete", language: language),
foreground: palette.danger,
action: delete
)
Button(MacL10n.string("mac.delete", language: language), role: .destructive, action: delete)
}
}
}
private func cardActionButton(
systemImage: String,
accessibilityLabel: String,
foreground: Color? = nil,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: systemImage)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(foreground ?? palette.textSecondary)
.frame(width: 28, height: 28)
.background(palette.background.opacity(isHovering ? 0.9 : 0.72), in: Circle())
}
.buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel)
}
private var hoverBorder: Color {
isHovering ? palette.dividerStrong : palette.divider
}
}
/// Read-only prompt viewer for built-in styles (mirrors iOS).
private struct MacPolishStylePromptDetailSheet: View {
let pack: PolishStylePack
let language: AppUILanguage
@Environment(\.dismiss) private var dismiss
@Environment(\.themePalette) private var palette
var body: some View {
VStack(alignment: .leading, spacing: Spacing.md) {
HStack {
Text(pack.displayName(language: language))
.font(TypeStyle.title2)
Spacer()
Button(MacL10n.string("mac.done", language: language)) { dismiss() }
.keyboardShortcut(.cancelAction)
}
ScrollView {
Text(pack.prompt)
.font(.body.monospaced())
.foregroundStyle(palette.textPrimary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md)
.background(
palette.surface,
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
.stroke(palette.divider, lineWidth: 1)
)
}
}
.padding(Spacing.xl)
.frame(width: 680, height: 520)
.background(palette.background)
}
}
private struct MacPolishStyleEditor: View {
let pack: PolishStylePack?
let language: AppUILanguage
+26
View File
@@ -146,6 +146,18 @@ struct MacSettingsView: View {
validate: validateMacLLM,
language: lang
)
MacProviderSettingRow(title: MacL10n.string("mac.settings.translation", language: lang)) {
MacInlinePicker(
selection: translationTargetBinding,
options: TranslationLanguageCatalog.all.map { language in
MacInlinePickerOption(
value: language.id,
label: translationLabel(for: language)
)
},
fillsWidth: true
)
}
}
}
}
@@ -497,6 +509,20 @@ struct MacSettingsView: View {
)
}
private var translationTargetBinding: Binding<String> {
Binding(
get: { viewModel.config.translationTargetLocaleId },
set: { viewModel.config.translationTargetLocaleId = $0 }
)
}
private func translationLabel(for language: TranslationLanguage) -> String {
if TranslationLanguageCatalog.isOff(language.id) {
return MacL10n.string("mac.settings.translationOff", language: lang)
}
return language.nativeName
}
// MARK: - AppKit actions (macOS only)
private func openAccessibilitySettings() {