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