Cursor: Apply local changes for cloud agent
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
// PolishStyleCorpusExportStore.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Exports the same paired dictation corpus used by personal style learning.
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct PolishStyleCorpusExport: Codable, Equatable {
|
||||
static let currentSchemaVersion = 1
|
||||
|
||||
let schemaVersion: Int
|
||||
let generatedAt: Date
|
||||
let appVersion: String
|
||||
let appBuild: String
|
||||
let effectiveCharacterCount: Int
|
||||
let requiredEffectiveCharacterCount: Int
|
||||
let examples: [Example]
|
||||
|
||||
struct Example: Codable, Equatable {
|
||||
let prePolishText: String
|
||||
let finalText: String
|
||||
let polishStyleID: String?
|
||||
let polishStylePrompt: String?
|
||||
let wasUserEdited: Bool
|
||||
let createdAt: Date
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class PolishStyleCorpusExportStore {
|
||||
static let shared = PolishStyleCorpusExportStore()
|
||||
|
||||
private let directoryURL: URL?
|
||||
private let fileManager: FileManager
|
||||
private let now: () -> Date
|
||||
private let appVersion: () -> String
|
||||
private let appBuild: () -> String
|
||||
|
||||
init(
|
||||
directoryURL: URL? = nil,
|
||||
fileManager: FileManager = .default,
|
||||
now: @escaping () -> Date = { Date() },
|
||||
appVersion: @escaping () -> String = { AppVersionDisplay.marketingVersion },
|
||||
appBuild: @escaping () -> String = { AppVersionDisplay.buildNumber }
|
||||
) {
|
||||
self.directoryURL = directoryURL
|
||||
self.fileManager = fileManager
|
||||
self.now = now
|
||||
self.appVersion = appVersion
|
||||
self.appBuild = appBuild
|
||||
}
|
||||
|
||||
func makeExport(from history: SyncedSpeechHistory) -> PolishStyleCorpusExport? {
|
||||
let eligibleCorpus = PolishStyleLearningCorpusBuilder.build(from: history)
|
||||
guard !eligibleCorpus.examples.isEmpty else { return nil }
|
||||
let corpus = PolishStyleLearningCorpusBuilder.trainingWindow(
|
||||
from: eligibleCorpus.examples
|
||||
)
|
||||
|
||||
let examples = corpus.examples
|
||||
.map {
|
||||
PolishStyleCorpusExport.Example(
|
||||
prePolishText: $0.prePolishText,
|
||||
finalText: $0.finalText,
|
||||
polishStyleID: $0.polishStyleID,
|
||||
polishStylePrompt: $0.polishStylePrompt,
|
||||
wasUserEdited: $0.wasUserEdited,
|
||||
createdAt: $0.createdAt
|
||||
)
|
||||
}
|
||||
return PolishStyleCorpusExport(
|
||||
schemaVersion: PolishStyleCorpusExport.currentSchemaVersion,
|
||||
generatedAt: now(),
|
||||
appVersion: appVersion(),
|
||||
appBuild: appBuild(),
|
||||
effectiveCharacterCount: corpus.effectiveCharacterCount,
|
||||
requiredEffectiveCharacterCount:
|
||||
PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount,
|
||||
examples: examples
|
||||
)
|
||||
}
|
||||
|
||||
func makeExportURL(from history: SyncedSpeechHistory) -> URL? {
|
||||
guard let directoryURL = resolvedDirectoryURL() else {
|
||||
return nil
|
||||
}
|
||||
let exportURL = exportURL(in: directoryURL)
|
||||
guard let export = makeExport(from: history) else {
|
||||
try? fileManager.removeItem(at: exportURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
try fileManager.createDirectory(
|
||||
at: directoryURL,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
||||
let jsonData = try encoder.encode(export)
|
||||
let archiveData = try SingleFileZIPArchive.makeArchive(
|
||||
fileName: "osgkeyboard-personal-style-corpus-v1.json",
|
||||
contents: jsonData
|
||||
)
|
||||
try archiveData.write(to: exportURL, options: .atomic)
|
||||
try? fileManager.setAttributes(
|
||||
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
|
||||
ofItemAtPath: exportURL.path
|
||||
)
|
||||
return exportURL
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedDirectoryURL() -> URL? {
|
||||
if let directoryURL {
|
||||
return directoryURL
|
||||
}
|
||||
return fileManager.urls(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask
|
||||
).first?.appendingPathComponent("CorpusExports", isDirectory: true)
|
||||
}
|
||||
|
||||
private func exportURL(in directoryURL: URL) -> URL {
|
||||
directoryURL.appendingPathComponent(
|
||||
"osgkeyboard-personal-style-corpus-v1.zip",
|
||||
isDirectory: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum SingleFileZIPArchive {
|
||||
private enum ArchiveError: Error {
|
||||
case fileNameTooLong
|
||||
case contentsTooLarge
|
||||
}
|
||||
|
||||
private static let localFileHeaderSignature: UInt32 = 0x0403_4B50
|
||||
private static let centralDirectorySignature: UInt32 = 0x0201_4B50
|
||||
private static let endOfCentralDirectorySignature: UInt32 = 0x0605_4B50
|
||||
private static let minimumVersion: UInt16 = 20
|
||||
private static let utf8Flag: UInt16 = 0x0800
|
||||
private static let storedCompressionMethod: UInt16 = 0
|
||||
private static let dosTime: UInt16 = 0
|
||||
private static let dosDate: UInt16 = 0x0021
|
||||
|
||||
static func makeArchive(fileName: String, contents: Data) throws -> Data {
|
||||
let fileNameData = Data(fileName.utf8)
|
||||
guard let fileNameLength = UInt16(exactly: fileNameData.count) else {
|
||||
throw ArchiveError.fileNameTooLong
|
||||
}
|
||||
guard let contentsSize = UInt32(exactly: contents.count) else {
|
||||
throw ArchiveError.contentsTooLarge
|
||||
}
|
||||
let checksum = crc32(contents)
|
||||
|
||||
var archive = Data()
|
||||
archive.appendLittleEndian(localFileHeaderSignature)
|
||||
archive.appendLittleEndian(minimumVersion)
|
||||
archive.appendLittleEndian(utf8Flag)
|
||||
archive.appendLittleEndian(storedCompressionMethod)
|
||||
archive.appendLittleEndian(dosTime)
|
||||
archive.appendLittleEndian(dosDate)
|
||||
archive.appendLittleEndian(checksum)
|
||||
archive.appendLittleEndian(contentsSize)
|
||||
archive.appendLittleEndian(contentsSize)
|
||||
archive.appendLittleEndian(fileNameLength)
|
||||
archive.appendLittleEndian(UInt16(0))
|
||||
archive.append(fileNameData)
|
||||
archive.append(contents)
|
||||
|
||||
guard let centralDirectoryOffset = UInt32(exactly: archive.count) else {
|
||||
throw ArchiveError.contentsTooLarge
|
||||
}
|
||||
archive.appendLittleEndian(centralDirectorySignature)
|
||||
archive.appendLittleEndian(minimumVersion)
|
||||
archive.appendLittleEndian(minimumVersion)
|
||||
archive.appendLittleEndian(utf8Flag)
|
||||
archive.appendLittleEndian(storedCompressionMethod)
|
||||
archive.appendLittleEndian(dosTime)
|
||||
archive.appendLittleEndian(dosDate)
|
||||
archive.appendLittleEndian(checksum)
|
||||
archive.appendLittleEndian(contentsSize)
|
||||
archive.appendLittleEndian(contentsSize)
|
||||
archive.appendLittleEndian(fileNameLength)
|
||||
archive.appendLittleEndian(UInt16(0))
|
||||
archive.appendLittleEndian(UInt16(0))
|
||||
archive.appendLittleEndian(UInt16(0))
|
||||
archive.appendLittleEndian(UInt16(0))
|
||||
archive.appendLittleEndian(UInt32(0))
|
||||
archive.appendLittleEndian(UInt32(0))
|
||||
archive.append(fileNameData)
|
||||
|
||||
guard let centralDirectorySize = UInt32(
|
||||
exactly: archive.count - Int(centralDirectoryOffset)
|
||||
) else {
|
||||
throw ArchiveError.contentsTooLarge
|
||||
}
|
||||
archive.appendLittleEndian(endOfCentralDirectorySignature)
|
||||
archive.appendLittleEndian(UInt16(0))
|
||||
archive.appendLittleEndian(UInt16(0))
|
||||
archive.appendLittleEndian(UInt16(1))
|
||||
archive.appendLittleEndian(UInt16(1))
|
||||
archive.appendLittleEndian(centralDirectorySize)
|
||||
archive.appendLittleEndian(centralDirectoryOffset)
|
||||
archive.appendLittleEndian(UInt16(0))
|
||||
return archive
|
||||
}
|
||||
|
||||
private static func crc32(_ data: Data) -> UInt32 {
|
||||
data.reduce(UInt32.max) { checksum, byte in
|
||||
let tableIndex = Int((checksum ^ UInt32(byte)) & 0xFF)
|
||||
return crc32Table[tableIndex] ^ (checksum >> 8)
|
||||
} ^ UInt32.max
|
||||
}
|
||||
|
||||
private static let crc32Table: [UInt32] = (0..<256).map { index in
|
||||
(0..<8).reduce(UInt32(index)) { value, _ in
|
||||
(value & 1) == 1
|
||||
? 0xEDB8_8320 ^ (value >> 1)
|
||||
: value >> 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Data {
|
||||
mutating func appendLittleEndian<T: FixedWidthInteger>(_ value: T) {
|
||||
var littleEndianValue = value.littleEndian
|
||||
Swift.withUnsafeBytes(of: &littleEndianValue) {
|
||||
append(contentsOf: $0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// AppDistributionChannel.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Keeps internal tools available in local Debug and TestFlight builds only.
|
||||
|
||||
import Foundation
|
||||
|
||||
enum AppDistributionChannel {
|
||||
static var allowsInternalTools: Bool {
|
||||
allowsInternalTools(
|
||||
isDebugBuild: isDebugBuild,
|
||||
receiptURL: Bundle.main.appStoreReceiptURL
|
||||
)
|
||||
}
|
||||
|
||||
static func allowsInternalTools(
|
||||
isDebugBuild: Bool,
|
||||
receiptURL: URL?
|
||||
) -> Bool {
|
||||
isDebugBuild || receiptURL?.lastPathComponent == "sandboxReceipt"
|
||||
}
|
||||
|
||||
private static var isDebugBuild: Bool {
|
||||
#if DEBUG
|
||||
true
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,11 @@ import SwiftUI
|
||||
struct FlowDiagnosticsSettingsView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@ObservedObject private var historyStore = SpeechHistoryStore.shared
|
||||
|
||||
@State private var reportURLs: [URL] = []
|
||||
@State private var exportURL: URL?
|
||||
@State private var corpusExportURL: URL?
|
||||
@State private var showsClearConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
@@ -57,6 +59,10 @@ struct FlowDiagnosticsSettingsView: View {
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
if AppDistributionChannel.allowsInternalTools {
|
||||
corpusExportSection
|
||||
}
|
||||
|
||||
CardSection("settings.diagnostics.privacy.section") {
|
||||
Text("settings.diagnostics.privacy.body")
|
||||
.font(TypeStyle.caption2)
|
||||
@@ -71,6 +77,9 @@ struct FlowDiagnosticsSettingsView: View {
|
||||
.navigationTitle("settings.diagnostics.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: refreshReports)
|
||||
.onChange(of: historyStore.entries) {
|
||||
refreshCorpusExport()
|
||||
}
|
||||
.alert("settings.diagnostics.clear.title", isPresented: $showsClearConfirmation) {
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
Button("settings.diagnostics.clear.confirm", role: .destructive) {
|
||||
@@ -82,6 +91,72 @@ struct FlowDiagnosticsSettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var corpusExportSection: some View {
|
||||
CardSection("settings.diagnostics.corpus.section") {
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(corpusCountText)
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(corpusStatusText)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.settingsListRow()
|
||||
|
||||
if let corpusExportURL {
|
||||
Divider().background(palette.divider)
|
||||
|
||||
ShareLink(item: corpusExportURL) {
|
||||
actionRow(
|
||||
title: "settings.diagnostics.corpus.export",
|
||||
systemImage: "square.and.arrow.up"
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("settings.diagnostics.corpus.export")
|
||||
}
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
Text("settings.diagnostics.corpus.privacy")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.settingsListRow()
|
||||
}
|
||||
.surfaceCard()
|
||||
}
|
||||
}
|
||||
|
||||
private var corpus: PolishStyleLearningCorpus {
|
||||
let eligibleCorpus = PolishStyleLearningCorpusBuilder.build(
|
||||
from: historyStore.snapshot()
|
||||
)
|
||||
return PolishStyleLearningCorpusBuilder.trainingWindow(
|
||||
from: eligibleCorpus.examples
|
||||
)
|
||||
}
|
||||
|
||||
private var corpusCountText: String {
|
||||
String(
|
||||
format: AppL10n.string(
|
||||
"settings.diagnostics.corpus.count",
|
||||
language: config.uiLanguage
|
||||
),
|
||||
corpus.examples.count,
|
||||
corpus.effectiveCharacterCount
|
||||
)
|
||||
}
|
||||
|
||||
private var corpusStatusText: String {
|
||||
let key = corpus.examples.isEmpty
|
||||
? "settings.diagnostics.corpus.empty"
|
||||
: "settings.diagnostics.corpus.ready"
|
||||
return AppL10n.string(key, language: config.uiLanguage)
|
||||
}
|
||||
|
||||
private var reportCountText: String {
|
||||
String(
|
||||
format: AppL10n.string(
|
||||
@@ -135,5 +210,16 @@ struct FlowDiagnosticsSettingsView: View {
|
||||
private func refreshReports() {
|
||||
reportURLs = FlowFailureLogStore.shared.reportURLs()
|
||||
exportURL = FlowFailureLogStore.shared.makeExportURL()
|
||||
refreshCorpusExport()
|
||||
}
|
||||
|
||||
private func refreshCorpusExport() {
|
||||
guard AppDistributionChannel.allowsInternalTools else {
|
||||
corpusExportURL = nil
|
||||
return
|
||||
}
|
||||
corpusExportURL = PolishStyleCorpusExportStore.shared.makeExportURL(
|
||||
from: historyStore.snapshot()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,13 @@ struct MainAppRoot: View {
|
||||
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
|
||||
config.reloadFromPersistedStorage()
|
||||
}
|
||||
.onReceive(
|
||||
NotificationCenter.default.publisher(for: .managedCreditsMayHaveChanged)
|
||||
) { _ in
|
||||
Task {
|
||||
await accountSession.refreshAccountData(force: true)
|
||||
}
|
||||
}
|
||||
.onChange(of: config.hasCompletedOnboarding) { _, done in
|
||||
if done {
|
||||
// Deploy now rather than via warmup: the user just finished
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
typealias LearnedStyleGenerator = @MainActor @Sendable (
|
||||
PolishStyleLearningCorpus,
|
||||
[PolishStyleReplyLearningExample],
|
||||
AppUILanguage
|
||||
) async throws -> PolishStylePack
|
||||
|
||||
@MainActor
|
||||
struct PolishStylesView: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@@ -24,16 +30,39 @@ struct PolishStylesView: View {
|
||||
@State private var isGeneratingLearnedStyle = false
|
||||
|
||||
private let store = AppGroupStore()
|
||||
private let learnedStyleGenerator: LearnedStyleGenerator
|
||||
private let columns = [
|
||||
GridItem(.flexible(), spacing: CardLayoutMetrics.compactItemSpacing),
|
||||
GridItem(.flexible(), spacing: CardLayoutMetrics.compactItemSpacing)
|
||||
]
|
||||
|
||||
init(
|
||||
initialEditingPack: PolishStylePack? = nil,
|
||||
learnedStyleGenerator: @escaping LearnedStyleGenerator = { corpus, replyExamples, language in
|
||||
try await PolishStyleLearningService(store: AppGroupStore())
|
||||
.generateStyle(
|
||||
from: corpus,
|
||||
replyExamples: replyExamples,
|
||||
outputLanguage: language
|
||||
)
|
||||
}
|
||||
) {
|
||||
_editingPack = State(initialValue: initialEditingPack)
|
||||
self.learnedStyleGenerator = learnedStyleGenerator
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
styleLearningCard
|
||||
if let learnedStylePack {
|
||||
learnedStyleCard(
|
||||
learnedStylePack,
|
||||
corpus: styleLearningCorpus
|
||||
)
|
||||
} else {
|
||||
styleLearningCard
|
||||
}
|
||||
packGridSection(
|
||||
title: "polishStyles.builtin.section",
|
||||
packs: PolishStylePackCatalog.BuiltinStyleGroup.practical.packs
|
||||
@@ -42,11 +71,10 @@ struct PolishStylesView: View {
|
||||
title: "polishStyles.fun.section",
|
||||
packs: PolishStylePackCatalog.BuiltinStyleGroup.fun.packs
|
||||
)
|
||||
if !catalog.entries.isEmpty {
|
||||
if !remainingUserPacks.isEmpty {
|
||||
packGridSection(
|
||||
title: "polishStyles.custom.section",
|
||||
packs: PolishStylePackCatalog.all(userCatalog: catalog)
|
||||
.filter { $0.kind == .user }
|
||||
packs: remainingUserPacks
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -108,6 +136,18 @@ struct PolishStylesView: View {
|
||||
PolishStyleLearningCorpusBuilder.build(from: history.snapshot())
|
||||
}
|
||||
|
||||
private var learnedStylePack: PolishStylePack? {
|
||||
catalog.entries
|
||||
.filter { $0.learningMetadata != nil }
|
||||
.max { $0.updatedAt < $1.updatedAt }
|
||||
}
|
||||
|
||||
private var remainingUserPacks: [PolishStylePack] {
|
||||
let featuredID = learnedStylePack?.id
|
||||
return PolishStylePackCatalog.all(userCatalog: catalog)
|
||||
.filter { $0.kind == .user && $0.id != featuredID }
|
||||
}
|
||||
|
||||
private var styleLearningCard: some View {
|
||||
let corpus = styleLearningCorpus
|
||||
let required = PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount
|
||||
@@ -206,6 +246,7 @@ struct PolishStylesView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canGenerate)
|
||||
.accessibilityIdentifier("polishStyles.learn.generate")
|
||||
|
||||
Text(
|
||||
reachedLimit
|
||||
@@ -220,6 +261,170 @@ struct PolishStylesView: View {
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
private func learnedStyleCard(
|
||||
_ pack: PolishStylePack,
|
||||
corpus: PolishStyleLearningCorpus
|
||||
) -> some View {
|
||||
let isSelected = pack.id == activeID
|
||||
let canRegenerate = corpus.isReady && !isGeneratingLearnedStyle
|
||||
let shape = RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
let actionShape = RoundedRectangle(
|
||||
cornerRadius: Radius.medium,
|
||||
style: .continuous
|
||||
)
|
||||
let selectedFillColors = colorScheme == .dark
|
||||
? [
|
||||
OSGColor.selectedCardFillLeadingDark,
|
||||
OSGColor.selectedCardFillTrailingDark
|
||||
]
|
||||
: [
|
||||
OSGColor.selectedCardFillLeadingLight,
|
||||
OSGColor.selectedCardFillTrailingLight
|
||||
]
|
||||
let selectedStroke = colorScheme == .dark
|
||||
? OSGColor.selectedCardStrokeDark
|
||||
: OSGColor.selectedCardStrokeLight
|
||||
|
||||
return VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
HStack(alignment: .top, spacing: Spacing.md) {
|
||||
Image(systemName: "person.wave.2.fill")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(
|
||||
palette.textPrimary.opacity(0.08),
|
||||
in: RoundedRectangle(
|
||||
cornerRadius: Radius.medium,
|
||||
style: .continuous
|
||||
)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(pack.displayName(language: config.uiLanguage))
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("polishStyles.learn.generated.description")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Button {
|
||||
editingPack = pack
|
||||
} label: {
|
||||
Image(systemName: "pencil")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(palette.surfaceElevated, in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("polishStyles.edit"))
|
||||
}
|
||||
|
||||
if let metadata = pack.learningMetadata {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Image(systemName: "clock")
|
||||
Text("polishStyles.learn.generatedAt")
|
||||
Text(
|
||||
metadata.generatedAt,
|
||||
format: .dateTime.year().month().day()
|
||||
)
|
||||
}
|
||||
Text(
|
||||
AppL10n.format(
|
||||
"polishStyles.learn.evidenceSummary",
|
||||
Int64(metadata.asrEffectiveCharacterCount),
|
||||
Int64(metadata.replyExampleCount),
|
||||
Int64(metadata.replyFinalEditCount)
|
||||
)
|
||||
)
|
||||
}
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
|
||||
HStack(spacing: Spacing.sm) {
|
||||
if isSelected {
|
||||
Label(
|
||||
"polishStyles.learn.selected",
|
||||
systemImage: "checkmark.circle.fill"
|
||||
)
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(maxWidth: .infinity, minHeight: 44)
|
||||
.background(palette.accentMuted, in: actionShape)
|
||||
} else {
|
||||
Button {
|
||||
activate(pack)
|
||||
} label: {
|
||||
Label(
|
||||
"polishStyles.learn.select",
|
||||
systemImage: "checkmark.circle"
|
||||
)
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.frame(maxWidth: .infinity, minHeight: 44)
|
||||
.background(palette.surfaceElevated, in: actionShape)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Button {
|
||||
generateLearnedStyle(from: corpus, replacing: pack)
|
||||
} label: {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
if isGeneratingLearnedStyle {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
Text(
|
||||
isGeneratingLearnedStyle
|
||||
? "polishStyles.learn.regenerating"
|
||||
: "polishStyles.learn.regenerate"
|
||||
)
|
||||
}
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(
|
||||
canRegenerate
|
||||
? palette.textPrimary
|
||||
: palette.textTertiary
|
||||
)
|
||||
.frame(maxWidth: .infinity, minHeight: 44)
|
||||
.background(palette.surfaceElevated, in: actionShape)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canRegenerate)
|
||||
}
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
.background {
|
||||
shape
|
||||
.fill(palette.surface)
|
||||
.overlay {
|
||||
if isSelected {
|
||||
shape
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: selectedFillColors,
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
shape.stroke(selectedStroke, lineWidth: 0.75)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.clipShape(shape)
|
||||
.cardElevation(accented: isSelected)
|
||||
.accessibilityIdentifier("polishStyles.learnedStyle.card")
|
||||
}
|
||||
|
||||
private func packGridSection(
|
||||
title: LocalizedStringKey,
|
||||
packs: [PolishStylePack]
|
||||
@@ -318,20 +523,36 @@ struct PolishStylesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func generateLearnedStyle(from corpus: PolishStyleLearningCorpus) {
|
||||
private func generateLearnedStyle(
|
||||
from corpus: PolishStyleLearningCorpus,
|
||||
replacing existingPack: PolishStylePack? = nil
|
||||
) {
|
||||
guard corpus.isReady, !isGeneratingLearnedStyle else { return }
|
||||
isGeneratingLearnedStyle = true
|
||||
Task {
|
||||
defer { isGeneratingLearnedStyle = false }
|
||||
do {
|
||||
let generated = try await PolishStyleLearningService(store: store)
|
||||
.generateStyle(
|
||||
from: corpus,
|
||||
outputLanguage: config.uiLanguage
|
||||
)
|
||||
let generated = try await learnedStyleGenerator(
|
||||
corpus,
|
||||
ClipboardReplyFeedbackStore.shared.learningExamples(),
|
||||
config.uiLanguage
|
||||
)
|
||||
// Always let the user inspect and edit the learned prompt before
|
||||
// it is saved, synced, or made active.
|
||||
editingPack = generated
|
||||
if let existingPack {
|
||||
editingPack = PolishStylePack(
|
||||
id: existingPack.id,
|
||||
name: generated.name,
|
||||
prompt: generated.prompt,
|
||||
allowsAddedEmoji: generated.allowsAddedEmoji,
|
||||
learningMetadata: generated.learningMetadata,
|
||||
kind: .user,
|
||||
createdAt: existingPack.createdAt,
|
||||
updatedAt: Date()
|
||||
)
|
||||
} else {
|
||||
editingPack = generated
|
||||
}
|
||||
} catch {
|
||||
errorMessage = localizedLearningError(error)
|
||||
}
|
||||
@@ -551,6 +772,7 @@ private struct PolishStyleEditorSheet: View {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("common.cancel") { dismiss() }
|
||||
.tint(palette.textPrimary)
|
||||
.accessibilityIdentifier("polishStyles.editor.cancel")
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("common.save") {
|
||||
@@ -560,8 +782,10 @@ private struct PolishStyleEditorSheet: View {
|
||||
prompt: prompt,
|
||||
allowsAddedEmoji: allowsAddedEmoji
|
||||
|| PolishStylePack.promptDeclaresAddedEmojiOptIn(prompt),
|
||||
learningMetadata: pack.learningMetadata,
|
||||
kind: .user,
|
||||
createdAt: pack.createdAt
|
||||
createdAt: pack.createdAt,
|
||||
updatedAt: Date()
|
||||
)
|
||||
onSave(result)
|
||||
dismiss()
|
||||
@@ -572,6 +796,7 @@ private struct PolishStyleEditorSheet: View {
|
||||
|| prompt.count > PolishStyleLimits.maximumPromptCharacters
|
||||
)
|
||||
.tint(palette.textPrimary)
|
||||
.accessibilityIdentifier("polishStyles.editor.save")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,16 +11,47 @@ import SwiftUI
|
||||
@MainActor
|
||||
struct PolishStylesScreenshotHarness: View {
|
||||
private let language: AppUILanguage
|
||||
private let generatedPack: PolishStylePack?
|
||||
private let showsSavedGeneratedStyle: Bool
|
||||
private let simulatesGeneration: Bool
|
||||
|
||||
init() {
|
||||
language = ReleaseNotesScreenshotFixture.language
|
||||
ProviderConfig.shared.uiLanguage = language
|
||||
ReleaseNotesScreenshotFixture.seedReadyStyleCorpus(language: language)
|
||||
simulatesGeneration = ProcessInfo.processInfo.arguments.contains(
|
||||
"--polish-styles-generation-demo"
|
||||
)
|
||||
if simulatesGeneration {
|
||||
ReleaseNotesScreenshotFixture.resetStyleCatalog()
|
||||
}
|
||||
showsSavedGeneratedStyle = ProcessInfo.processInfo.arguments.contains(
|
||||
"--polish-styles-generated-saved"
|
||||
)
|
||||
generatedPack = ProcessInfo.processInfo.arguments.contains(
|
||||
"--polish-styles-generated-review"
|
||||
)
|
||||
? ReleaseNotesScreenshotFixture.generatedStyle(language: language)
|
||||
: nil
|
||||
if showsSavedGeneratedStyle {
|
||||
ReleaseNotesScreenshotFixture.seedGeneratedStyle(language: language)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ThemedRoot {
|
||||
PolishStylesView()
|
||||
if simulatesGeneration {
|
||||
PolishStylesView(
|
||||
learnedStyleGenerator: { _, _, language in
|
||||
try await Task.sleep(for: .seconds(1.8))
|
||||
return ReleaseNotesScreenshotFixture.generatedStyle(
|
||||
language: language
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
PolishStylesView(initialEditingPack: generatedPack)
|
||||
}
|
||||
}
|
||||
.environment(\.locale, language.swiftUILocale)
|
||||
.preferredColorScheme(.light)
|
||||
@@ -70,6 +101,68 @@ private enum ReleaseNotesScreenshotFixture {
|
||||
history.reloadFromDisk()
|
||||
}
|
||||
|
||||
static func generatedStyle(language: AppUILanguage) -> PolishStylePack {
|
||||
let isChinese = language == .chinese
|
||||
return PolishStylePack(
|
||||
name: isChinese ? "我的说话风格" : "My Speaking Style",
|
||||
prompt: isChinese
|
||||
? """
|
||||
# 角色
|
||||
保留用户自然、直接的表达方式,优先使用简短口语,不写成客服或公文语气。
|
||||
|
||||
# 风格边界
|
||||
ASR preserve mode:只整理识别错误、标点和明显重复,不改变原意、措辞习惯与直接程度。
|
||||
AI reply active-transfer mode:主动使用自然短句,先回应对方,再补充必要信息;不复述对方原文,不虚构立场、事实或承诺。
|
||||
熟人闲聊可以使用符合语境的轻松幽默和 emoji,严肃场景保持克制。
|
||||
|
||||
# 示例
|
||||
原文:这个我晚点确认一下然后再跟你说
|
||||
输出:这个我晚点确认一下,再跟你说。
|
||||
|
||||
收到:今天可能要晚一点到
|
||||
回复:好,没事,你路上慢点。
|
||||
"""
|
||||
: """
|
||||
# Role
|
||||
Preserve the user's natural, direct voice with concise everyday wording.
|
||||
|
||||
# Style Boundaries
|
||||
ASR preserve mode: correct recognition and punctuation without changing intent.
|
||||
AI reply active-transfer mode: respond first, add only necessary detail, never restate the sender's message, and invent no facts or commitments.
|
||||
Light contextual humor and emoji are welcome with friends; stay restrained in serious contexts.
|
||||
|
||||
# Examples
|
||||
Draft: I will check this later and let you know
|
||||
Output: I'll check later and let you know.
|
||||
""",
|
||||
learningMetadata: PolishStylePack.LearningMetadata(
|
||||
schemaVersion: 2,
|
||||
evidenceStatus: "sufficient",
|
||||
confidence: 0.86,
|
||||
asrExampleCount: 42,
|
||||
asrEffectiveCharacterCount: 2_735,
|
||||
replyExampleCount: 12,
|
||||
replyFinalEditCount: 4,
|
||||
generatedAt: Date()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
static func seedGeneratedStyle(language: AppUILanguage) {
|
||||
let pack = generatedStyle(language: language)
|
||||
var catalog = PolishStyleCatalog()
|
||||
try? catalog.upsert(pack)
|
||||
let store = AppGroupStore()
|
||||
store.setPolishStyleCatalog(catalog)
|
||||
store.setActivePolishStyleId(pack.id)
|
||||
}
|
||||
|
||||
static func resetStyleCatalog() {
|
||||
let store = AppGroupStore()
|
||||
store.setPolishStyleCatalog(PolishStyleCatalog())
|
||||
store.setActivePolishStyleId(PolishStylePackCatalog.defaultID)
|
||||
}
|
||||
|
||||
static func seedFrequentTerms(language: AppUILanguage) {
|
||||
let store = FrequentTermStore()
|
||||
store.clear()
|
||||
|
||||
@@ -330,6 +330,22 @@ struct AIAgentSettingsView: View {
|
||||
CardSection("settings.aiAgent.responseLength.section") {
|
||||
VStack(spacing: 0) {
|
||||
AIResponseLengthPickerRow(config: config)
|
||||
Divider().background(palette.divider)
|
||||
Toggle(isOn: $config.multipleReplyVariantsEnabled) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("settings.aiAgent.multipleReplies.title")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.aiAgent.multipleReplies.description")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.settingsListRow()
|
||||
.accessibilityIdentifier(
|
||||
"settings.aiAgent.multipleReplies.toggle"
|
||||
)
|
||||
}
|
||||
.surfaceCard()
|
||||
}
|
||||
@@ -492,6 +508,7 @@ struct ClipboardSettingsView: View {
|
||||
) {
|
||||
Button("settings.clipboard.clear.confirm", role: .destructive) {
|
||||
history.clearAll()
|
||||
ClipboardReplyFeedbackStore.shared.clear()
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
|
||||
@@ -291,6 +291,12 @@
|
||||
"settings.diagnostics.clear.title" = "Clear diagnostic logs?";
|
||||
"settings.diagnostics.clear.message" = "This permanently removes all saved Flow startup failure reports from this device.";
|
||||
"settings.diagnostics.clear.confirm" = "Clear Logs";
|
||||
"settings.diagnostics.corpus.section" = "Personal Style Training Corpus";
|
||||
"settings.diagnostics.corpus.count" = "%d eligible samples · %d effective characters";
|
||||
"settings.diagnostics.corpus.empty" = "No eligible paired dictations are available.";
|
||||
"settings.diagnostics.corpus.ready" = "Newest complete samples through the 2,500-character target.";
|
||||
"settings.diagnostics.corpus.export" = "Export Training Corpus";
|
||||
"settings.diagnostics.corpus.privacy" = "Contains dictated text and prior polish prompts. Nothing is uploaded automatically. Share only with trusted devices or services.";
|
||||
"settings.diagnostics.privacy.section" = "Privacy";
|
||||
"settings.diagnostics.privacy.body" = "Reports stay on this device until you export or clear them. They include startup state, timings, error codes, and memory usage, but never audio, transcripts, clipboard content, prompts, API keys, or account identifiers.";
|
||||
"releaseNotes.title" = "What's New";
|
||||
@@ -306,6 +312,8 @@
|
||||
"settings.aiAgent.title" = "AI Agent";
|
||||
"settings.aiAgent.responseLength.section" = "Answers";
|
||||
"settings.aiAgent.responseLength.title" = "Response length";
|
||||
"settings.aiAgent.multipleReplies.title" = "Provide multiple replies";
|
||||
"settings.aiAgent.multipleReplies.description" = "Show several natural reply options when using Reply.";
|
||||
"settings.clipboard.title" = "Clipboard";
|
||||
"settings.clipboard.section" = "Features";
|
||||
"settings.clipboard.subtitle.on" = "On";
|
||||
@@ -320,12 +328,12 @@
|
||||
"settings.clipboard.paste.verified" = "Verified";
|
||||
"settings.clipboard.paste.open" = "Open iOS Settings";
|
||||
"settings.clipboard.storage.section" = "Local history";
|
||||
"settings.clipboard.storage.body" = "History stays on this device, does not sync through iCloud, and is never sent to AI automatically. Turning History off keeps saved items.";
|
||||
"settings.clipboard.storage.body" = "Clipboard history and reply choices stay on this device and do not sync through iCloud. Reply choices are sent to your selected AI only when you explicitly relearn a personal style. Turning History off keeps saved items.";
|
||||
"clipboard.paste.noText.title" = "Copy text first";
|
||||
"clipboard.paste.noText.message" = "Copy a short piece of text, then try again.";
|
||||
"settings.clipboard.clear.button" = "Clear clipboard history";
|
||||
"settings.clipboard.clear.title" = "Clear clipboard history?";
|
||||
"settings.clipboard.clear.message" = "This permanently removes all saved clipboard items from this device. This cannot be undone.";
|
||||
"settings.clipboard.clear.message" = "This permanently removes all saved clipboard items and reply choices from this device. This cannot be undone.";
|
||||
"settings.clipboard.clear.confirm" = "Clear history";
|
||||
"settings.typingInput.title" = "Pinyin Input";
|
||||
"settings.typingInput.default.title" = "Default Input";
|
||||
@@ -609,6 +617,8 @@
|
||||
"skills.clarifyRequest.description" = "Ask only the key questions needed for a question, task, or problem.";
|
||||
"skills.empathyReply.name" = "Empathetic reply";
|
||||
"skills.empathyReply.description" = "Acknowledge a complaint and offer a safe next step.";
|
||||
"skills.blessingReply.name" = "Reply to wishes";
|
||||
"skills.blessingReply.description" = "Thank the sender or join a group wish without confusing the recipient.";
|
||||
"skills.askForDetails.name" = "Ask for details";
|
||||
"skills.askForDetails.description" = "Request the information needed to diagnose or resolve a problem.";
|
||||
"skills.businessReply.name" = "Business reply";
|
||||
@@ -675,6 +685,13 @@
|
||||
"polishStyles.learn.ready" = "Ready";
|
||||
"polishStyles.learn.action" = "Generate Style";
|
||||
"polishStyles.learn.generating" = "Generating…";
|
||||
"polishStyles.learn.generated.description" = "Your personal style learned from reviewed evidence.";
|
||||
"polishStyles.learn.generatedAt" = "Generated";
|
||||
"polishStyles.learn.evidenceSummary" = "%lld ASR characters · %lld reply choices · %lld final edits";
|
||||
"polishStyles.learn.select" = "Use this style";
|
||||
"polishStyles.learn.selected" = "In use";
|
||||
"polishStyles.learn.regenerate" = "Regenerate";
|
||||
"polishStyles.learn.regenerating" = "Regenerating…";
|
||||
"polishStyles.learn.privacy" = "Sent to your configured AI only when you generate. Review and edit before saving.";
|
||||
"polishStyles.learn.limit" = "Delete a custom style before generating another one.";
|
||||
"polishStyles.learn.error.insufficient" = "Keep dictating until 2,500 effective characters are available.";
|
||||
|
||||
@@ -291,6 +291,12 @@
|
||||
"settings.diagnostics.clear.title" = "清除诊断日志?";
|
||||
"settings.diagnostics.clear.message" = "这将从本机永久删除所有 Flow 会话启动失败报告。";
|
||||
"settings.diagnostics.clear.confirm" = "清除日志";
|
||||
"settings.diagnostics.corpus.section" = "个人风格训练语料";
|
||||
"settings.diagnostics.corpus.count" = "%d 条可用样本 · %d 个有效字符";
|
||||
"settings.diagnostics.corpus.empty" = "暂无符合条件的成对听写语料。";
|
||||
"settings.diagnostics.corpus.ready" = "选取最新完整样本,累积至至少 2,500 个有效字符。";
|
||||
"settings.diagnostics.corpus.export" = "导出训练语料";
|
||||
"settings.diagnostics.corpus.privacy" = "文件包含口述文本和历史润色提示词,不会自动上传。请仅分享给可信设备或服务。";
|
||||
"settings.diagnostics.privacy.section" = "隐私";
|
||||
"settings.diagnostics.privacy.body" = "报告会保留在本机,直到你主动导出或清除。报告仅包含启动状态、耗时、错误码和内存占用,不会保存录音、转写、剪贴板内容、提示词、API Key 或账号标识。";
|
||||
"releaseNotes.title" = "更新说明";
|
||||
@@ -306,6 +312,8 @@
|
||||
"settings.aiAgent.title" = "AI Agent";
|
||||
"settings.aiAgent.responseLength.section" = "回答";
|
||||
"settings.aiAgent.responseLength.title" = "回复篇幅";
|
||||
"settings.aiAgent.multipleReplies.title" = "提供多种回复";
|
||||
"settings.aiAgent.multipleReplies.description" = "使用「回复」时提供多个自然、可直接发送的选项。";
|
||||
"settings.clipboard.title" = "剪贴板";
|
||||
"settings.clipboard.section" = "功能";
|
||||
"settings.clipboard.subtitle.on" = "已开启";
|
||||
@@ -320,12 +328,12 @@
|
||||
"settings.clipboard.paste.verified" = "已验证";
|
||||
"settings.clipboard.paste.open" = "打开系统设置";
|
||||
"settings.clipboard.storage.section" = "本机历史";
|
||||
"settings.clipboard.storage.body" = "历史仅保存在本机,不经 iCloud 同步,也不会自动发送给 AI。关闭历史不会删除已有记录。";
|
||||
"settings.clipboard.storage.body" = "剪贴板历史和回复选择记录仅保存在本机,不经 iCloud 同步;回复选择只会在你主动重新学习个人风格时提交给所选 AI。关闭历史不会删除已有记录。";
|
||||
"clipboard.paste.noText.title" = "请先复制文字";
|
||||
"clipboard.paste.noText.message" = "复制一小段文字后,再试一次。";
|
||||
"settings.clipboard.clear.button" = "清空剪贴板历史";
|
||||
"settings.clipboard.clear.title" = "清空剪贴板历史?";
|
||||
"settings.clipboard.clear.message" = "将从本机永久删除全部剪贴板历史,且无法撤销。";
|
||||
"settings.clipboard.clear.message" = "将从本机永久删除全部剪贴板历史和回复选择记录,且无法撤销。";
|
||||
"settings.clipboard.clear.confirm" = "清空历史";
|
||||
"settings.typingInput.title" = "拼音输入";
|
||||
"settings.typingInput.default.title" = "默认输入方式";
|
||||
@@ -608,6 +616,8 @@
|
||||
"skills.clarifyRequest.description" = "针对问题、任务或故障描述,只追问最关键的信息。";
|
||||
"skills.empathyReply.name" = "共情回复";
|
||||
"skills.empathyReply.description" = "理解投诉或负面反馈,并给出稳妥下一步。";
|
||||
"skills.blessingReply.name" = "回复祝福";
|
||||
"skills.blessingReply.description" = "感谢对方或参与群聊祝福,不混淆真正的收件人。";
|
||||
"skills.askForDetails.name" = "询问细节";
|
||||
"skills.askForDetails.description" = "询问定位或解决问题所需的信息。";
|
||||
"skills.businessReply.name" = "商务回复";
|
||||
@@ -674,6 +684,13 @@
|
||||
"polishStyles.learn.ready" = "可以生成";
|
||||
"polishStyles.learn.action" = "生成风格";
|
||||
"polishStyles.learn.generating" = "生成中…";
|
||||
"polishStyles.learn.generated.description" = "根据经过确认的证据学习得到的个人表达风格。";
|
||||
"polishStyles.learn.generatedAt" = "生成于";
|
||||
"polishStyles.learn.evidenceSummary" = "%lld 个 ASR 字符 · %lld 次回复选择 · %lld 次最终改稿";
|
||||
"polishStyles.learn.select" = "使用此风格";
|
||||
"polishStyles.learn.selected" = "使用中";
|
||||
"polishStyles.learn.regenerate" = "重新生成";
|
||||
"polishStyles.learn.regenerating" = "重新生成中…";
|
||||
"polishStyles.learn.privacy" = "仅在生成时发送给你配置的 AI,保存前可预览和修改。";
|
||||
"polishStyles.learn.limit" = "请先删除一个自定义风格,再生成新风格。";
|
||||
"polishStyles.learn.error.insufficient" = "请继续听写,累积到 2,500 个有效字符后再生成。";
|
||||
|
||||
Reference in New Issue
Block a user