diff --git a/Core/Package.swift b/Core/Package.swift index 0e73ae56..c9a4bc81 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -21,7 +21,9 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/azooKey/AzooKeyKanaKanjiConverter", revision: "44429812ea2f6fe1b8a759dd994c6b29eafbc88f", traits: kanaKanjiConverterTraits) + .package(url: "https://github.com/azooKey/AzooKeyKanaKanjiConverter", revision: "23544d6ea30822fd498caeff2dbc04d78b268134", traits: kanaKanjiConverterTraits), + .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"), + .package(url: "https://github.com/weichsel/ZIPFoundation.git", from: "0.9.0") ], targets: [ .executableTarget( @@ -36,7 +38,9 @@ let package = Package( name: "Core", dependencies: [ .product(name: "SwiftUtils", package: "AzooKeyKanaKanjiConverter"), - .product(name: "KanaKanjiConverterModuleWithDefaultDictionary", package: "AzooKeyKanaKanjiConverter") + .product(name: "KanaKanjiConverterModuleWithDefaultDictionary", package: "AzooKeyKanaKanjiConverter"), + .product(name: "Crypto", package: "swift-crypto"), + .product(name: "ZIPFoundation", package: "ZIPFoundation") ], swiftSettings: [.interoperabilityMode(.Cxx)], plugins: [ diff --git a/Core/Sources/Core/Configs/AppGroup.swift b/Core/Sources/Core/Configs/AppGroup.swift new file mode 100644 index 00000000..25a0ab62 --- /dev/null +++ b/Core/Sources/Core/Configs/AppGroup.swift @@ -0,0 +1,5 @@ +import Foundation + +public enum AppGroup { + public static let azooKeyMacIdentifier = "group.dev.ensan.inputmethod.azooKeyMac" +} diff --git a/Core/Sources/Core/Configs/BoolConfigItem.swift b/Core/Sources/Core/Configs/BoolConfigItem.swift index d9dfc10d..5f326294 100644 --- a/Core/Sources/Core/Configs/BoolConfigItem.swift +++ b/Core/Sources/Core/Configs/BoolConfigItem.swift @@ -32,6 +32,12 @@ extension Config { static let `default` = false public static let key: String = "dev.ensan.inputmethod.azooKeyMac.preference.debug.predictiveTyping" } + /// 入力訂正のデバッグ機能を有効化する設定 + public struct DebugTypoCorrection: BoolConfigItem { + public init() {} + static let `default` = false + public static let key: String = "dev.ensan.inputmethod.azooKeyMac.preference.debug.typoCorrection" + } /// ライブ変換を有効化する設定 public struct LiveConversion: BoolConfigItem { public init() {} diff --git a/Core/Sources/Core/InputUtils/DebugTypoCorrectionWeights.swift b/Core/Sources/Core/InputUtils/DebugTypoCorrectionWeights.swift new file mode 100644 index 00000000..264d95cc --- /dev/null +++ b/Core/Sources/Core/InputUtils/DebugTypoCorrectionWeights.swift @@ -0,0 +1,144 @@ +import Crypto +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import ZIPFoundation + +public enum DebugTypoCorrectionState: Sendable, Equatable { + case downloaded + case failed + case notDownloaded +} + +public enum DebugTypoCorrectionWeightsError: LocalizedError, Sendable { + case invalidHTTPStatus(url: URL, statusCode: Int) + case hashMismatch(fileName: String, expected: String, actual: String) + case extractedFolderNotFound(path: String) + + public var errorDescription: String? { + switch self { + case .invalidHTTPStatus(let url, let statusCode): + return "Failed to download \(url.lastPathComponent) (HTTP \(statusCode))" + case .hashMismatch(let fileName, let expected, let actual): + return "Hash mismatch for \(fileName). expected=\(expected), actual=\(actual)" + case .extractedFolderNotFound(let path): + return "Extracted folder not found at \(path)" + } + } +} + +public enum DebugTypoCorrectionWeights { + public struct RequiredFile: Sendable, Equatable { + public let fileName: String + public let md5: String + } + + public static let bundleDirectoryName = "input_n5_lm_v1" + + public static let requiredFiles: [RequiredFile] = [ + .init(fileName: "lm_c_abc.marisa", md5: "cb0c5c156eae8b16e9ddd0757d029263"), + .init(fileName: "lm_c_bc.marisa", md5: "49a68be03c58d67fdf078bcb48bce4a2"), + .init(fileName: "lm_r_xbx.marisa", md5: "d95157d1ff815b8d3e42b43660fdfa2f"), + .init(fileName: "lm_u_abx.marisa", md5: "9d3d1be564f78e4f4ca2ec7629a2b80b"), + .init(fileName: "lm_u_xbc.marisa", md5: "2c0f4652f78e8647cc70ab8eceba9b58") + ] + + private static let zipURL = URL(string: "https://huggingface.co/Miwa-Keita/input_n5_lm_v1/resolve/main/input_n5_lm_v1.zip")! + + public static var requiredFileNames: [String] { + Self.requiredFiles.map(\.fileName) + } + + public static func modelDirectoryURL(azooKeyApplicationSupportDirectoryURL: URL) -> URL { + azooKeyApplicationSupportDirectoryURL + .appendingPathComponent("downloaded", isDirectory: true) + .appendingPathComponent(Self.bundleDirectoryName, isDirectory: true) + } + + public static func hasRequiredWeightFiles(modelDirectoryURL: URL) -> Bool { + Self.requiredFiles.allSatisfy { + FileManager.default.fileExists(atPath: modelDirectoryURL.appendingPathComponent($0.fileName).path) + } + } + + public static func state(modelDirectoryURL: URL) -> DebugTypoCorrectionState { + do { + return try Self.validateWeights(modelDirectoryURL: modelDirectoryURL) ? .downloaded : .notDownloaded + } catch { + return .failed + } + } + + public static func validateWeights(modelDirectoryURL: URL) throws -> Bool { + for required in Self.requiredFiles { + let fileURL = modelDirectoryURL.appendingPathComponent(required.fileName) + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return false + } + let md5 = try Self.fileMD5HexString(fileURL: fileURL) + guard md5 == required.md5 else { + return false + } + } + return true + } + + public static func downloadWeights(modelDirectoryURL: URL) async throws { + let fileManager = FileManager.default + let parentDirectoryURL = modelDirectoryURL.deletingLastPathComponent() + try fileManager.createDirectory(at: parentDirectoryURL, withIntermediateDirectories: true) + + let temporaryRootURL = fileManager.temporaryDirectory + .appendingPathComponent("azookey-debug-tc-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: temporaryRootURL, withIntermediateDirectories: true) + defer { + try? fileManager.removeItem(at: temporaryRootURL) + } + + let downloadedZipTemporaryURL = temporaryRootURL.appendingPathComponent("input_n5_lm_v1.zip", isDirectory: false) + let (temporaryFileURL, response) = try await URLSession.shared.download(from: Self.zipURL) + if let httpResponse = response as? HTTPURLResponse, !(200 ... 299).contains(httpResponse.statusCode) { + throw DebugTypoCorrectionWeightsError.invalidHTTPStatus(url: Self.zipURL, statusCode: httpResponse.statusCode) + } + try fileManager.moveItem(at: temporaryFileURL, to: downloadedZipTemporaryURL) + + let extractionRootURL = temporaryRootURL.appendingPathComponent("extracted", isDirectory: true) + try fileManager.unzipItem(at: downloadedZipTemporaryURL, to: extractionRootURL) + + let stagingDirectoryURL = extractionRootURL.appendingPathComponent(Self.bundleDirectoryName, isDirectory: true) + guard fileManager.fileExists(atPath: stagingDirectoryURL.path) else { + throw DebugTypoCorrectionWeightsError.extractedFolderNotFound(path: stagingDirectoryURL.path) + } + + for required in Self.requiredFiles { + let fileURL = stagingDirectoryURL.appendingPathComponent(required.fileName, isDirectory: false) + let actualMD5 = try Self.fileMD5HexString(fileURL: fileURL) + guard actualMD5 == required.md5 else { + throw DebugTypoCorrectionWeightsError.hashMismatch(fileName: required.fileName, expected: required.md5, actual: actualMD5) + } + } + + if fileManager.fileExists(atPath: modelDirectoryURL.path) { + try fileManager.removeItem(at: modelDirectoryURL) + } + try fileManager.moveItem(at: stagingDirectoryURL, to: modelDirectoryURL) + } + + private static func fileMD5HexString(fileURL: URL) throws -> String { + let handle = try FileHandle(forReadingFrom: fileURL) + defer { + try? handle.close() + } + + var md5 = Insecure.MD5() + while true { + let data = try handle.read(upToCount: 1_048_576) ?? Data() + if data.isEmpty { + break + } + md5.update(data: data) + } + return md5.finalize().map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index fe279a1a..461523ba 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -31,6 +31,7 @@ public final class SegmentsManager { private let context: Context private var composingText: ComposingText = ComposingText() + private var lastInputStyle: InputStyle = .direct private var liveConversionEnabled: Bool { Config.LiveConversion().value @@ -61,10 +62,18 @@ public final class SegmentsManager { private var replaceSuggestions: [Candidate] = [] private var suggestSelectionIndex: Int? + private var backspaceAdjustedPredictionCandidate: PredictionCandidate? + private var backspaceTypoCorrectionLock: BackspaceTypoCorrectionLock? public struct PredictionCandidate: Sendable { public var displayText: String public var appendText: String + public var deleteCount: Int = 0 + } + + struct BackspaceTypoCorrectionLock: Sendable { + var displayText: String + var targetReading: String } private func candidateReading(_ candidate: Candidate) -> String { @@ -167,7 +176,8 @@ public final class SegmentsManager { requireJapanesePrediction: ConvertRequestOptions.PredictionMode, requireEnglishPrediction: ConvertRequestOptions.PredictionMode ) -> ConvertRequestOptions { - .init( + let canUseDebugTypoCorrection = Config.DebugTypoCorrection().value && self.hasDebugTypoCorrectionWeights() + return .init( requireJapanesePrediction: requireJapanesePrediction, requireEnglishPrediction: requireEnglishPrediction, keyboardLanguage: .ja_JP, @@ -180,17 +190,34 @@ public final class SegmentsManager { specialCandidateProviders: KanaKanjiConverter.defaultSpecialCandidateProviders, zenzaiMode: self.zenzaiMode(leftSideContext: leftSideContext, requestRichCandidates: requestRichCandidates), experimentalZenzaiPredictiveInput: true, + typoCorrectionConfig: .init( + mode: canUseDebugTypoCorrection ? .noisyChannel : .auto, + languageModel: .ngram(.init(prefix: self.downloadedInputN5LMDir.path + "/lm_", n: 5, d: 0.75)) + ), metadata: self.metadata ) } + private func hasDebugTypoCorrectionWeights() -> Bool { + DebugTypoCorrectionWeights.hasRequiredWeightFiles(modelDirectoryURL: self.downloadedInputN5LMDir) + } + public var azooKeyMemoryDir: URL { self.applicationDirectoryURL } + public var downloadedInputN5LMDir: URL { + DebugTypoCorrectionWeights.modelDirectoryURL( + azooKeyApplicationSupportDirectoryURL: self.applicationDirectoryURL.deletingLastPathComponent() + ) + } + @MainActor public func activate() { self.shouldShowCandidateWindow = false + self.backspaceAdjustedPredictionCandidate = nil + self.backspaceTypoCorrectionLock = nil + self.lastInputStyle = .direct self.zenzaiPersonalizationMode = self.getZenzaiPersonalizationMode() } @@ -205,6 +232,9 @@ public final class SegmentsManager { self.shouldShowCandidateWindow = false self.selectionIndex = nil self.resetAdditionalCandidates() + self.backspaceAdjustedPredictionCandidate = nil + self.backspaceTypoCorrectionLock = nil + self.lastInputStyle = .direct } @MainActor @@ -218,6 +248,9 @@ public final class SegmentsManager { self.shouldShowCandidateWindow = false self.selectionIndex = nil self.resetAdditionalCandidates() + self.backspaceAdjustedPredictionCandidate = nil + self.backspaceTypoCorrectionLock = nil + self.lastInputStyle = .direct } @MainActor @@ -230,6 +263,9 @@ public final class SegmentsManager { self.shouldShowCandidateWindow = false self.selectionIndex = nil self.resetAdditionalCandidates() + self.backspaceAdjustedPredictionCandidate = nil + self.backspaceTypoCorrectionLock = nil + self.lastInputStyle = .direct } /// 変換キーを押したタイミングで入力の区切りを示す @@ -239,6 +275,7 @@ public final class SegmentsManager { // すでに末尾がcompositionSeparatorの場合は何もしない return } + self.lastInputStyle = inputStyle self.composingText.insertAtCursorPosition([.init(piece: .compositionSeparator, inputStyle: inputStyle)]) self.lastOperation = .insert if !skipUpdate { @@ -248,6 +285,7 @@ public final class SegmentsManager { @MainActor public func insertAtCursorPosition(_ string: String, inputStyle: InputStyle) { + self.lastInputStyle = inputStyle self.composingText.insertAtCursorPosition(string, inputStyle: inputStyle) self.lastOperation = .insert // ライブ変換がオフの場合は変換候補ウィンドウを出したい @@ -257,6 +295,7 @@ public final class SegmentsManager { @MainActor public func insertAtCursorPosition(pieces: [InputPiece], inputStyle: InputStyle) { + self.lastInputStyle = inputStyle self.composingText.insertAtCursorPosition(pieces.map { .init(piece: $0, inputStyle: inputStyle) }) self.lastOperation = .insert // ライブ変換がオフの場合は変換候補ウィンドウを出したい @@ -297,17 +336,62 @@ public final class SegmentsManager { @MainActor public func deleteBackwardFromCursorPosition(count: Int = 1) { + var beforeComposingText = self.composingText.prefixToCursorPosition() if !self.composingText.isAtEndIndex { // 右端に持っていく _ = self.composingText.moveCursorFromCursorPosition(count: self.composingText.convertTarget.count - self.composingText.convertTargetCursorPosition) // 一度segmentの編集状態もリセットにする self.didExperienceSegmentEdition = false + beforeComposingText = self.composingText.prefixToCursorPosition() } self.composingText.deleteBackwardFromCursorPosition(count: count) self.lastOperation = .delete // ライブ変換がオフの場合は変換候補ウィンドウを出したい self.shouldShowCandidateWindow = !self.liveConversionEnabled self.updateRawCandidate() + guard Config.DebugTypoCorrection().value && self.hasDebugTypoCorrectionWeights() else { + self.backspaceAdjustedPredictionCandidate = nil + self.backspaceTypoCorrectionLock = nil + return + } + let currentInput = self.composingText.convertTarget + guard count == 1 else { + self.backspaceAdjustedPredictionCandidate = nil + self.backspaceTypoCorrectionLock = nil + return + } + if let lock = self.backspaceTypoCorrectionLock { + self.backspaceAdjustedPredictionCandidate = Self.makeBackspaceTypoCorrectionPredictionCandidate( + currentInput: currentInput, + targetReading: lock.targetReading, + displayText: lock.displayText + ) + if self.backspaceAdjustedPredictionCandidate == nil { + self.backspaceTypoCorrectionLock = nil + } + return + } + let currentBestCandidateText = self.rawCandidates?.mainResults.first?.text ?? self.rawCandidatesList?.first?.text + let shouldTriggerTypoCorrection = Self.shouldTriggerBackspaceTypoCorrection( + deleteCount: count, + currentBestCandidateText: currentBestCandidateText, + currentInput: currentInput + ) + guard shouldTriggerTypoCorrection else { + self.backspaceAdjustedPredictionCandidate = nil + self.backspaceTypoCorrectionLock = nil + return + } + self.backspaceTypoCorrectionLock = self.lmBasedBackspaceTypoCorrectionLock(previousComposingText: beforeComposingText) + if let lock = self.backspaceTypoCorrectionLock { + self.backspaceAdjustedPredictionCandidate = Self.makeBackspaceTypoCorrectionPredictionCandidate( + currentInput: currentInput, + targetReading: lock.targetReading, + displayText: lock.displayText + ) + } else { + self.backspaceAdjustedPredictionCandidate = nil + } } @MainActor @@ -399,6 +483,10 @@ public final class SegmentsManager { /// - Note: /// This function is executed on the `@MainActor` to ensure UI consistency. @MainActor private func updateRawCandidate(requestRichCandidates: Bool = false, forcedLeftSideContext: String? = nil) { + if self.lastOperation != .delete { + self.backspaceAdjustedPredictionCandidate = nil + self.backspaceTypoCorrectionLock = nil + } self.resetAdditionalCandidates() // 不要 if composingText.isEmpty { @@ -728,6 +816,7 @@ public final class SegmentsManager { suggestSelectionIndex = nil } + // swiftlint:disable:next cyclomatic_complexity public func requestPredictionCandidates() -> [PredictionCandidate] { guard Config.DebugPredictiveTyping().value else { return [] @@ -748,6 +837,10 @@ public final class SegmentsManager { } matchTarget = matchTarget.toHiragana() + if let backspaceAdjustedPredictionCandidate { + return [backspaceAdjustedPredictionCandidate] + } + guard let rawCandidates else { return [] } @@ -774,6 +867,112 @@ public final class SegmentsManager { return [] } + private func requestTypoCorrectionCandidates(composingText targetComposingText: ComposingText, inputStyle: InputStyle) -> [String] { + guard Config.DebugTypoCorrection().value && self.hasDebugTypoCorrectionWeights() else { + return [] + } + guard !targetComposingText.isEmpty else { + return [] + } + + let leftSideContext = self.getCleanLeftSideContext(maxCount: 30) ?? "" + let typoCandidates = self.kanaKanjiConverter.experimentalRequestTypoCorrectionOnly( + leftSideContext: leftSideContext, + composingText: targetComposingText, + options: options( + leftSideContext: leftSideContext, + requestRichCandidates: false, + requireJapanesePrediction: .disabled, + requireEnglishPrediction: .disabled + ), + inputStyle: inputStyle, + searchConfig: .init( + beamSize: 16, + topK: 32, + nBest: 3 + ) + ) + + var seen: Set = [] + return typoCandidates.compactMap { candidate in + let text = candidate.convertedText.toHiragana() + guard !text.isEmpty else { + return nil + } + guard seen.insert(text).inserted else { + return nil + } + return text + } + } + + private func convertedText(reading: String, leftSideContext: String?) -> String? { + var composingText = ComposingText() + composingText.insertAtCursorPosition(reading, inputStyle: .direct) + + let result = self.kanaKanjiConverter.requestCandidates( + composingText, + options: options( + leftSideContext: leftSideContext, + requestRichCandidates: false, + requireJapanesePrediction: .disabled, + requireEnglishPrediction: .disabled + ) + ) + return result.mainResults.first?.text + } + + @MainActor + private func lmBasedBackspaceTypoCorrectionLock(previousComposingText: ComposingText) -> BackspaceTypoCorrectionLock? { + let typoCorrectionCandidates = self.requestTypoCorrectionCandidates( + composingText: previousComposingText, + inputStyle: self.lastInputStyle + ) + guard let correctedReading = typoCorrectionCandidates.first else { + return nil + } + + let correctedDisplayText = self.convertedText( + reading: correctedReading, + leftSideContext: self.getCleanLeftSideContext(maxCount: 30) + ) ?? correctedReading + + return .init(displayText: correctedDisplayText, targetReading: correctedReading) + } + + static func makeBackspaceTypoCorrectionPredictionCandidate( + currentInput: String, + targetReading: String, + displayText: String + ) -> PredictionCandidate? { + let operation = Self.makeSuffixEditOperation(from: currentInput, to: targetReading) + ?? Self.makeSuffixEditOperation(from: currentInput.toHiragana(), to: targetReading) + guard let operation else { + return nil + } + return .init(displayText: displayText, appendText: operation.appendText, deleteCount: operation.deleteCount) + } + + static func shouldTriggerBackspaceTypoCorrection(deleteCount: Int, currentBestCandidateText: String?, currentInput: String) -> Bool { + guard deleteCount == 1 else { + return false + } + if let currentBestCandidateText, currentBestCandidateText == currentInput { + return false + } + return true + } + + private static func makeSuffixEditOperation(from currentText: String, to targetText: String) -> (appendText: String, deleteCount: Int)? { + let sharedPrefixLength = zip(currentText, targetText).prefix(while: ==).count + let deleteCount = currentText.count - sharedPrefixLength + let appendText = String(targetText.dropFirst(sharedPrefixLength)) + guard deleteCount > 0 || !appendText.isEmpty else { + return nil + } + return (appendText, deleteCount) + } + // swiftlint:disable:next cyclomatic_complexity public func getCurrentMarkedText(inputState: InputState) -> MarkedText { switch inputState { diff --git a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionBackspaceCorrectionTests.swift b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionBackspaceCorrectionTests.swift new file mode 100644 index 00000000..53629b58 --- /dev/null +++ b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerPredictionBackspaceCorrectionTests.swift @@ -0,0 +1,56 @@ +@testable import Core +import Testing + +@Test func testShouldTriggerBackspaceTypoCorrectionReturnsFalseWhenBestCandidateMatchesCurrentInput() async throws { + let shouldTrigger = SegmentsManager.shouldTriggerBackspaceTypoCorrection( + deleteCount: 1, + currentBestCandidateText: "くだし", + currentInput: "くだし" + ) + + #expect(shouldTrigger == false) +} + +@Test func testShouldTriggerBackspaceTypoCorrectionReturnsTrueWhenBestCandidateDiffersFromCurrentInput() async throws { + let shouldTrigger = SegmentsManager.shouldTriggerBackspaceTypoCorrection( + deleteCount: 1, + currentBestCandidateText: "下さい", + currentInput: "くだし" + ) + + #expect(shouldTrigger == true) +} + +@Test func testShouldTriggerBackspaceTypoCorrectionReturnsFalseWhenDeleteCountIsNotOne() async throws { + let shouldTrigger = SegmentsManager.shouldTriggerBackspaceTypoCorrection( + deleteCount: 2, + currentBestCandidateText: "下さい", + currentInput: "くだし" + ) + + #expect(shouldTrigger == false) +} + +@Test func testMakeBackspaceTypoCorrectionPredictionCandidateRecalculatesEditOperationForCurrentInput() async throws { + let candidate = SegmentsManager.makeBackspaceTypoCorrectionPredictionCandidate( + currentInput: "くだし", + targetReading: "ください", + displayText: "下さい" + ) + + #expect(candidate?.displayText == "下さい") + #expect(candidate?.appendText == "さい") + #expect(candidate?.deleteCount == 1) +} + +@Test func testMakeBackspaceTypoCorrectionPredictionCandidateKeepsDisplayTextAndUpdatesAppendTextOnFurtherDelete() async throws { + let candidate = SegmentsManager.makeBackspaceTypoCorrectionPredictionCandidate( + currentInput: "くだ", + targetReading: "ください", + displayText: "下さい" + ) + + #expect(candidate?.displayText == "下さい") + #expect(candidate?.appendText == "さい") + #expect(candidate?.deleteCount == 0) +} diff --git a/azooKeyMac/InputController/azooKeyMacInputController.swift b/azooKeyMac/InputController/azooKeyMacInputController.swift index 63a2f18b..7edb3e1e 100644 --- a/azooKeyMac/InputController/azooKeyMacInputController.swift +++ b/azooKeyMac/InputController/azooKeyMacInputController.swift @@ -110,7 +110,7 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s .appendingPathComponent("memory", isDirectory: true) } - let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.dev.ensan.inputmethod.azooKeyMac") + let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppGroup.azooKeyMacIdentifier) self.segmentsManager = SegmentsManager( kanaKanjiConverter: (NSApplication.shared.delegate as? AppDelegate)!.kanaKanjiConverter, applicationDirectoryURL: applicationDirectoryURL, @@ -713,19 +713,10 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s guard let prediction = predictions.first else { return } - - let currentTarget = self.segmentsManager.convertTarget - var matchTarget = currentTarget - if let last = matchTarget.last, - last.unicodeScalars.allSatisfy({ $0.isASCII && CharacterSet.letters.contains($0) }) { - matchTarget.removeLast() - self.segmentsManager.deleteBackwardFromCursorPosition(count: 1) + let deleteCount = prediction.deleteCount + if deleteCount > 0 { + self.segmentsManager.deleteBackwardFromCursorPosition(count: deleteCount) } - - guard !matchTarget.isEmpty else { - return - } - let appendText = prediction.appendText guard !appendText.isEmpty else { diff --git a/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift b/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift index ae866db8..c9346d83 100644 --- a/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift +++ b/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift @@ -109,6 +109,8 @@ extension azooKeyMacInputController { do { self.segmentsManager.appendDebugMessage("\(#line): Applicatiion Support Directory Path: \(self.segmentsManager.azooKeyMemoryDir)") try FileManager.default.createDirectory(at: self.segmentsManager.azooKeyMemoryDir, withIntermediateDirectories: true) + self.segmentsManager.appendDebugMessage("\(#line): Debug TypoCorrection Download Directory Path: \(self.segmentsManager.downloadedInputN5LMDir)") + try FileManager.default.createDirectory(at: self.segmentsManager.downloadedInputN5LMDir, withIntermediateDirectories: true) } catch { self.segmentsManager.appendDebugMessage("\(#line): \(error.localizedDescription)") } diff --git a/azooKeyMac/Windows/ConfigWindow.swift b/azooKeyMac/Windows/ConfigWindow.swift index da9276d0..16d52c7a 100644 --- a/azooKeyMac/Windows/ConfigWindow.swift +++ b/azooKeyMac/Windows/ConfigWindow.swift @@ -17,6 +17,7 @@ struct ConfigWindow: View { @ConfigState private var inferenceLimit = Config.ZenzaiInferenceLimit() @ConfigState private var debugWindow = Config.DebugWindow() @ConfigState private var debugPredictiveTyping = Config.DebugPredictiveTyping() + @ConfigState private var debugTypoCorrection = Config.DebugTypoCorrection() @ConfigState private var userDictionary = Config.UserDictionary() @ConfigState private var systemUserDictionary = Config.SystemUserDictionary() @ConfigState private var keyboardLayout = Config.KeyboardLayout() @@ -34,6 +35,9 @@ struct ConfigWindow: View { @State private var learningResetMessage: LearningResetMessage? @State private var foundationModelsAvailability: FoundationModelsAvailability? @State private var availabilityCheckDone = false + @State private var debugTypoCorrectionState: DebugTypoCorrectionState = .notDownloaded + @State private var debugTypoCorrectionDownloadInProgress = false + @State private var debugTypoCorrectionErrorMessage: String? private enum Tab: String, CaseIterable, Hashable { case basic = "基本" @@ -59,6 +63,90 @@ struct ConfigWindow: View { case successfulUpdate } + private var azooKeyApplicationSupportDirectoryURL: URL { + if #available(macOS 13, *) { + URL.applicationSupportDirectory + .appending(path: "azooKey", directoryHint: .isDirectory) + } else { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + .appendingPathComponent("azooKey", isDirectory: true) + } + } + + private var debugTypoCorrectionModelDirectoryURL: URL { + DebugTypoCorrectionWeights.modelDirectoryURL( + azooKeyApplicationSupportDirectoryURL: self.azooKeyApplicationSupportDirectoryURL + ) + } + + private var debugTypoCorrectionStatusText: String { + if self.debugTypoCorrectionDownloadInProgress { + return "ダウンロード中..." + } + switch self.debugTypoCorrectionState { + case .downloaded: + return "重み: ダウンロード済み" + case .failed: + return "重み: ダウンロード失敗" + case .notDownloaded: + return "重み: ダウンロード未実施" + } + } + + @MainActor + private func refreshDebugTypoCorrectionState() async { + let modelDirectoryURL = self.debugTypoCorrectionModelDirectoryURL + let state = await Task.detached(priority: .utility) { + DebugTypoCorrectionWeights.state(modelDirectoryURL: modelDirectoryURL) + }.value + self.debugTypoCorrectionState = state + if state != .failed { + self.debugTypoCorrectionErrorMessage = nil + } + } + + @MainActor + private func downloadDebugTypoCorrectionWeights() { + guard !self.debugTypoCorrectionDownloadInProgress else { + return + } + self.debugTypoCorrectionDownloadInProgress = true + self.debugTypoCorrectionErrorMessage = nil + + let modelDirectoryURL = self.debugTypoCorrectionModelDirectoryURL + Task { + do { + try await DebugTypoCorrectionWeights.downloadWeights(modelDirectoryURL: modelDirectoryURL) + let state = await Task.detached(priority: .utility) { + DebugTypoCorrectionWeights.state(modelDirectoryURL: modelDirectoryURL) + }.value + await MainActor.run { + self.debugTypoCorrectionState = state + self.debugTypoCorrectionErrorMessage = state == .failed ? "ダウンロード後の整合性チェックに失敗しました" : nil + self.debugTypoCorrectionDownloadInProgress = false + } + } catch { + await MainActor.run { + self.debugTypoCorrectionState = .failed + self.debugTypoCorrectionErrorMessage = error.localizedDescription + self.debugTypoCorrectionDownloadInProgress = false + } + } + } + } + + private func openAzooKeyDataDirectoryInFinder() { + do { + try FileManager.default.createDirectory( + at: self.azooKeyApplicationSupportDirectoryURL, + withIntermediateDirectories: true + ) + NSWorkspace.shared.activateFileViewerSelecting([self.azooKeyApplicationSupportDirectoryURL]) + } catch { + // no-op + } + } + private func getErrorMessage(for error: OpenAIError) -> String { switch error { case .invalidURL: @@ -513,12 +601,74 @@ struct ConfigWindow: View { Section { Toggle("デバッグウィンドウを有効化", isOn: $debugWindow) Toggle("開発中の予測入力を有効化", isOn: $debugPredictiveTyping) + VStack(alignment: .leading, spacing: 6) { + Toggle("開発中の入力訂正を有効化", isOn: $debugTypoCorrection) + if self.debugTypoCorrection.value { + HStack { + Text(self.debugTypoCorrectionStatusText) + .font(.caption) + .foregroundStyle(.secondary) + if self.debugTypoCorrectionDownloadInProgress { + ProgressView() + .controlSize(.small) + } + Spacer() + switch self.debugTypoCorrectionState { + case .downloaded: + EmptyView() + case .failed: + Button("再ダウンロード") { + self.downloadDebugTypoCorrectionWeights() + } + .disabled(self.debugTypoCorrectionDownloadInProgress) + case .notDownloaded: + Button("ダウンロード") { + self.downloadDebugTypoCorrectionWeights() + } + .disabled(self.debugTypoCorrectionDownloadInProgress) + } + } + if case .failed = self.debugTypoCorrectionState, + let errorMessage = self.debugTypoCorrectionErrorMessage { + Text(errorMessage) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .onAppear { + guard self.debugTypoCorrection.value else { + return + } + Task { @MainActor in + await self.refreshDebugTypoCorrectionState() + } + } + .onChange(of: self.debugTypoCorrection.value) { enabled in + if enabled { + Task { @MainActor in + await self.refreshDebugTypoCorrectionState() + } + } else { + self.debugTypoCorrectionState = .notDownloaded + self.debugTypoCorrectionDownloadInProgress = false + self.debugTypoCorrectionErrorMessage = nil + } + } Picker("パーソナライズ", selection: $zenzaiPersonalizationLevel) { Text("オフ").tag(Config.ZenzaiPersonalizationLevel.Value.off) Text("弱く").tag(Config.ZenzaiPersonalizationLevel.Value.soft) Text("普通").tag(Config.ZenzaiPersonalizationLevel.Value.normal) Text("強く").tag(Config.ZenzaiPersonalizationLevel.Value.hard) } + LabeledContent("アプリデータ") { + HStack { + Button("Finderで開く") { + self.openAzooKeyDataDirectoryInFinder() + } + } + } } header: { Label("開発者向け設定", systemImage: "hammer") }