feat(polish): add configurable style packs

Add shared prompt composition, custom style management, iCloud sync, and native iOS/macOS selection interfaces so users can keep a consistent writing voice across devices.
This commit is contained in:
Rocky
2026-07-26 12:34:43 +08:00
parent e6f99d2744
commit 937ce33f05
29 changed files with 2388 additions and 87 deletions
@@ -13,6 +13,7 @@ enum MacSection: String, CaseIterable, Identifiable {
case dashboard
case history
case dictionary
case styles
case settings
var id: String { rawValue }
@@ -22,6 +23,7 @@ enum MacSection: String, CaseIterable, Identifiable {
case .dashboard: return MacL10n.string("mac.section.dashboard", language: language)
case .history: return MacL10n.string("mac.section.history", language: language)
case .dictionary: return MacL10n.string("mac.section.dictionary", language: language)
case .styles: return MacL10n.string("mac.section.styles", language: language)
case .settings: return MacL10n.string("mac.section.settings", language: language)
}
}
@@ -31,6 +33,7 @@ enum MacSection: String, CaseIterable, Identifiable {
case .dashboard: return "house"
case .history: return "clock.arrow.circlepath"
case .dictionary: return "character.book.closed"
case .styles: return "text.badge.star"
case .settings: return "gearshape"
}
}
@@ -63,6 +66,7 @@ final class MacDictationViewModel: ObservableObject {
@Published var sessionSeconds: Int = 0
@Published var foregroundAppName: String?
@Published var dictionaryRevision = 0
@Published var polishStylesRevision = 0
@Published var autoPasteEnabled: Bool
@Published var hotkeyEnabled: Bool
@@ -137,6 +141,10 @@ final class MacDictationViewModel: ObservableObject {
dictionaryRevision += 1
}
func refreshPolishStyles() {
polishStylesRevision += 1
}
// MARK: - Derived
var polishSelectableProviders: [LLMProvider] {
@@ -34,6 +34,10 @@ enum MacICloudSyncBootstrap {
cloudSync?.dictionarySyncService ?? PersonalDictionaryCloudSync(makeStore: { AppGroupStore(defaults: .standard) })
}
static var polishStyleSync: PolishStyleCloudSync {
cloudSync?.polishStyleSyncService ?? PolishStyleCloudSync(makeStore: { AppGroupStore(defaults: .standard) })
}
static var appCloudSync: AppCloudSync {
cloudSync ?? AppCloudSync.shared
}
+292
View File
@@ -0,0 +1,292 @@
// MacPolishStylesView.swift
// OSGKeyboard · Mac
//
// macOS counterpart of the iOS polish-styles tab. Both surfaces edit the same
// Shared model and iCloud payload.
import SwiftUI
struct MacPolishStylesView: View {
@ObservedObject var viewModel: MacDictationViewModel
@Environment(\.themePalette) private var palette
@State private var editingPack: PolishStylePack?
@State private var showEditor = false
@State private var errorMessage: String?
private var lang: AppUILanguage { viewModel.config.uiLanguage }
private var store: AppGroupStore { AppGroupStore(defaults: viewModel.defaults) }
private var catalog: PolishStyleCatalog {
_ = viewModel.polishStylesRevision
return store.polishStyleCatalog
}
private var activeID: String {
_ = viewModel.polishStylesRevision
return store.activePolishStyleId
}
var body: some View {
VStack(spacing: 0) {
MacPageHeader(
title: MacL10n.string("mac.section.styles", language: lang),
subtitle: MacL10n.string("mac.styles.subtitle", language: lang)
) {
Button {
editingPack = nil
showEditor = true
} label: {
Label(
MacL10n.string("mac.styles.add", language: lang),
systemImage: "plus"
)
}
.buttonStyle(.borderedProminent)
.disabled(catalog.entries.count >= PolishStyleLimits.maximumUserPacks)
}
ScrollView {
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
styleSection(
title: MacL10n.string("mac.styles.builtin", language: lang),
packs: PolishStylePackCatalog.builtins
)
if !catalog.entries.isEmpty {
styleSection(
title: MacL10n.string("mac.styles.custom", language: lang),
packs: catalog.entries
)
}
}
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.bottom, Spacing.xl)
}
}
.background(palette.background)
.sheet(isPresented: $showEditor) {
MacPolishStyleEditor(pack: editingPack, language: lang) { pack in
save(pack)
}
}
.alert(
MacL10n.string("mac.styles.error", language: lang),
isPresented: Binding(
get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } }
)
) {
Button(MacL10n.string("mac.done", language: lang)) { errorMessage = nil }
} message: {
Text(errorMessage ?? "")
}
.task {
await MacICloudSyncBootstrap.polishStyleSync.pullAndMergeIfEnabled()
viewModel.refreshPolishStyles()
}
.onReceive(NotificationCenter.default.publisher(for: .polishStylesDidSyncFromCloud)) { _ in
viewModel.refreshPolishStyles()
}
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
viewModel.refreshPolishStyles()
}
}
private func styleSection(title: String, packs: [PolishStylePack]) -> some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(title)
.font(MacSettingsType.sectionTitle)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
MacCard(padding: 0) {
VStack(spacing: 0) {
ForEach(packs) { pack in
styleRow(pack)
if pack.id != packs.last?.id {
Divider().background(palette.divider)
}
}
}
}
}
}
private func styleRow(_ pack: PolishStylePack) -> some View {
HStack(spacing: Spacing.md) {
Button {
activate(pack)
} label: {
HStack(spacing: Spacing.md) {
Image(systemName: pack.id == activeID ? "checkmark.circle.fill" : "circle")
.foregroundStyle(pack.id == activeID ? palette.accent : palette.textTertiary)
VStack(alignment: .leading, spacing: 2) {
Text(pack.displayName(language: lang))
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Text(subtitle(for: pack))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.lineLimit(1)
}
Spacer()
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Button {
editingPack = PolishStylePack(
name: "\(pack.displayName(language: lang)) \(MacL10n.string("mac.styles.copy", language: lang))",
prompt: pack.prompt
)
showEditor = true
} label: {
Image(systemName: "plus.square.on.square")
}
.buttonStyle(.borderless)
if pack.kind == .user {
Button {
editingPack = pack
showEditor = true
} label: {
Image(systemName: "pencil")
}
.buttonStyle(.borderless)
Button(role: .destructive) {
delete(pack)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
}
private func subtitle(for pack: PolishStylePack) -> String {
if pack.kind == .user {
return MacL10n.string("mac.styles.customDescription", language: lang)
}
return MacL10n.string("mac.styles.\(pack.id.dropFirst("builtin.".count))", language: lang)
}
private func activate(_ pack: PolishStylePack) {
store.setActivePolishStyleId(pack.id)
viewModel.refreshPolishStyles()
Task {
try? await MacICloudSyncBootstrap.settingsSync.pushLocalIfEnabled()
}
}
private func save(_ pack: PolishStylePack) {
var updated = catalog
do {
try updated.upsert(pack)
store.setPolishStyleCatalog(updated)
store.setActivePolishStyleId(pack.id)
viewModel.refreshPolishStyles()
Task {
try? await MacICloudSyncBootstrap.polishStyleSync.pushLocalIfEnabled(updated)
try? await MacICloudSyncBootstrap.settingsSync.pushLocalIfEnabled()
}
} catch {
errorMessage = MacL10n.string("mac.styles.validation", language: lang)
}
}
private func delete(_ pack: PolishStylePack) {
var updated = catalog
updated.recordDeletion(of: pack.id)
store.setPolishStyleCatalog(updated)
if activeID == pack.id {
store.setActivePolishStyleId(PolishStylePackCatalog.defaultID)
}
viewModel.refreshPolishStyles()
Task {
try? await MacICloudSyncBootstrap.polishStyleSync.pushLocalIfEnabled(updated)
try? await MacICloudSyncBootstrap.settingsSync.pushLocalIfEnabled()
}
}
}
private struct MacPolishStyleEditor: View {
let pack: PolishStylePack?
let language: AppUILanguage
let onSave: (PolishStylePack) -> Void
@Environment(\.dismiss) private var dismiss
@Environment(\.themePalette) private var palette
@State private var name: String
@State private var prompt: String
init(
pack: PolishStylePack?,
language: AppUILanguage,
onSave: @escaping (PolishStylePack) -> Void
) {
self.pack = pack
self.language = language
self.onSave = onSave
_name = State(initialValue: pack?.name ?? "")
_prompt = State(initialValue: pack?.prompt ?? PolishStylePackCatalog.newUserPromptTemplate)
}
var body: some View {
VStack(alignment: .leading, spacing: Spacing.md) {
Text(MacL10n.string(pack == nil ? "mac.styles.add" : "mac.styles.edit", language: language))
.font(TypeStyle.title2)
TextField(MacL10n.string("mac.styles.name", language: language), text: $name)
.textFieldStyle(.roundedBorder)
HStack {
Text(MacL10n.string("mac.styles.prompt", language: language))
.font(MacSettingsType.sectionTitle)
Spacer()
Text("\(prompt.count)/\(PolishStyleLimits.maximumPromptCharacters)")
.font(TypeStyle.caption2)
.foregroundStyle(
prompt.count > PolishStyleLimits.maximumPromptCharacters
? palette.danger
: palette.textTertiary
)
}
TextEditor(text: $prompt)
.font(.body.monospaced())
.frame(minHeight: 360)
.padding(4)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium)
.stroke(palette.divider, lineWidth: 1)
)
Text(MacL10n.string("mac.styles.hint", language: language))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
HStack {
Spacer()
Button(MacL10n.string("mac.cancel", language: language)) { dismiss() }
Button(MacL10n.string("mac.save", language: language)) {
onSave(
PolishStylePack(
id: pack?.id ?? "user.\(UUID().uuidString.lowercased())",
name: name,
prompt: prompt,
kind: .user,
createdAt: pack?.createdAt ?? Date()
)
)
dismiss()
}
.buttonStyle(.borderedProminent)
.disabled(
name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| prompt.count > PolishStyleLimits.maximumPromptCharacters
)
}
}
.padding(Spacing.xl)
.frame(width: 680, height: 590)
.background(palette.background)
}
}
+1
View File
@@ -95,6 +95,7 @@ struct MacRootView: View {
case .dashboard: DashboardView(viewModel: viewModel)
case .history: MacHistoryView(viewModel: viewModel)
case .dictionary: MacDictionaryView(viewModel: viewModel)
case .styles: MacPolishStylesView(viewModel: viewModel)
case .settings: MacSettingsView(viewModel: viewModel)
}
}