diff --git a/docs/wiki/architecture.md b/docs/wiki/architecture.md index a74646f..d5c18f3 100644 --- a/docs/wiki/architecture.md +++ b/docs/wiki/architecture.md @@ -22,8 +22,10 @@ AppDelegate - `DocumentLoading*.swift`: EPUB/DOCX archive handling, HTML generation, shared document helpers. - `ProcessRunner.swift`: bounded external process execution for archive helpers and other command-line runtimes. - `AIChatPanel*.swift`: AI chat UI, request lifecycle, bubble layout, selection handling. -- `AISettingsPanelController*.swift`: settings window, model configuration, AI analysis cache controls, and TTS runtime download controls. +- `AISettingsPanelController*.swift`: settings window, with focused builders for each page and separate speech selection/download extensions. - `SpeechPlaybackCoordinator.swift`, `SpeechRuntimeResourceManager.swift`, and `RuntimeDownload.swift`: local TTS playback, runtime selection, compatibility, and model downloads. +- `SQLiteTransactionExecutor.swift`: shared checked transaction boundary used by SQLite-backed stores. +- `Resources/reader-web*.js`: focused WebKit reader modules for text, marks, search, TTS ranges, selection events, and bridge installation. - `RecentDocuments*.swift` and `RecentBookCardView.swift`: bookshelf panel and recent document UI. - `WordRecordSQLiteStore.swift` and related stores: persistent word and conversation data. @@ -41,4 +43,5 @@ Large controllers are split by behavior into extensions or focused helper views. - `mac-app/AIChatPanel.swift` - `mac-app/SpeechPlaybackCoordinator.swift` - `mac-app/SpeechRuntimeResourceManager.swift` +- `mac-app/SQLiteTransactionExecutor.swift` - `mac-app/WordRecordSQLiteStore.swift` diff --git a/docs/wiki/code-map.md b/docs/wiki/code-map.md index 502d723..59bd6e2 100644 --- a/docs/wiki/code-map.md +++ b/docs/wiki/code-map.md @@ -4,9 +4,9 @@ Generated by `./scripts/generate_code_wiki.sh`. ## Summary -- Code files: 422 -- Main code lines: 56716 -- Swift app lines: 46761 +- Code files: 428 +- Main code lines: 56992 +- Swift app lines: 46957 - Full Swift type index: [Type Index](type-index.md) ## Largest Files @@ -14,10 +14,7 @@ Generated by `./scripts/generate_code_wiki.sh`. | File | Lines | | --- | ---: | | `tests/VocabularyLogicTests.swift` | 500 | -| `mac-app/Resources/reader-web.js` | 498 | -| `mac-app/PersonalVocabularyProfileStore.swift` | 491 | -| `mac-app/AISettingsPanelController+Build.swift` | 489 | -| `mac-app/AISettingsPanelController+Speech.swift` | 487 | +| `mac-app/PersonalVocabularyProfileStore.swift` | 479 | | `mac-app/KokoroTTSBackend.swift` | 476 | | `tests/SpeechRuntimeDownloadTests.swift` | 461 | | `mac-app/ECDICTDictionary.swift` | 440 | @@ -28,10 +25,13 @@ Generated by `./scripts/generate_code_wiki.sh`. | `tests/ReaderCoreLogicTests.swift` | 407 | | `mac-app/ReaderWindowController+Input.swift` | 400 | | `tests/ReadingNoteMarkdownLogicTests.swift` | 390 | -| `mac-app/WordRecordSQLiteStore.swift` | 390 | | `tests/SpeechRuntimeAvailabilityTests.swift` | 385 | | `mac-app/DocumentLoading+EPUB.swift` | 385 | | `mac-app/SelectionActionToolbar.swift` | 376 | +| `tests/AISettingsLogicTests.swift` | 373 | +| `scripts/build_app.sh` | 373 | +| `mac-app/ReadingNotesPanelController.swift` | 361 | +| `mac-app/AISettingsStore.swift` | 360 | ## Reader Window Modules diff --git a/docs/wiki/tts.md b/docs/wiki/tts.md index 28c2514..1d8d5da 100644 --- a/docs/wiki/tts.md +++ b/docs/wiki/tts.md @@ -47,8 +47,10 @@ ReaderWindowController+ReadAloud - `SpeechTextPolicy.swift`:TTS 文本归一化、英文候选判断和朗读分段。 - `SpeechRuntimeResourceManager.swift`:运行时安装检测、下载 URL、模型大小、兼容性、暂停/恢复/取消和清理。 - `RuntimeDownload.swift`:URLSession 下载、进度、断点数据和 HTTP 错误处理。 -- `AISettingsPanelController+Speech.swift`:设置页里选择、下载、暂停、取消、删除和兼容性警告。 -- `AISettingsPanelController+Build.swift`:朗读设置行、模型选择器、状态标签、按钮和进度条。 +- `AISettingsPanelController+Speech.swift`:设置页里的运行时选择、语言策略、声音选择和试听。 +- `AISettingsPanelController+SpeechDownloads.swift`:运行时状态、下载、暂停、取消、删除、兼容性警告和诊断。 +- `AISettingsPanelController+BuildSpeech.swift`:朗读设置行、模型选择器、状态标签、按钮和进度条。 +- `Resources/reader-web-tts.js`:WebKit 内容的朗读分段、批次推进和当前范围下划线。 - `ReaderWindowController+ReadAloud.swift`:PDF 和 WebKit 内容的文档级朗读入口。 - `ReaderWindowController+ReadAloudProgress.swift`:朗读时的当前段落下划线和高亮更新。 - `AIChatPanel+Actions.swift`:AI 文本发音,优先本地 TTS,必要时系统语音回退。 diff --git a/docs/wiki/type-index.md b/docs/wiki/type-index.md index 1fe7bb5..01ff8ed 100644 --- a/docs/wiki/type-index.md +++ b/docs/wiki/type-index.md @@ -55,6 +55,13 @@ Generated by `./scripts/generate_code_wiki.sh`. | `mac-app/AISettingsPanelController+BuildEmbedding.swift` | 33 | `struct AISettingsEmbeddingConstraints {` | | `mac-app/AISettingsPanelController+BuildEmbedding.swift` | 39 | `extension AISettingsPanelController {` | | `mac-app/AISettingsPanelController+BuildEmbedding.swift` | 3 | `struct AISettingsEmbeddingSection {` | +| `mac-app/AISettingsPanelController+BuildGeneral.swift` | 26 | `struct AISettingsGeneralSection {` | +| `mac-app/AISettingsPanelController+BuildGeneral.swift` | 3 | `struct AISettingsPanelPalette {` | +| `mac-app/AISettingsPanelController+BuildGeneral.swift` | 51 | `struct AISettingsGeneralLayout {` | +| `mac-app/AISettingsPanelController+BuildGeneral.swift` | 59 | `extension AISettingsPanelController {` | +| `mac-app/AISettingsPanelController+BuildModel.swift` | 22 | `struct AISettingsModelLayout {` | +| `mac-app/AISettingsPanelController+BuildModel.swift` | 32 | `extension AISettingsPanelController {` | +| `mac-app/AISettingsPanelController+BuildModel.swift` | 3 | `struct AISettingsModelSection {` | | `mac-app/AISettingsPanelController+BuildSpeech.swift` | 28 | `struct AISettingsSpeechSection {` | | `mac-app/AISettingsPanelController+BuildSpeech.swift` | 3 | `struct SpeechRuntimeRowControls {` | | `mac-app/AISettingsPanelController+BuildSpeech.swift` | 46 | `extension AISettingsPanelController {` | @@ -65,6 +72,7 @@ Generated by `./scripts/generate_code_wiki.sh`. | `mac-app/AISettingsPanelController+ModelEmbedding.swift` | 3 | `extension AISettingsPanelController {` | | `mac-app/AISettingsPanelController+Panel.swift` | 3 | `extension AISettingsPanelController {` | | `mac-app/AISettingsPanelController+Speech.swift` | 3 | `extension AISettingsPanelController {` | +| `mac-app/AISettingsPanelController+SpeechDownloads.swift` | 3 | `extension AISettingsPanelController {` | | `mac-app/AISettingsPanelController+Theme.swift` | 3 | `extension AISettingsPanelController {` | | `mac-app/AISettingsPanelController+UIFactory.swift` | 3 | `extension AISettingsPanelController {` | | `mac-app/AISettingsPanelController.swift` | 3 | `final class AISettingsPanelController {` | @@ -395,6 +403,7 @@ Generated by `./scripts/generate_code_wiki.sh`. | `mac-app/RecentDocumentsStore.swift` | 3 | `struct RecentDocumentItem` | | `mac-app/RequestAvailabilityPolicy.swift` | 3 | `enum RequestAvailabilityPolicy {` | | `mac-app/SQLiteSchemaMigrator.swift` | 4 | `enum SQLiteSchemaMigrator {` | +| `mac-app/SQLiteTransactionExecutor.swift` | 3 | `struct SQLiteTransactionExecutor {` | | `mac-app/SearchOverlayView.swift` | 3 | `final class SearchOverlayView` | | `mac-app/SelectionActionToolbar.swift` | 306 | `final class SelectionActionButton` | | `mac-app/SelectionActionToolbar.swift` | 3 | `final class SelectionActionToolbar` | diff --git a/mac-app/AISettingsPanelController+Build.swift b/mac-app/AISettingsPanelController+Build.swift index 82fa926..2dc962f 100644 --- a/mac-app/AISettingsPanelController+Build.swift +++ b/mac-app/AISettingsPanelController+Build.swift @@ -14,23 +14,10 @@ extension AISettingsPanelController { let settingsFontSize: CGFloat = 14 let theme = ReaderTheme.selected let isDark = theme == .dark - let panelBackground: NSColor - let primaryText: NSColor - let secondaryText: NSColor - switch theme { - case .original: - panelBackground = .white - primaryText = NSColor(red: 0.12, green: 0.13, blue: 0.16, alpha: 1) - secondaryText = NSColor(red: 0.47, green: 0.50, blue: 0.58, alpha: 1) - case .eyeCare: - panelBackground = NSColor(red: 0.91, green: 0.87, blue: 0.74, alpha: 1) - primaryText = NSColor(red: 0.15, green: 0.13, blue: 0.09, alpha: 1) - secondaryText = NSColor(red: 0.45, green: 0.39, blue: 0.27, alpha: 1) - case .dark: - panelBackground = NSColor(red: 0.10, green: 0.12, blue: 0.15, alpha: 1) - primaryText = NSColor(red: 0.86, green: 0.88, blue: 0.92, alpha: 1) - secondaryText = NSColor(red: 0.58, green: 0.63, blue: 0.70, alpha: 1) - } + let palette = AISettingsPanelPalette(theme: theme) + let panelBackground = palette.background + let primaryText = palette.primaryText + let secondaryText = palette.secondaryText let formBackground = settingsFormBackgroundColor(for: theme) let layout = AISettingsLayoutMetrics() @@ -105,55 +92,18 @@ extension AISettingsPanelController { speechPage.isHidden = true cachePage.isHidden = true - let modelLabel = label(AppText.model, size: settingsFontSize, weight: .semibold, color: primaryText) - let modelHelpLabel = label(AppText.modelHelp, size: settingsFontSize, color: secondaryText) - modelHelpLabel.isHidden = true - let modelPopup = popup( - items: AISettingsStore.models.map { ($0.displayName, $0.id) }, - selected: selectedModel.id, - fontSize: settingsFontSize + let generalSection = makeGeneralSection( + settingsFontSize: settingsFontSize, + primaryText: primaryText, + secondaryText: secondaryText, + theme: theme + ) + let modelSection = makeModelSection( + selectedModel: selectedModel, + settingsFontSize: settingsFontSize, + primaryText: primaryText, + secondaryText: secondaryText ) - - let customEndpointLabel = label(AppText.localized("自定义 / Azure URL", "Custom / Azure URL"), size: settingsFontSize, weight: .semibold, color: primaryText) - let customEndpointField = inputField(AISettingsStore.customEndpointString, placeholder: "https://resource.openai.azure.com/openai/deployments/deployment/chat/completions?api-version=2024-10-21", fontSize: settingsFontSize, textColor: primaryText, backgroundColor: fieldBackground()) - let customModelLabel = label(AppText.localized("模型 ID / Azure 部署名", "Model ID / Azure Deployment"), size: settingsFontSize, weight: .semibold, color: primaryText) - let initialModelFieldValue: String - if selectedModel.id == AISettingsStore.ollamaModelID { - initialModelFieldValue = AISettingsStore.ollamaModelName - } else if selectedModel.id == AISettingsStore.localOpenAIModelID { - initialModelFieldValue = AISettingsStore.localOpenAIModelName - } else { - initialModelFieldValue = AISettingsStore.customModelName - } - let customModelField = inputField(initialModelFieldValue, placeholder: "gpt-4o-mini", fontSize: settingsFontSize, textColor: primaryText, backgroundColor: fieldBackground()) - let customModelContainer = settingsCard() - - let keyLabel = label("API Key", size: settingsFontSize, weight: .semibold, color: primaryText) - let keyHelpLabel = label(AppText.keyHelp, size: settingsFontSize, color: secondaryText) - keyHelpLabel.isHidden = true - let keyField = APIKeySecureTextField(string: AISettingsStore.apiKey(for: selectedModel)) - configureKeyField(keyField, placeholder: AppText.apiKeyPlaceholder, fontSize: settingsFontSize, textColor: primaryText, backgroundColor: fieldBackground()) - - let languageLabel = label(AppText.language, size: settingsFontSize, weight: .semibold, color: primaryText) - let languageHelpLabel = label(AppText.languageHelp, size: settingsFontSize, color: secondaryText) - languageHelpLabel.isHidden = true - let languagePopup = popup(items: AppText.Language.allCases.map { ($0.title, $0.rawValue) }, selected: AppText.selectedLanguage.rawValue, fontSize: settingsFontSize) - - let themeLabel = label(AppText.localized("模式", "Mode"), size: settingsFontSize, weight: .semibold, color: primaryText) - let themeHelpLabel = label(ReaderTheme.selected.helpText, size: settingsFontSize, color: secondaryText) - themeHelpLabel.isHidden = true - let themePopup = popup(items: ReaderTheme.allCases.map { ($0.title, $0.rawValue) }, selected: ReaderTheme.selected.rawValue, fontSize: settingsFontSize) - let pdfDimmingLabel = label(AppText.localized("阅读区亮度", "Reading Area Brightness"), size: settingsFontSize, weight: .semibold, color: primaryText) - let pdfDimmingSlider = ThemedSettingsSlider(value: pdfBrightnessSliderValue(forDimmingStrength: ReaderTheme.pdfDimmingStrength), minValue: 0, maxValue: Self.pdfBrightnessSliderMaximum) - pdfDimmingSlider.theme = theme - pdfDimmingSlider.numberOfTickMarks = 7 - pdfDimmingSlider.target = self - pdfDimmingSlider.action = #selector(pdfDimmingSliderChanged(_:)) - pdfDimmingSlider.translatesAutoresizingMaskIntoConstraints = false - let speakSelectedWordLabel = label(AppText.localized("自动播放单词", "Auto Play Words"), size: settingsFontSize, weight: .semibold, color: primaryText) - let speakSelectedWordCheckbox = settingsCheckbox(isOn: AISettingsStore.speakSelectedWordEnabled, theme: theme, fontSize: settingsFontSize) - let saveAIConversationLabel = label(AppText.localized("保存 AI 对话信息", "Save AI Chat"), size: settingsFontSize, weight: .semibold, color: primaryText) - let saveAIConversationCheckbox = settingsCheckbox(isOn: AISettingsStore.saveAIConversationEnabled, theme: theme, fontSize: settingsFontSize) let embeddingSection = makeEmbeddingSection( selectedEndpoint: selectedEmbeddingEndpoint, @@ -177,9 +127,6 @@ extension AISettingsPanelController { let speechVoicePopup = speechSection.voicePopup let speechSpeedPopup = speechSection.speedPopup - let testChatButton = settingsActionButton(title: AppText.localized("测试模型连接", "Test Chat"), target: self, action: #selector(testChatConnection(_:))) - testChatButton.font = AppFont.semibold(ofSize: settingsFontSize) - let cacheSection = makeCacheSection( settingsFontSize: settingsFontSize, primaryText: primaryText, @@ -192,25 +139,13 @@ extension AISettingsPanelController { let saveButton = settingsActionButton(title: AppText.confirm, target: self, action: #selector(save(_:)), isPrimary: true) saveButton.keyEquivalent = "\r" - modelPopup.target = self - modelPopup.action = #selector(modelChanged(_:)) - themePopup.target = self - themePopup.action = #selector(themeChanged(_:)) - modelPopup.identifier = Identifiers.modelPopup - languagePopup.identifier = Identifiers.languagePopup - themePopup.identifier = Identifiers.themePopup - keyField.identifier = Identifiers.keyField - for view in [titleIcon, titleLabel, closeButton, sidebarControl, scrollView, cancelButton, saveButton] { content.addSubview(view) } - for view in [customEndpointLabel, customEndpointField, customModelLabel, customModelField] { - customModelContainer.addSubview(view) - } - for view in [languageLabel, languagePopup, languageHelpLabel, themeLabel, themePopup, themeHelpLabel, pdfDimmingLabel, pdfDimmingSlider, speakSelectedWordLabel, speakSelectedWordCheckbox, saveAIConversationLabel, saveAIConversationCheckbox] { + for view in generalSection.pageViews { basicPage.addSubview(view) } - for view in [modelLabel, modelPopup, modelHelpLabel, customModelContainer, keyLabel, keyField, keyHelpLabel, testChatButton] { + for view in modelSection.pageViews { modelPage.addSubview(view) } for view in embeddingSection.pageViews { @@ -223,14 +158,6 @@ extension AISettingsPanelController { cachePage.addSubview(view) } - let keyTopWithCustom = keyLabel.topAnchor.constraint(equalTo: customModelContainer.bottomAnchor, constant: 22) - let keyTopWithoutCustom = keyLabel.topAnchor.constraint(equalTo: modelPopup.bottomAnchor, constant: 34) - let customModelContainerHeight = customModelContainer.heightAnchor.constraint(equalToConstant: 116) - let customModelTopToEndpoint = customModelLabel.topAnchor.constraint(equalTo: customEndpointLabel.bottomAnchor, constant: 22) - let customModelTopToContainer = customModelLabel.topAnchor.constraint(equalTo: customModelContainer.topAnchor, constant: 14) - let customModelCenterYToContainer = customModelLabel.centerYAnchor.constraint(equalTo: customModelContainer.centerYAnchor) - customModelTopToContainer.isActive = false - customModelCenterYToContainer.isActive = false let labelColumnWidth = layout.labelColumnWidth let fieldWidth = layout.fieldWidth let formWidth = layout.formWidth @@ -246,20 +173,29 @@ extension AISettingsPanelController { ) let embeddingModelTopWithCustomEndpoint = embeddingLayout.modelTopWithCustomEndpoint let embeddingModelTopWithoutCustomEndpoint = embeddingLayout.modelTopWithoutCustomEndpoint - keyTopWithCustomConstraint = keyTopWithCustom - keyTopWithoutCustomConstraint = keyTopWithoutCustom + let generalLayout = generalConstraints( + for: generalSection, + page: basicPage, + labelColumnWidth: labelColumnWidth, + fieldWidth: fieldWidth, + controlHeight: controlHeight + ) + let modelLayout = modelConstraints( + for: modelSection, + page: modelPage, + labelColumnWidth: labelColumnWidth, + fieldWidth: fieldWidth, + inputHeight: inputHeight, + controlHeight: controlHeight + ) + keyTopWithCustomConstraint = modelLayout.keyTopWithCustom + keyTopWithoutCustomConstraint = modelLayout.keyTopWithoutCustom embeddingModelTopWithCustomEndpointConstraint = embeddingModelTopWithCustomEndpoint embeddingModelTopWithoutCustomEndpointConstraint = embeddingModelTopWithoutCustomEndpoint - let pdfDimmingLabelTopConstraint = pdfDimmingLabel.topAnchor.constraint(equalTo: themePopup.bottomAnchor, constant: 22) - let speakSelectedWordTopToDimmingConstraint = speakSelectedWordLabel.topAnchor.constraint(equalTo: pdfDimmingSlider.bottomAnchor, constant: 22) - let speakSelectedWordTopToThemeConstraint = speakSelectedWordLabel.topAnchor.constraint(equalTo: themePopup.bottomAnchor, constant: 22) - let pdfDimmingCollapsedConstraints = [ - pdfDimmingLabel.heightAnchor.constraint(equalToConstant: 0), - pdfDimmingSlider.heightAnchor.constraint(equalToConstant: 0) - ] - NSLayoutConstraint.activate(embeddingLayout.constraints) + NSLayoutConstraint.activate(generalLayout.constraints) + NSLayoutConstraint.activate(modelLayout.constraints) NSLayoutConstraint.activate(speechConstraints( for: speechSection, page: speechPage, @@ -321,96 +257,6 @@ extension AISettingsPanelController { cachePage.trailingAnchor.constraint(equalTo: formContent.trailingAnchor), cachePage.bottomAnchor.constraint(equalTo: formContent.bottomAnchor), - languageLabel.topAnchor.constraint(equalTo: basicPage.topAnchor, constant: 4), - languageLabel.leadingAnchor.constraint(equalTo: basicPage.leadingAnchor), - languageLabel.widthAnchor.constraint(equalToConstant: labelColumnWidth), - languagePopup.topAnchor.constraint(equalTo: languageLabel.topAnchor), - languagePopup.leadingAnchor.constraint(equalTo: basicPage.leadingAnchor, constant: labelColumnWidth), - languagePopup.widthAnchor.constraint(equalToConstant: fieldWidth), - languagePopup.heightAnchor.constraint(equalToConstant: controlHeight), - languageHelpLabel.topAnchor.constraint(equalTo: languagePopup.bottomAnchor, constant: 4), - languageHelpLabel.leadingAnchor.constraint(equalTo: languagePopup.leadingAnchor), - languageHelpLabel.widthAnchor.constraint(equalToConstant: fieldWidth), - - themeLabel.topAnchor.constraint(equalTo: languagePopup.bottomAnchor, constant: 34), - themeLabel.leadingAnchor.constraint(equalTo: basicPage.leadingAnchor), - themeLabel.widthAnchor.constraint(equalToConstant: labelColumnWidth), - themePopup.topAnchor.constraint(equalTo: themeLabel.topAnchor), - themePopup.leadingAnchor.constraint(equalTo: basicPage.leadingAnchor, constant: labelColumnWidth), - themePopup.widthAnchor.constraint(equalToConstant: fieldWidth), - themePopup.heightAnchor.constraint(equalToConstant: controlHeight), - themeHelpLabel.topAnchor.constraint(equalTo: themePopup.bottomAnchor, constant: 4), - themeHelpLabel.leadingAnchor.constraint(equalTo: themePopup.leadingAnchor), - themeHelpLabel.widthAnchor.constraint(equalToConstant: fieldWidth), - - pdfDimmingLabelTopConstraint, - pdfDimmingLabel.leadingAnchor.constraint(equalTo: basicPage.leadingAnchor), - pdfDimmingLabel.widthAnchor.constraint(equalToConstant: labelColumnWidth), - pdfDimmingSlider.centerYAnchor.constraint(equalTo: pdfDimmingLabel.centerYAnchor), - pdfDimmingSlider.leadingAnchor.constraint(equalTo: basicPage.leadingAnchor, constant: labelColumnWidth), - pdfDimmingSlider.widthAnchor.constraint(equalToConstant: fieldWidth), - - speakSelectedWordTopToDimmingConstraint, - speakSelectedWordLabel.leadingAnchor.constraint(equalTo: basicPage.leadingAnchor), - speakSelectedWordLabel.widthAnchor.constraint(equalToConstant: labelColumnWidth), - speakSelectedWordCheckbox.centerYAnchor.constraint(equalTo: speakSelectedWordLabel.centerYAnchor), - speakSelectedWordCheckbox.leadingAnchor.constraint(equalTo: basicPage.leadingAnchor, constant: labelColumnWidth), - speakSelectedWordCheckbox.widthAnchor.constraint(equalToConstant: 32), - saveAIConversationLabel.topAnchor.constraint(equalTo: speakSelectedWordLabel.bottomAnchor, constant: 22), - saveAIConversationLabel.leadingAnchor.constraint(equalTo: basicPage.leadingAnchor), - saveAIConversationLabel.widthAnchor.constraint(equalToConstant: labelColumnWidth), - saveAIConversationCheckbox.centerYAnchor.constraint(equalTo: saveAIConversationLabel.centerYAnchor), - saveAIConversationCheckbox.leadingAnchor.constraint(equalTo: basicPage.leadingAnchor, constant: labelColumnWidth), - saveAIConversationCheckbox.widthAnchor.constraint(equalToConstant: 32), - saveAIConversationCheckbox.bottomAnchor.constraint(lessThanOrEqualTo: basicPage.bottomAnchor, constant: -8), - - modelLabel.topAnchor.constraint(equalTo: modelPage.topAnchor, constant: 4), - modelLabel.leadingAnchor.constraint(equalTo: modelPage.leadingAnchor), - modelLabel.widthAnchor.constraint(equalToConstant: labelColumnWidth), - modelPopup.topAnchor.constraint(equalTo: modelPage.topAnchor, constant: 4), - modelPopup.leadingAnchor.constraint(equalTo: modelPage.leadingAnchor, constant: labelColumnWidth), - modelPopup.widthAnchor.constraint(equalToConstant: fieldWidth), - modelPopup.heightAnchor.constraint(equalToConstant: controlHeight), - modelHelpLabel.topAnchor.constraint(equalTo: modelPopup.bottomAnchor, constant: 4), - modelHelpLabel.leadingAnchor.constraint(equalTo: modelPopup.leadingAnchor), - modelHelpLabel.widthAnchor.constraint(equalToConstant: fieldWidth), - - customModelContainer.topAnchor.constraint(equalTo: modelPopup.bottomAnchor, constant: 14), - customModelContainer.leadingAnchor.constraint(equalTo: modelPopup.leadingAnchor), - customModelContainer.widthAnchor.constraint(equalToConstant: fieldWidth), - customModelContainerHeight, - customEndpointLabel.topAnchor.constraint(equalTo: customModelContainer.topAnchor, constant: 14), - customEndpointLabel.leadingAnchor.constraint(equalTo: customModelContainer.leadingAnchor, constant: 14), - customEndpointLabel.widthAnchor.constraint(equalToConstant: 180), - customEndpointField.centerYAnchor.constraint(equalTo: customEndpointLabel.centerYAnchor), - customEndpointField.leadingAnchor.constraint(equalTo: customModelContainer.leadingAnchor, constant: 204), - customEndpointField.trailingAnchor.constraint(equalTo: customModelContainer.trailingAnchor, constant: -14), - customEndpointField.heightAnchor.constraint(equalToConstant: inputHeight), - customModelTopToEndpoint, - customModelLabel.leadingAnchor.constraint(equalTo: customEndpointLabel.leadingAnchor), - customModelLabel.widthAnchor.constraint(equalToConstant: 180), - customModelField.centerYAnchor.constraint(equalTo: customModelLabel.centerYAnchor), - customModelField.leadingAnchor.constraint(equalTo: customEndpointField.leadingAnchor), - customModelField.trailingAnchor.constraint(equalTo: customEndpointField.trailingAnchor), - customModelField.heightAnchor.constraint(equalToConstant: inputHeight), - - keyTopWithCustom, - keyLabel.leadingAnchor.constraint(equalTo: modelPage.leadingAnchor), - keyLabel.widthAnchor.constraint(equalToConstant: labelColumnWidth), - keyField.topAnchor.constraint(equalTo: keyLabel.topAnchor), - keyField.leadingAnchor.constraint(equalTo: modelPage.leadingAnchor, constant: labelColumnWidth), - keyField.widthAnchor.constraint(equalToConstant: fieldWidth), - keyField.heightAnchor.constraint(equalToConstant: inputHeight), - keyHelpLabel.topAnchor.constraint(equalTo: keyField.bottomAnchor, constant: 4), - keyHelpLabel.leadingAnchor.constraint(equalTo: keyField.leadingAnchor), - keyHelpLabel.widthAnchor.constraint(equalToConstant: fieldWidth), - - testChatButton.topAnchor.constraint(equalTo: keyField.bottomAnchor, constant: 18), - testChatButton.leadingAnchor.constraint(equalTo: keyField.leadingAnchor), - testChatButton.widthAnchor.constraint(equalToConstant: 136), - testChatButton.heightAnchor.constraint(equalToConstant: controlHeight), - testChatButton.bottomAnchor.constraint(lessThanOrEqualTo: modelPage.bottomAnchor, constant: -8), - saveButton.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -44), saveButton.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -36), saveButton.widthAnchor.constraint(equalToConstant: 104), @@ -430,25 +276,25 @@ extension AISettingsPanelController { self.embeddingPage = embeddingPage self.speechPage = speechPage self.cachePage = cachePage - self.modelPopup = modelPopup - self.languagePopup = languagePopup - self.themePopup = themePopup - self.pdfDimmingLabel = pdfDimmingLabel - self.pdfDimmingSlider = pdfDimmingSlider - self.pdfDimmingLabelTopConstraint = pdfDimmingLabelTopConstraint - self.speakSelectedWordTopToDimmingConstraint = speakSelectedWordTopToDimmingConstraint - self.speakSelectedWordTopToThemeConstraint = speakSelectedWordTopToThemeConstraint - self.pdfDimmingCollapsedConstraints = pdfDimmingCollapsedConstraints - self.secureKeyField = keyField - self.customModelContainer = customModelContainer - self.customEndpointLabel = customEndpointLabel - self.customEndpointField = customEndpointField - self.customModelLabel = customModelLabel - self.customModelField = customModelField - self.customModelContainerHeightConstraint = customModelContainerHeight - self.customModelLabelTopToEndpointConstraint = customModelTopToEndpoint - self.customModelLabelTopToContainerConstraint = customModelTopToContainer - self.customModelLabelCenterYToContainerConstraint = customModelCenterYToContainer + self.modelPopup = modelSection.modelPopup + self.languagePopup = generalSection.languagePopup + self.themePopup = generalSection.themePopup + self.pdfDimmingLabel = generalSection.pdfDimmingLabel + self.pdfDimmingSlider = generalSection.pdfDimmingSlider + self.pdfDimmingLabelTopConstraint = generalLayout.pdfDimmingLabelTop + self.speakSelectedWordTopToDimmingConstraint = generalLayout.speakSelectedWordTopToDimming + self.speakSelectedWordTopToThemeConstraint = generalLayout.speakSelectedWordTopToTheme + self.pdfDimmingCollapsedConstraints = generalLayout.pdfDimmingCollapsed + self.secureKeyField = modelSection.keyField + self.customModelContainer = modelSection.customModelContainer + self.customEndpointLabel = modelSection.customEndpointLabel + self.customEndpointField = modelSection.customEndpointField + self.customModelLabel = modelSection.customModelLabel + self.customModelField = modelSection.customModelField + self.customModelContainerHeightConstraint = modelLayout.customModelContainerHeight + self.customModelLabelTopToEndpointConstraint = modelLayout.customModelLabelTopToEndpoint + self.customModelLabelTopToContainerConstraint = modelLayout.customModelLabelTopToContainer + self.customModelLabelCenterYToContainerConstraint = modelLayout.customModelLabelCenterYToContainer self.secureKeyField?.isEnabled = selectedModel.acceptsAPIKey self.embeddingProviderPopup = embeddingProviderPopup self.embeddingEndpointContainer = embeddingEndpointContainer @@ -456,8 +302,8 @@ extension AISettingsPanelController { self.embeddingEndpointField = embeddingEndpointField self.embeddingModelField = embeddingModelField self.embeddingKeyField = embeddingKeyField - self.speakSelectedWordCheckbox = speakSelectedWordCheckbox - self.saveAIConversationCheckbox = saveAIConversationCheckbox + self.speakSelectedWordCheckbox = generalSection.speakSelectedWordCheckbox + self.saveAIConversationCheckbox = generalSection.saveAIConversationCheckbox self.autoEmbeddingIndexCheckbox = autoEmbeddingIndexCheckbox self.speechRuntimePopup = speechRuntimePopup self.speechVoicePopup = speechVoicePopup @@ -478,11 +324,11 @@ extension AISettingsPanelController { DispatchQueue.main.async { panel.makeKey() if selectedModel.id == AISettingsStore.customModelID { - panel.makeFirstResponder(customEndpointField) + panel.makeFirstResponder(modelSection.customEndpointField) } else if selectedModel.id == AISettingsStore.ollamaModelID { - panel.makeFirstResponder(customModelField) + panel.makeFirstResponder(modelSection.customModelField) } else { - panel.makeFirstResponder(keyField) + panel.makeFirstResponder(modelSection.keyField) } } } diff --git a/mac-app/AISettingsPanelController+BuildGeneral.swift b/mac-app/AISettingsPanelController+BuildGeneral.swift new file mode 100644 index 0000000..ce26865 --- /dev/null +++ b/mac-app/AISettingsPanelController+BuildGeneral.swift @@ -0,0 +1,184 @@ +import Cocoa + +struct AISettingsPanelPalette { + let background: NSColor + let primaryText: NSColor + let secondaryText: NSColor + + init(theme: ReaderTheme) { + switch theme { + case .original: + background = .white + primaryText = NSColor(red: 0.12, green: 0.13, blue: 0.16, alpha: 1) + secondaryText = NSColor(red: 0.47, green: 0.50, blue: 0.58, alpha: 1) + case .eyeCare: + background = NSColor(red: 0.91, green: 0.87, blue: 0.74, alpha: 1) + primaryText = NSColor(red: 0.15, green: 0.13, blue: 0.09, alpha: 1) + secondaryText = NSColor(red: 0.45, green: 0.39, blue: 0.27, alpha: 1) + case .dark: + background = NSColor(red: 0.10, green: 0.12, blue: 0.15, alpha: 1) + primaryText = NSColor(red: 0.86, green: 0.88, blue: 0.92, alpha: 1) + secondaryText = NSColor(red: 0.58, green: 0.63, blue: 0.70, alpha: 1) + } + } +} + +struct AISettingsGeneralSection { + let languageLabel: NSTextField + let languagePopup: NSPopUpButton + let languageHelpLabel: NSTextField + let themeLabel: NSTextField + let themePopup: NSPopUpButton + let themeHelpLabel: NSTextField + let pdfDimmingLabel: NSTextField + let pdfDimmingSlider: ThemedSettingsSlider + let speakSelectedWordLabel: NSTextField + let speakSelectedWordCheckbox: NSButton + let saveAIConversationLabel: NSTextField + let saveAIConversationCheckbox: NSButton + + var pageViews: [NSView] { + [ + languageLabel, languagePopup, languageHelpLabel, + themeLabel, themePopup, themeHelpLabel, + pdfDimmingLabel, pdfDimmingSlider, + speakSelectedWordLabel, speakSelectedWordCheckbox, + saveAIConversationLabel, saveAIConversationCheckbox + ] + } +} + +struct AISettingsGeneralLayout { + let constraints: [NSLayoutConstraint] + let pdfDimmingLabelTop: NSLayoutConstraint + let speakSelectedWordTopToDimming: NSLayoutConstraint + let speakSelectedWordTopToTheme: NSLayoutConstraint + let pdfDimmingCollapsed: [NSLayoutConstraint] +} + +extension AISettingsPanelController { + func makeGeneralSection( + settingsFontSize: CGFloat, + primaryText: NSColor, + secondaryText: NSColor, + theme: ReaderTheme + ) -> AISettingsGeneralSection { + let languageLabel = label(AppText.language, size: settingsFontSize, weight: .semibold, color: primaryText) + let languageHelpLabel = label(AppText.languageHelp, size: settingsFontSize, color: secondaryText) + languageHelpLabel.isHidden = true + let languagePopup = popup( + items: AppText.Language.allCases.map { ($0.title, $0.rawValue) }, + selected: AppText.selectedLanguage.rawValue, + fontSize: settingsFontSize + ) + languagePopup.identifier = Identifiers.languagePopup + + let themeLabel = label(AppText.localized("模式", "Mode"), size: settingsFontSize, weight: .semibold, color: primaryText) + let themeHelpLabel = label(ReaderTheme.selected.helpText, size: settingsFontSize, color: secondaryText) + themeHelpLabel.isHidden = true + let themePopup = popup( + items: ReaderTheme.allCases.map { ($0.title, $0.rawValue) }, + selected: ReaderTheme.selected.rawValue, + fontSize: settingsFontSize + ) + themePopup.target = self + themePopup.action = #selector(themeChanged(_:)) + themePopup.identifier = Identifiers.themePopup + + let pdfDimmingLabel = label(AppText.localized("阅读区亮度", "Reading Area Brightness"), size: settingsFontSize, weight: .semibold, color: primaryText) + let pdfDimmingSlider = ThemedSettingsSlider( + value: pdfBrightnessSliderValue(forDimmingStrength: ReaderTheme.pdfDimmingStrength), + minValue: 0, + maxValue: Self.pdfBrightnessSliderMaximum + ) + pdfDimmingSlider.theme = theme + pdfDimmingSlider.numberOfTickMarks = 7 + pdfDimmingSlider.target = self + pdfDimmingSlider.action = #selector(pdfDimmingSliderChanged(_:)) + pdfDimmingSlider.translatesAutoresizingMaskIntoConstraints = false + + let speakSelectedWordLabel = label(AppText.localized("自动播放单词", "Auto Play Words"), size: settingsFontSize, weight: .semibold, color: primaryText) + let speakSelectedWordCheckbox = settingsCheckbox(isOn: AISettingsStore.speakSelectedWordEnabled, theme: theme, fontSize: settingsFontSize) + let saveAIConversationLabel = label(AppText.localized("保存 AI 对话信息", "Save AI Chat"), size: settingsFontSize, weight: .semibold, color: primaryText) + let saveAIConversationCheckbox = settingsCheckbox(isOn: AISettingsStore.saveAIConversationEnabled, theme: theme, fontSize: settingsFontSize) + + return AISettingsGeneralSection( + languageLabel: languageLabel, + languagePopup: languagePopup, + languageHelpLabel: languageHelpLabel, + themeLabel: themeLabel, + themePopup: themePopup, + themeHelpLabel: themeHelpLabel, + pdfDimmingLabel: pdfDimmingLabel, + pdfDimmingSlider: pdfDimmingSlider, + speakSelectedWordLabel: speakSelectedWordLabel, + speakSelectedWordCheckbox: speakSelectedWordCheckbox, + saveAIConversationLabel: saveAIConversationLabel, + saveAIConversationCheckbox: saveAIConversationCheckbox + ) + } + + func generalConstraints( + for section: AISettingsGeneralSection, + page: NSView, + labelColumnWidth: CGFloat, + fieldWidth: CGFloat, + controlHeight: CGFloat + ) -> AISettingsGeneralLayout { + let pdfDimmingLabelTop = section.pdfDimmingLabel.topAnchor.constraint(equalTo: section.themePopup.bottomAnchor, constant: 22) + let speakSelectedWordTopToDimming = section.speakSelectedWordLabel.topAnchor.constraint(equalTo: section.pdfDimmingSlider.bottomAnchor, constant: 22) + let speakSelectedWordTopToTheme = section.speakSelectedWordLabel.topAnchor.constraint(equalTo: section.themePopup.bottomAnchor, constant: 22) + let pdfDimmingCollapsed = [ + section.pdfDimmingLabel.heightAnchor.constraint(equalToConstant: 0), + section.pdfDimmingSlider.heightAnchor.constraint(equalToConstant: 0) + ] + let constraints = [ + section.languageLabel.topAnchor.constraint(equalTo: page.topAnchor, constant: 4), + section.languageLabel.leadingAnchor.constraint(equalTo: page.leadingAnchor), + section.languageLabel.widthAnchor.constraint(equalToConstant: labelColumnWidth), + section.languagePopup.topAnchor.constraint(equalTo: section.languageLabel.topAnchor), + section.languagePopup.leadingAnchor.constraint(equalTo: page.leadingAnchor, constant: labelColumnWidth), + section.languagePopup.widthAnchor.constraint(equalToConstant: fieldWidth), + section.languagePopup.heightAnchor.constraint(equalToConstant: controlHeight), + section.languageHelpLabel.topAnchor.constraint(equalTo: section.languagePopup.bottomAnchor, constant: 4), + section.languageHelpLabel.leadingAnchor.constraint(equalTo: section.languagePopup.leadingAnchor), + section.languageHelpLabel.widthAnchor.constraint(equalToConstant: fieldWidth), + section.themeLabel.topAnchor.constraint(equalTo: section.languagePopup.bottomAnchor, constant: 34), + section.themeLabel.leadingAnchor.constraint(equalTo: page.leadingAnchor), + section.themeLabel.widthAnchor.constraint(equalToConstant: labelColumnWidth), + section.themePopup.topAnchor.constraint(equalTo: section.themeLabel.topAnchor), + section.themePopup.leadingAnchor.constraint(equalTo: page.leadingAnchor, constant: labelColumnWidth), + section.themePopup.widthAnchor.constraint(equalToConstant: fieldWidth), + section.themePopup.heightAnchor.constraint(equalToConstant: controlHeight), + section.themeHelpLabel.topAnchor.constraint(equalTo: section.themePopup.bottomAnchor, constant: 4), + section.themeHelpLabel.leadingAnchor.constraint(equalTo: section.themePopup.leadingAnchor), + section.themeHelpLabel.widthAnchor.constraint(equalToConstant: fieldWidth), + pdfDimmingLabelTop, + section.pdfDimmingLabel.leadingAnchor.constraint(equalTo: page.leadingAnchor), + section.pdfDimmingLabel.widthAnchor.constraint(equalToConstant: labelColumnWidth), + section.pdfDimmingSlider.centerYAnchor.constraint(equalTo: section.pdfDimmingLabel.centerYAnchor), + section.pdfDimmingSlider.leadingAnchor.constraint(equalTo: page.leadingAnchor, constant: labelColumnWidth), + section.pdfDimmingSlider.widthAnchor.constraint(equalToConstant: fieldWidth), + speakSelectedWordTopToDimming, + section.speakSelectedWordLabel.leadingAnchor.constraint(equalTo: page.leadingAnchor), + section.speakSelectedWordLabel.widthAnchor.constraint(equalToConstant: labelColumnWidth), + section.speakSelectedWordCheckbox.centerYAnchor.constraint(equalTo: section.speakSelectedWordLabel.centerYAnchor), + section.speakSelectedWordCheckbox.leadingAnchor.constraint(equalTo: page.leadingAnchor, constant: labelColumnWidth), + section.speakSelectedWordCheckbox.widthAnchor.constraint(equalToConstant: 32), + section.saveAIConversationLabel.topAnchor.constraint(equalTo: section.speakSelectedWordLabel.bottomAnchor, constant: 22), + section.saveAIConversationLabel.leadingAnchor.constraint(equalTo: page.leadingAnchor), + section.saveAIConversationLabel.widthAnchor.constraint(equalToConstant: labelColumnWidth), + section.saveAIConversationCheckbox.centerYAnchor.constraint(equalTo: section.saveAIConversationLabel.centerYAnchor), + section.saveAIConversationCheckbox.leadingAnchor.constraint(equalTo: page.leadingAnchor, constant: labelColumnWidth), + section.saveAIConversationCheckbox.widthAnchor.constraint(equalToConstant: 32), + section.saveAIConversationCheckbox.bottomAnchor.constraint(lessThanOrEqualTo: page.bottomAnchor, constant: -8) + ] + return AISettingsGeneralLayout( + constraints: constraints, + pdfDimmingLabelTop: pdfDimmingLabelTop, + speakSelectedWordTopToDimming: speakSelectedWordTopToDimming, + speakSelectedWordTopToTheme: speakSelectedWordTopToTheme, + pdfDimmingCollapsed: pdfDimmingCollapsed + ) + } +} diff --git a/mac-app/AISettingsPanelController+BuildModel.swift b/mac-app/AISettingsPanelController+BuildModel.swift new file mode 100644 index 0000000..a9cfce8 --- /dev/null +++ b/mac-app/AISettingsPanelController+BuildModel.swift @@ -0,0 +1,187 @@ +import Cocoa + +struct AISettingsModelSection { + let modelLabel: NSTextField + let modelPopup: NSPopUpButton + let modelHelpLabel: NSTextField + let customModelContainer: NSView + let customEndpointLabel: NSTextField + let customEndpointField: NSTextField + let customModelLabel: NSTextField + let customModelField: NSTextField + let keyLabel: NSTextField + let keyField: APIKeySecureTextField + let keyHelpLabel: NSTextField + let testChatButton: NSButton + + var pageViews: [NSView] { + [modelLabel, modelPopup, modelHelpLabel, customModelContainer, keyLabel, keyField, keyHelpLabel, testChatButton] + } +} + +struct AISettingsModelLayout { + let constraints: [NSLayoutConstraint] + let keyTopWithCustom: NSLayoutConstraint + let keyTopWithoutCustom: NSLayoutConstraint + let customModelContainerHeight: NSLayoutConstraint + let customModelLabelTopToEndpoint: NSLayoutConstraint + let customModelLabelTopToContainer: NSLayoutConstraint + let customModelLabelCenterYToContainer: NSLayoutConstraint +} + +extension AISettingsPanelController { + func makeModelSection( + selectedModel: AIModelConfig, + settingsFontSize: CGFloat, + primaryText: NSColor, + secondaryText: NSColor + ) -> AISettingsModelSection { + let modelLabel = label(AppText.model, size: settingsFontSize, weight: .semibold, color: primaryText) + let modelHelpLabel = label(AppText.modelHelp, size: settingsFontSize, color: secondaryText) + modelHelpLabel.isHidden = true + let modelPopup = popup( + items: AISettingsStore.models.map { ($0.displayName, $0.id) }, + selected: selectedModel.id, + fontSize: settingsFontSize + ) + modelPopup.target = self + modelPopup.action = #selector(modelChanged(_:)) + modelPopup.identifier = Identifiers.modelPopup + + let customEndpointLabel = label(AppText.localized("自定义 / Azure URL", "Custom / Azure URL"), size: settingsFontSize, weight: .semibold, color: primaryText) + let customEndpointField = inputField( + AISettingsStore.customEndpointString, + placeholder: "https://resource.openai.azure.com/openai/deployments/deployment/chat/completions?api-version=2024-10-21", + fontSize: settingsFontSize, + textColor: primaryText, + backgroundColor: fieldBackground() + ) + let customModelLabel = label(AppText.localized("模型 ID / Azure 部署名", "Model ID / Azure Deployment"), size: settingsFontSize, weight: .semibold, color: primaryText) + let initialModelFieldValue: String + if selectedModel.id == AISettingsStore.ollamaModelID { + initialModelFieldValue = AISettingsStore.ollamaModelName + } else if selectedModel.id == AISettingsStore.localOpenAIModelID { + initialModelFieldValue = AISettingsStore.localOpenAIModelName + } else { + initialModelFieldValue = AISettingsStore.customModelName + } + let customModelField = inputField( + initialModelFieldValue, + placeholder: "gpt-4o-mini", + fontSize: settingsFontSize, + textColor: primaryText, + backgroundColor: fieldBackground() + ) + let customModelContainer = settingsCard() + for view in [customEndpointLabel, customEndpointField, customModelLabel, customModelField] { + customModelContainer.addSubview(view) + } + + let keyLabel = label("API Key", size: settingsFontSize, weight: .semibold, color: primaryText) + let keyHelpLabel = label(AppText.keyHelp, size: settingsFontSize, color: secondaryText) + keyHelpLabel.isHidden = true + let keyField = APIKeySecureTextField(string: AISettingsStore.apiKey(for: selectedModel)) + configureKeyField( + keyField, + placeholder: AppText.apiKeyPlaceholder, + fontSize: settingsFontSize, + textColor: primaryText, + backgroundColor: fieldBackground() + ) + keyField.identifier = Identifiers.keyField + let testChatButton = settingsActionButton( + title: AppText.localized("测试模型连接", "Test Chat"), + target: self, + action: #selector(testChatConnection(_:)) + ) + testChatButton.font = AppFont.semibold(ofSize: settingsFontSize) + + return AISettingsModelSection( + modelLabel: modelLabel, + modelPopup: modelPopup, + modelHelpLabel: modelHelpLabel, + customModelContainer: customModelContainer, + customEndpointLabel: customEndpointLabel, + customEndpointField: customEndpointField, + customModelLabel: customModelLabel, + customModelField: customModelField, + keyLabel: keyLabel, + keyField: keyField, + keyHelpLabel: keyHelpLabel, + testChatButton: testChatButton + ) + } + + func modelConstraints( + for section: AISettingsModelSection, + page: NSView, + labelColumnWidth: CGFloat, + fieldWidth: CGFloat, + inputHeight: CGFloat, + controlHeight: CGFloat + ) -> AISettingsModelLayout { + let keyTopWithCustom = section.keyLabel.topAnchor.constraint(equalTo: section.customModelContainer.bottomAnchor, constant: 22) + let keyTopWithoutCustom = section.keyLabel.topAnchor.constraint(equalTo: section.modelPopup.bottomAnchor, constant: 34) + let customModelContainerHeight = section.customModelContainer.heightAnchor.constraint(equalToConstant: 116) + let customModelLabelTopToEndpoint = section.customModelLabel.topAnchor.constraint(equalTo: section.customEndpointLabel.bottomAnchor, constant: 22) + let customModelLabelTopToContainer = section.customModelLabel.topAnchor.constraint(equalTo: section.customModelContainer.topAnchor, constant: 14) + let customModelLabelCenterYToContainer = section.customModelLabel.centerYAnchor.constraint(equalTo: section.customModelContainer.centerYAnchor) + customModelLabelTopToContainer.isActive = false + customModelLabelCenterYToContainer.isActive = false + + let constraints = [ + section.modelLabel.topAnchor.constraint(equalTo: page.topAnchor, constant: 4), + section.modelLabel.leadingAnchor.constraint(equalTo: page.leadingAnchor), + section.modelLabel.widthAnchor.constraint(equalToConstant: labelColumnWidth), + section.modelPopup.topAnchor.constraint(equalTo: page.topAnchor, constant: 4), + section.modelPopup.leadingAnchor.constraint(equalTo: page.leadingAnchor, constant: labelColumnWidth), + section.modelPopup.widthAnchor.constraint(equalToConstant: fieldWidth), + section.modelPopup.heightAnchor.constraint(equalToConstant: controlHeight), + section.modelHelpLabel.topAnchor.constraint(equalTo: section.modelPopup.bottomAnchor, constant: 4), + section.modelHelpLabel.leadingAnchor.constraint(equalTo: section.modelPopup.leadingAnchor), + section.modelHelpLabel.widthAnchor.constraint(equalToConstant: fieldWidth), + section.customModelContainer.topAnchor.constraint(equalTo: section.modelPopup.bottomAnchor, constant: 14), + section.customModelContainer.leadingAnchor.constraint(equalTo: section.modelPopup.leadingAnchor), + section.customModelContainer.widthAnchor.constraint(equalToConstant: fieldWidth), + customModelContainerHeight, + section.customEndpointLabel.topAnchor.constraint(equalTo: section.customModelContainer.topAnchor, constant: 14), + section.customEndpointLabel.leadingAnchor.constraint(equalTo: section.customModelContainer.leadingAnchor, constant: 14), + section.customEndpointLabel.widthAnchor.constraint(equalToConstant: 180), + section.customEndpointField.centerYAnchor.constraint(equalTo: section.customEndpointLabel.centerYAnchor), + section.customEndpointField.leadingAnchor.constraint(equalTo: section.customModelContainer.leadingAnchor, constant: 204), + section.customEndpointField.trailingAnchor.constraint(equalTo: section.customModelContainer.trailingAnchor, constant: -14), + section.customEndpointField.heightAnchor.constraint(equalToConstant: inputHeight), + customModelLabelTopToEndpoint, + section.customModelLabel.leadingAnchor.constraint(equalTo: section.customEndpointLabel.leadingAnchor), + section.customModelLabel.widthAnchor.constraint(equalToConstant: 180), + section.customModelField.centerYAnchor.constraint(equalTo: section.customModelLabel.centerYAnchor), + section.customModelField.leadingAnchor.constraint(equalTo: section.customEndpointField.leadingAnchor), + section.customModelField.trailingAnchor.constraint(equalTo: section.customEndpointField.trailingAnchor), + section.customModelField.heightAnchor.constraint(equalToConstant: inputHeight), + keyTopWithCustom, + section.keyLabel.leadingAnchor.constraint(equalTo: page.leadingAnchor), + section.keyLabel.widthAnchor.constraint(equalToConstant: labelColumnWidth), + section.keyField.topAnchor.constraint(equalTo: section.keyLabel.topAnchor), + section.keyField.leadingAnchor.constraint(equalTo: page.leadingAnchor, constant: labelColumnWidth), + section.keyField.widthAnchor.constraint(equalToConstant: fieldWidth), + section.keyField.heightAnchor.constraint(equalToConstant: inputHeight), + section.keyHelpLabel.topAnchor.constraint(equalTo: section.keyField.bottomAnchor, constant: 4), + section.keyHelpLabel.leadingAnchor.constraint(equalTo: section.keyField.leadingAnchor), + section.keyHelpLabel.widthAnchor.constraint(equalToConstant: fieldWidth), + section.testChatButton.topAnchor.constraint(equalTo: section.keyField.bottomAnchor, constant: 18), + section.testChatButton.leadingAnchor.constraint(equalTo: section.keyField.leadingAnchor), + section.testChatButton.widthAnchor.constraint(equalToConstant: 136), + section.testChatButton.heightAnchor.constraint(equalToConstant: controlHeight), + section.testChatButton.bottomAnchor.constraint(lessThanOrEqualTo: page.bottomAnchor, constant: -8) + ] + return AISettingsModelLayout( + constraints: constraints, + keyTopWithCustom: keyTopWithCustom, + keyTopWithoutCustom: keyTopWithoutCustom, + customModelContainerHeight: customModelContainerHeight, + customModelLabelTopToEndpoint: customModelLabelTopToEndpoint, + customModelLabelTopToContainer: customModelLabelTopToContainer, + customModelLabelCenterYToContainer: customModelLabelCenterYToContainer + ) + } +} diff --git a/mac-app/AISettingsPanelController+Speech.swift b/mac-app/AISettingsPanelController+Speech.swift index 21f9f12..9678632 100644 --- a/mac-app/AISettingsPanelController+Speech.swift +++ b/mac-app/AISettingsPanelController+Speech.swift @@ -5,46 +5,10 @@ extension AISettingsPanelController { static let selectionDebounce: TimeInterval = 0.3 } - private struct RuntimeStatus { - let downloaded: Bool - let downloading: Bool - let paused: Bool - } - func speechRuntimeButtonTag(for runtime: SpeechRuntimeResourceManager.Runtime) -> Int { SpeechRuntimeResourceManager.Runtime.displayOrder.firstIndex(of: runtime) ?? -1 } - private func speechRuntime(for sender: NSButton) -> SpeechRuntimeResourceManager.Runtime? { - let runtimes = SpeechRuntimeResourceManager.Runtime.displayOrder - guard runtimes.indices.contains(sender.tag) else { return nil } - return runtimes[sender.tag] - } - - @objc func downloadSpeechRuntimeButton(_ sender: NSButton) { - guard let runtime = speechRuntime(for: sender) else { return } - downloadSpeechRuntime(runtime, button: sender) - } - - @objc func deleteSpeechRuntimeButton(_ sender: NSButton) { - guard let runtime = speechRuntime(for: sender) else { return } - deleteSpeechRuntime(runtime) - } - - @objc func pauseSpeechRuntimeDownloadButton(_ sender: NSButton) { - guard let runtime = speechRuntime(for: sender) else { return } - toggleSpeechRuntimeDownloadPaused(runtime) - } - - @objc func cancelSpeechRuntimeDownloadButton(_ sender: NSButton) { - guard let runtime = speechRuntime(for: sender) else { return } - cancelSpeechRuntimeDownload(runtime) - } - - @objc func copySpeechRuntimeDiagnosticsButton(_ sender: NSButton) { - copySpeechRuntimeDiagnostics(error: nil, runtime: nil) - } - @objc func speechRuntimeChanged(_ sender: NSPopUpButton) { let runtimeID = sender.selectedItem?.representedObject as? String if let runtimeID, @@ -84,78 +48,6 @@ extension AISettingsPanelController { previewSelectedSpeechVoice(voiceID, runtimeID: runtimeID) } - func refreshSpeechRuntimeStatus() { - let statuses = Dictionary( - uniqueKeysWithValues: SpeechRuntimeResourceManager.Runtime.displayOrder.map { ($0, runtimeStatus($0)) } - ) - for runtime in SpeechRuntimeResourceManager.Runtime.displayOrder { - guard let status = statuses[runtime], - let controls = speechRuntimeControls[runtime] else { continue } - updateRuntimeControls(runtime: runtime, status: status, controls: controls) - } - refreshSpeechRuntimePopup() - updateSpeechDownloadRefreshTimer(isDownloading: statuses.values.contains { $0.downloading }) - } - - private func runtimeStatus(_ runtime: SpeechRuntimeResourceManager.Runtime) -> RuntimeStatus { - RuntimeStatus( - downloaded: SpeechRuntimeResourceManager.isDownloaded(runtime), - downloading: SpeechRuntimeResourceManager.isDownloading(runtime), - paused: SpeechRuntimeResourceManager.isPaused(runtime) - ) - } - - private func updateRuntimeControls( - runtime: SpeechRuntimeResourceManager.Runtime, - status: RuntimeStatus, - controls: SpeechRuntimeRowControls - ) { - controls.statusLabel.stringValue = SpeechRuntimeResourceManager.statusText(for: runtime) - updateSpeechProgressIndicator(controls.progressIndicator, runtime: runtime, isDownloading: status.downloading) - controls.pauseButton.title = status.paused ? AppText.localized("继续", "Resume") : AppText.localized("暂停", "Pause") - controls.downloadButton.isEnabled = !status.downloading - controls.deleteButton.isEnabled = status.downloaded - controls.downloadButton.isHidden = status.downloaded || status.downloading - controls.pauseButton.isHidden = !status.downloading - controls.cancelButton.isHidden = !status.downloading - controls.deleteButton.isHidden = !status.downloaded || status.downloading - } - - private func toggleSpeechRuntimeDownloadPaused(_ runtime: SpeechRuntimeResourceManager.Runtime) { - if SpeechRuntimeResourceManager.isPaused(runtime) { - SpeechRuntimeResourceManager.resume(runtime) - } else { - SpeechRuntimeResourceManager.pause(runtime) - } - refreshSpeechRuntimeStatus() - } - - private func cancelSpeechRuntimeDownload(_ runtime: SpeechRuntimeResourceManager.Runtime) { - SpeechRuntimeResourceManager.cancel(runtime) - refreshSpeechRuntimeStatus() - } - - private func updateSpeechProgressIndicator( - _ indicator: NSProgressIndicator?, - runtime: SpeechRuntimeResourceManager.Runtime, - isDownloading: Bool - ) { - indicator?.isHidden = !isDownloading - indicator?.doubleValue = SpeechRuntimeResourceManager.downloadProgress(for: runtime) ?? 0 - } - - private func updateSpeechDownloadRefreshTimer(isDownloading: Bool) { - if isDownloading { - guard speechDownloadRefreshTimer == nil else { return } - speechDownloadRefreshTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in - self?.refreshSpeechRuntimeStatus() - } - } else { - speechDownloadRefreshTimer?.invalidate() - speechDownloadRefreshTimer = nil - } - } - func saveSelectedSpeechSettings( runtimeID: String?, voiceID: String?, @@ -185,7 +77,7 @@ extension AISettingsPanelController { } } - private func refreshSpeechRuntimePopup() { + func refreshSpeechRuntimePopup() { guard let popup = speechRuntimePopup else { return } let languageHint = currentSpeechLanguageHint?() syncSpeechRuntimeForLanguageIfNeeded(languageHint: languageHint) @@ -305,183 +197,4 @@ extension AISettingsPanelController { languageHint == .chinese && runtime == .piper } - private func downloadSpeechRuntime(_ runtime: SpeechRuntimeResourceManager.Runtime, button: NSButton) { - if !runtime.isSupportedOnCurrentSystem { - showUnsupportedRuntimeDownloadWarning(runtime) { [weak self, weak button] shouldContinue in - guard let self, let button else { return } - if shouldContinue { - self.startSpeechRuntimeDownload(runtime, button: button) - } else { - self.refreshSpeechRuntimeStatus() - } - } - return - } - - startSpeechRuntimeDownload(runtime, button: button) - } - - private func startSpeechRuntimeDownload(_ runtime: SpeechRuntimeResourceManager.Runtime, button: NSButton) { - button.isEnabled = false - SpeechRuntimeResourceManager.download(runtime) { [weak self, weak button] result in - guard let self else { return } - switch result { - case .success: - self.selectSpeechRuntimeAfterDownload(runtime) - self.refreshSpeechRuntimeStatus() - case .failure(let error): - guard (error as NSError).code != NSUserCancelledError else { - self.refreshSpeechRuntimeStatus() - return - } - button?.isEnabled = true - self.speechRuntimeControls[runtime]?.statusLabel.stringValue = AppText.localized("下载失败", "Download failed") - self.showSpeechDownloadError(error, runtime: runtime) - } - } - refreshSpeechRuntimeStatus() - } - - private func showUnsupportedRuntimeDownloadWarning( - _ runtime: SpeechRuntimeResourceManager.Runtime, - completion: @escaping (Bool) -> Void - ) { - guard let panel else { - completion(true) - return - } - - let alert = NSAlert() - alert.messageText = AppText.localized("系统版本低于朗读模型要求", "System Version Below Runtime Requirement") - alert.informativeText = AppText.localized( - "\(runtime.title) 需要 \(runtime.minimumSystemVersionText) 或更高版本才能运行。你仍然可以下载模型,但当前系统可能无法使用它。", - "\(runtime.title) requires \(runtime.minimumSystemVersionText) or later to run. You can still download the model, but this system may not be able to use it." - ) - alert.alertStyle = .warning - alert.addButton(withTitle: AppText.localized("继续下载", "Download Anyway")) - alert.addButton(withTitle: AppText.cancel) - alert.beginSheetModal(for: panel) { response in - completion(response == .alertFirstButtonReturn) - } - } - - private func selectSpeechRuntimeAfterDownload(_ downloadedRuntime: SpeechRuntimeResourceManager.Runtime) { - let runnableRuntimes = SpeechRuntimeResourceManager.runnableReadAloudRuntimes() - guard runnableRuntimes.contains(downloadedRuntime) else { return } - - let selectedRuntime = SpeechRuntimeResourceManager.Runtime.runtime(for: AISettingsStore.selectedSpeechRuntimeID) - let selectedRuntimeIsRunnable = selectedRuntime.map { runnableRuntimes.contains($0) } ?? false - guard runnableRuntimes.count == 1 || !selectedRuntimeIsRunnable else { return } - - let previousRuntimeID = AISettingsStore.selectedSpeechRuntimeID - AISettingsStore.saveSelectedSpeechRuntimeID(downloadedRuntime.id) - guard downloadedRuntime.id != previousRuntimeID else { return } - if !SpeechPlaybackCoordinator.shared.hasActiveReadAloudWork() { - SpeechPlaybackCoordinator.shared.shutdown() - } - } - - private func deleteSpeechRuntime(_ runtime: SpeechRuntimeResourceManager.Runtime) { - SpeechPlaybackCoordinator.shared.shutdownRuntime(runtime) - do { - try SpeechRuntimeResourceManager.delete(runtime) - selectRunnableSpeechRuntimeIfNeeded(deletedRuntime: runtime) - refreshSpeechRuntimeStatus() - } catch { - showSpeechDeleteError(error, runtime: runtime) - } - } - - private func selectRunnableSpeechRuntimeIfNeeded(deletedRuntime: SpeechRuntimeResourceManager.Runtime) { - guard AISettingsStore.selectedSpeechRuntimeID == deletedRuntime.id else { return } - guard let replacement = SpeechRuntimeResourceManager.runnableReadAloudRuntimes().first else { return } - AISettingsStore.saveSelectedSpeechRuntimeID(replacement.id) - } - - private func showSpeechDownloadError(_ error: Error, runtime: SpeechRuntimeResourceManager.Runtime) { - guard let panel else { return } - let alert = NSAlert() - alert.messageText = AppText.localized("朗读模型下载失败", "Read Aloud Model Download Failed") - alert.informativeText = speechRuntimeErrorDescription(error) - alert.alertStyle = .warning - alert.addButton(withTitle: AppText.confirm) - alert.addButton(withTitle: AppText.localized("复制诊断", "Copy Diagnostics")) - alert.beginSheetModal(for: panel) { [weak self] response in - guard response == .alertSecondButtonReturn else { return } - self?.copySpeechRuntimeDiagnostics(error: error, runtime: runtime) - } - } - - private func showSpeechDeleteError(_ error: Error, runtime: SpeechRuntimeResourceManager.Runtime) { - guard let panel else { return } - let alert = NSAlert() - alert.messageText = AppText.localized("朗读模型删除失败", "Delete Read Aloud Model Failed") - alert.informativeText = speechRuntimeErrorDescription(error) - alert.alertStyle = .warning - alert.addButton(withTitle: AppText.confirm) - alert.addButton(withTitle: AppText.localized("复制诊断", "Copy Diagnostics")) - alert.beginSheetModal(for: panel) { [weak self] response in - guard response == .alertSecondButtonReturn else { return } - self?.copySpeechRuntimeDiagnostics(error: error, runtime: runtime) - } - } - - private func speechRuntimeErrorDescription(_ error: Error) -> String { - let raw = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) - let fallback = AppText.localized("未知错误", "Unknown error") - return NetworkErrorFormatter.sanitizedBody(raw.isEmpty ? fallback : raw) - } - - private func copySpeechRuntimeDiagnostics(error: Error?, runtime: SpeechRuntimeResourceManager.Runtime?) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(speechRuntimeDiagnosticText(error: error, runtime: runtime), forType: .string) - } - - private func speechRuntimeDiagnosticText( - error: Error?, - runtime selectedRuntime: SpeechRuntimeResourceManager.Runtime? - ) -> String { - let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" - var lines = [ - "Leaf Reader Speech Runtime Diagnostic", - "version: \(version)", - "selectedRuntime: \(AISettingsStore.selectedSpeechRuntimeID)", - "selectedSpeed: \(AISettingsStore.selectedSpeechSpeedID)" - ] - if let error { - lines.append("error: \(speechRuntimeErrorDescription(error))") - } - let runtimes: [SpeechRuntimeResourceManager.Runtime] - if let selectedRuntime { - runtimes = [selectedRuntime] - } else { - runtimes = SpeechRuntimeResourceManager.Runtime.displayOrder - } - for runtime in runtimes { - let health = SpeechRuntimeAvailability.health(for: runtime) - let failure = SpeechRuntimeInferenceFailureStore.failure(for: runtime) - lines += [ - "", - "[\(runtime.id)]", - "title: \(runtime.title)", - "voice: \(AISettingsStore.selectedSpeechVoiceID(runtimeID: runtime.id))", - "supported: \(runtime.isSupportedOnCurrentSystem)", - "minimumSystem: \(runtime.minimumSystemVersionText)", - "downloaded: \(SpeechRuntimeResourceManager.isDownloaded(runtime))", - "runnable: \(SpeechRuntimeResourceManager.isRunnable(runtime))", - "downloading: \(SpeechRuntimeResourceManager.isDownloading(runtime))", - "paused: \(SpeechRuntimeResourceManager.isPaused(runtime))", - "installState: \(health.installState)", - "hasRuntime: \(health.hasRuntime)", - "hasModel: \(health.hasModel)", - "status: \(SpeechRuntimeResourceManager.statusText(for: runtime))", - "installDirectory: \(runtime.installDirectory.path)", - "bundledExecutable: \(runtime.bundledExecutableURL?.path ?? "none")", - "lastFailureContext: \(failure?.context ?? "none")", - "lastFailureTextLength: \(failure.map { String($0.textLength) } ?? "none")", - "lastFailureOutput: \(failure?.outputPath ?? "none")" - ] - } - return lines.joined(separator: "\n") - } } diff --git a/mac-app/AISettingsPanelController+SpeechDownloads.swift b/mac-app/AISettingsPanelController+SpeechDownloads.swift new file mode 100644 index 0000000..ed456d8 --- /dev/null +++ b/mac-app/AISettingsPanelController+SpeechDownloads.swift @@ -0,0 +1,272 @@ +import Cocoa + +extension AISettingsPanelController { + private struct RuntimeStatus { + let downloaded: Bool + let downloading: Bool + let paused: Bool + } + + private func speechRuntime(for sender: NSButton) -> SpeechRuntimeResourceManager.Runtime? { + let runtimes = SpeechRuntimeResourceManager.Runtime.displayOrder + guard runtimes.indices.contains(sender.tag) else { return nil } + return runtimes[sender.tag] + } + + @objc func downloadSpeechRuntimeButton(_ sender: NSButton) { + guard let runtime = speechRuntime(for: sender) else { return } + downloadSpeechRuntime(runtime, button: sender) + } + + @objc func deleteSpeechRuntimeButton(_ sender: NSButton) { + guard let runtime = speechRuntime(for: sender) else { return } + deleteSpeechRuntime(runtime) + } + + @objc func pauseSpeechRuntimeDownloadButton(_ sender: NSButton) { + guard let runtime = speechRuntime(for: sender) else { return } + toggleSpeechRuntimeDownloadPaused(runtime) + } + + @objc func cancelSpeechRuntimeDownloadButton(_ sender: NSButton) { + guard let runtime = speechRuntime(for: sender) else { return } + SpeechRuntimeResourceManager.cancel(runtime) + refreshSpeechRuntimeStatus() + } + + @objc func copySpeechRuntimeDiagnosticsButton(_ sender: NSButton) { + copySpeechRuntimeDiagnostics(error: nil, runtime: nil) + } + + func refreshSpeechRuntimeStatus() { + let statuses = Dictionary( + uniqueKeysWithValues: SpeechRuntimeResourceManager.Runtime.displayOrder.map { ($0, runtimeStatus($0)) } + ) + for runtime in SpeechRuntimeResourceManager.Runtime.displayOrder { + guard let status = statuses[runtime], + let controls = speechRuntimeControls[runtime] else { continue } + updateRuntimeControls(runtime: runtime, status: status, controls: controls) + } + refreshSpeechRuntimePopup() + updateSpeechDownloadRefreshTimer(isDownloading: statuses.values.contains { $0.downloading }) + } + + private func runtimeStatus(_ runtime: SpeechRuntimeResourceManager.Runtime) -> RuntimeStatus { + RuntimeStatus( + downloaded: SpeechRuntimeResourceManager.isDownloaded(runtime), + downloading: SpeechRuntimeResourceManager.isDownloading(runtime), + paused: SpeechRuntimeResourceManager.isPaused(runtime) + ) + } + + private func updateRuntimeControls( + runtime: SpeechRuntimeResourceManager.Runtime, + status: RuntimeStatus, + controls: SpeechRuntimeRowControls + ) { + controls.statusLabel.stringValue = SpeechRuntimeResourceManager.statusText(for: runtime) + controls.progressIndicator.isHidden = !status.downloading + controls.progressIndicator.doubleValue = SpeechRuntimeResourceManager.downloadProgress(for: runtime) ?? 0 + controls.pauseButton.title = status.paused ? AppText.localized("继续", "Resume") : AppText.localized("暂停", "Pause") + controls.downloadButton.isEnabled = !status.downloading + controls.deleteButton.isEnabled = status.downloaded + controls.downloadButton.isHidden = status.downloaded || status.downloading + controls.pauseButton.isHidden = !status.downloading + controls.cancelButton.isHidden = !status.downloading + controls.deleteButton.isHidden = !status.downloaded || status.downloading + } + + private func toggleSpeechRuntimeDownloadPaused(_ runtime: SpeechRuntimeResourceManager.Runtime) { + if SpeechRuntimeResourceManager.isPaused(runtime) { + SpeechRuntimeResourceManager.resume(runtime) + } else { + SpeechRuntimeResourceManager.pause(runtime) + } + refreshSpeechRuntimeStatus() + } + + private func updateSpeechDownloadRefreshTimer(isDownloading: Bool) { + if isDownloading { + guard speechDownloadRefreshTimer == nil else { return } + speechDownloadRefreshTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + self?.refreshSpeechRuntimeStatus() + } + } else { + speechDownloadRefreshTimer?.invalidate() + speechDownloadRefreshTimer = nil + } + } + + private func downloadSpeechRuntime(_ runtime: SpeechRuntimeResourceManager.Runtime, button: NSButton) { + if !runtime.isSupportedOnCurrentSystem { + showUnsupportedRuntimeDownloadWarning(runtime) { [weak self, weak button] shouldContinue in + guard let self, let button else { return } + if shouldContinue { + self.startSpeechRuntimeDownload(runtime, button: button) + } else { + self.refreshSpeechRuntimeStatus() + } + } + return + } + startSpeechRuntimeDownload(runtime, button: button) + } + + private func startSpeechRuntimeDownload(_ runtime: SpeechRuntimeResourceManager.Runtime, button: NSButton) { + button.isEnabled = false + SpeechRuntimeResourceManager.download(runtime) { [weak self, weak button] result in + guard let self else { return } + switch result { + case .success: + self.selectSpeechRuntimeAfterDownload(runtime) + self.refreshSpeechRuntimeStatus() + case .failure(let error): + guard (error as NSError).code != NSUserCancelledError else { + self.refreshSpeechRuntimeStatus() + return + } + button?.isEnabled = true + self.speechRuntimeControls[runtime]?.statusLabel.stringValue = AppText.localized("下载失败", "Download failed") + self.showSpeechDownloadError(error, runtime: runtime) + } + } + refreshSpeechRuntimeStatus() + } + + private func showUnsupportedRuntimeDownloadWarning( + _ runtime: SpeechRuntimeResourceManager.Runtime, + completion: @escaping (Bool) -> Void + ) { + guard let panel else { + completion(true) + return + } + let alert = NSAlert() + alert.messageText = AppText.localized("系统版本低于朗读模型要求", "System Version Below Runtime Requirement") + alert.informativeText = AppText.localized( + "\(runtime.title) 需要 \(runtime.minimumSystemVersionText) 或更高版本才能运行。你仍然可以下载模型,但当前系统可能无法使用它。", + "\(runtime.title) requires \(runtime.minimumSystemVersionText) or later to run. You can still download the model, but this system may not be able to use it." + ) + alert.alertStyle = .warning + alert.addButton(withTitle: AppText.localized("继续下载", "Download Anyway")) + alert.addButton(withTitle: AppText.cancel) + alert.beginSheetModal(for: panel) { response in + completion(response == .alertFirstButtonReturn) + } + } + + private func selectSpeechRuntimeAfterDownload(_ downloadedRuntime: SpeechRuntimeResourceManager.Runtime) { + let runnableRuntimes = SpeechRuntimeResourceManager.runnableReadAloudRuntimes() + guard runnableRuntimes.contains(downloadedRuntime) else { return } + let selectedRuntime = SpeechRuntimeResourceManager.Runtime.runtime(for: AISettingsStore.selectedSpeechRuntimeID) + let selectedRuntimeIsRunnable = selectedRuntime.map { runnableRuntimes.contains($0) } ?? false + guard runnableRuntimes.count == 1 || !selectedRuntimeIsRunnable else { return } + let previousRuntimeID = AISettingsStore.selectedSpeechRuntimeID + AISettingsStore.saveSelectedSpeechRuntimeID(downloadedRuntime.id) + guard downloadedRuntime.id != previousRuntimeID else { return } + if !SpeechPlaybackCoordinator.shared.hasActiveReadAloudWork() { + SpeechPlaybackCoordinator.shared.shutdown() + } + } + + private func deleteSpeechRuntime(_ runtime: SpeechRuntimeResourceManager.Runtime) { + SpeechPlaybackCoordinator.shared.shutdownRuntime(runtime) + do { + try SpeechRuntimeResourceManager.delete(runtime) + selectRunnableSpeechRuntimeIfNeeded(deletedRuntime: runtime) + refreshSpeechRuntimeStatus() + } catch { + showSpeechDeleteError(error, runtime: runtime) + } + } + + private func selectRunnableSpeechRuntimeIfNeeded(deletedRuntime: SpeechRuntimeResourceManager.Runtime) { + guard AISettingsStore.selectedSpeechRuntimeID == deletedRuntime.id else { return } + guard let replacement = SpeechRuntimeResourceManager.runnableReadAloudRuntimes().first else { return } + AISettingsStore.saveSelectedSpeechRuntimeID(replacement.id) + } + + private func showSpeechDownloadError(_ error: Error, runtime: SpeechRuntimeResourceManager.Runtime) { + showSpeechRuntimeError( + error, + runtime: runtime, + title: AppText.localized("朗读模型下载失败", "Read Aloud Model Download Failed") + ) + } + + private func showSpeechDeleteError(_ error: Error, runtime: SpeechRuntimeResourceManager.Runtime) { + showSpeechRuntimeError( + error, + runtime: runtime, + title: AppText.localized("朗读模型删除失败", "Delete Read Aloud Model Failed") + ) + } + + private func showSpeechRuntimeError( + _ error: Error, + runtime: SpeechRuntimeResourceManager.Runtime, + title: String + ) { + guard let panel else { return } + let alert = NSAlert() + alert.messageText = title + alert.informativeText = speechRuntimeErrorDescription(error) + alert.alertStyle = .warning + alert.addButton(withTitle: AppText.confirm) + alert.addButton(withTitle: AppText.localized("复制诊断", "Copy Diagnostics")) + alert.beginSheetModal(for: panel) { [weak self] response in + guard response == .alertSecondButtonReturn else { return } + self?.copySpeechRuntimeDiagnostics(error: error, runtime: runtime) + } + } + + private func speechRuntimeErrorDescription(_ error: Error) -> String { + let raw = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + let fallback = AppText.localized("未知错误", "Unknown error") + return NetworkErrorFormatter.sanitizedBody(raw.isEmpty ? fallback : raw) + } + + private func copySpeechRuntimeDiagnostics(error: Error?, runtime: SpeechRuntimeResourceManager.Runtime?) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(speechRuntimeDiagnosticText(error: error, runtime: runtime), forType: .string) + } + + private func speechRuntimeDiagnosticText( + error: Error?, + runtime selectedRuntime: SpeechRuntimeResourceManager.Runtime? + ) -> String { + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" + var lines = [ + "Leaf Reader Speech Runtime Diagnostic", + "version: \(version)", + "selectedRuntime: \(AISettingsStore.selectedSpeechRuntimeID)", + "selectedSpeed: \(AISettingsStore.selectedSpeechSpeedID)" + ] + if let error { + lines.append("error: \(speechRuntimeErrorDescription(error))") + } + let runtimes = selectedRuntime.map { [$0] } ?? SpeechRuntimeResourceManager.Runtime.displayOrder + for runtime in runtimes { + let health = SpeechRuntimeAvailability.health(for: runtime) + let failure = SpeechRuntimeInferenceFailureStore.failure(for: runtime) + lines += [ + "", "[\(runtime.id)]", "title: \(runtime.title)", + "voice: \(AISettingsStore.selectedSpeechVoiceID(runtimeID: runtime.id))", + "supported: \(runtime.isSupportedOnCurrentSystem)", + "minimumSystem: \(runtime.minimumSystemVersionText)", + "downloaded: \(SpeechRuntimeResourceManager.isDownloaded(runtime))", + "runnable: \(SpeechRuntimeResourceManager.isRunnable(runtime))", + "downloading: \(SpeechRuntimeResourceManager.isDownloading(runtime))", + "paused: \(SpeechRuntimeResourceManager.isPaused(runtime))", + "installState: \(health.installState)", "hasRuntime: \(health.hasRuntime)", + "hasModel: \(health.hasModel)", "status: \(SpeechRuntimeResourceManager.statusText(for: runtime))", + "installDirectory: \(runtime.installDirectory.path)", + "bundledExecutable: \(runtime.bundledExecutableURL?.path ?? "none")", + "lastFailureContext: \(failure?.context ?? "none")", + "lastFailureTextLength: \(failure.map { String($0.textLength) } ?? "none")", + "lastFailureOutput: \(failure?.outputPath ?? "none")" + ] + } + return lines.joined(separator: "\n") + } +} diff --git a/mac-app/PDFEmbeddingStore.swift b/mac-app/PDFEmbeddingStore.swift index c367802..e2dfddf 100644 --- a/mac-app/PDFEmbeddingStore.swift +++ b/mac-app/PDFEmbeddingStore.swift @@ -78,38 +78,34 @@ final class PDFEmbeddingStore { INSERT OR REPLACE INTO embeddings(document_id, model, chunk_id, page_index, chunk_index, text, embedding, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """ - guard beginTransaction() else { return } - var didWriteAllRows = true - let updatedAt = Date().timeIntervalSince1970 - var statement: OpaquePointer? - guard prepare(sql, statement: &statement, operation: "prepare embedding save") else { - rollbackTransaction() - return - } - defer { sqlite3_finalize(statement) } - for (chunk, embedding) in zip(chunks, embeddings) { - sqlite3_reset(statement) - sqlite3_clear_bindings(statement) - bind(documentID, at: 1, statement: statement) - bind(model, at: 2, statement: statement) - bind(chunk.id, at: 3, statement: statement) - sqlite3_bind_int(statement, 4, Int32(chunk.pageIndex)) - sqlite3_bind_int(statement, 5, Int32(chunk.chunkIndex)) - bind(chunk.text, at: 6, statement: statement) - let data = Self.encodeEmbedding(embedding) - _ = data.withUnsafeBytes { bytes in - sqlite3_bind_blob(statement, 7, bytes.baseAddress, Int32(data.count), SQLITE_TRANSIENT) + let didSave = withTransaction { + let updatedAt = Date().timeIntervalSince1970 + var statement: OpaquePointer? + guard prepare(sql, statement: &statement, operation: "prepare embedding save") else { + return false } - sqlite3_bind_double(statement, 8, updatedAt) - if !step(statement, operation: "save embedding row") { - didWriteAllRows = false - break + defer { sqlite3_finalize(statement) } + for (chunk, embedding) in zip(chunks, embeddings) { + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + bind(documentID, at: 1, statement: statement) + bind(model, at: 2, statement: statement) + bind(chunk.id, at: 3, statement: statement) + sqlite3_bind_int(statement, 4, Int32(chunk.pageIndex)) + sqlite3_bind_int(statement, 5, Int32(chunk.chunkIndex)) + bind(chunk.text, at: 6, statement: statement) + let data = Self.encodeEmbedding(embedding) + _ = data.withUnsafeBytes { bytes in + sqlite3_bind_blob(statement, 7, bytes.baseAddress, Int32(data.count), SQLITE_TRANSIENT) + } + sqlite3_bind_double(statement, 8, updatedAt) + guard step(statement, operation: "save embedding row") else { + return false + } } + return true } - guard didWriteAllRows, commitTransaction() else { - rollbackTransaction() - return - } + guard didSave else { return } invalidateCacheSize() pruneIfNeeded(maximumBytes: Self.defaultMaximumCacheBytes) } @@ -260,16 +256,15 @@ final class PDFEmbeddingStore { return true } - private func beginTransaction() -> Bool { - exec("BEGIN IMMEDIATE TRANSACTION", operation: "begin embedding save transaction") - } - - private func commitTransaction() -> Bool { - exec("COMMIT", operation: "commit embedding save transaction") - } - - private func rollbackTransaction() { - exec("ROLLBACK", operation: "rollback embedding save transaction") + private func withTransaction(_ work: () -> Bool) -> Bool { + let operations = SQLiteTransactionExecutor.Operations( + begin: "begin embedding save transaction", + commit: "commit embedding save transaction", + rollback: "rollback embedding save transaction" + ) + return SQLiteTransactionExecutor(operations: operations) { [weak self] sql, operation in + self?.exec(sql, operation: operation) ?? false + }.perform(work) } private func vacuum() { diff --git a/mac-app/PersonalVocabularyProfileStore.swift b/mac-app/PersonalVocabularyProfileStore.swift index b30d6b7..8e074bb 100644 --- a/mac-app/PersonalVocabularyProfileStore.swift +++ b/mac-app/PersonalVocabularyProfileStore.swift @@ -8,8 +8,10 @@ final class PersonalVocabularyProfileStore { private let lock = NSLock() private var db: OpaquePointer? + private let shouldFailOperation: (String) -> Bool - init(databaseURL: URL?) { + init(databaseURL: URL?, shouldFailOperation: @escaping (String) -> Bool = { _ in false }) { + self.shouldFailOperation = shouldFailOperation guard let url = databaseURL else { NSLog("LeafReader personal vocabulary: no database URL available") return @@ -45,20 +47,14 @@ final class PersonalVocabularyProfileStore { func recordExposure(_ exposure: PersonalVocabularyExposure) -> Bool { locked { guard !exposure.documentID.isEmpty, !exposure.lemmaCounts.isEmpty else { return true } - guard beginTransaction() else { return false } - var didFail = false - for (lemma, count) in exposure.lemmaCounts where count > 0 { - guard upsertExposure(lemma: lemma, documentID: exposure.documentID, count: count, date: exposure.date) else { - didFail = true - break + return withTransaction { + for (lemma, count) in exposure.lemmaCounts where count > 0 { + guard upsertExposure(lemma: lemma, documentID: exposure.documentID, count: count, date: exposure.date) else { + return false + } } + return true } - if didFail { - rollbackTransaction() - return false - } - commitTransaction() - return true } } @@ -67,20 +63,14 @@ final class PersonalVocabularyProfileStore { let counts = PersonalVocabularyTokenizer.lemmaCounts(in: text) guard !counts.isEmpty else { return true } return locked { - guard beginTransaction() else { return false } - var didFail = false - for lemma in counts.keys { - guard upsertQuery(lemma: lemma, aiExplainCount: aiExplainCount, date: date) else { - didFail = true - break + withTransaction { + for lemma in counts.keys { + guard upsertQuery(lemma: lemma, aiExplainCount: aiExplainCount, date: date) else { + return false + } } + return true } - if didFail { - rollbackTransaction() - return false - } - commitTransaction() - return true } } @@ -126,14 +116,14 @@ final class PersonalVocabularyProfileStore { } } guard !noiseLemmas.isEmpty else { return } - guard beginTransaction() else { return } - for lemma in noiseLemmas { - guard deleteProfileLocked(lemma: lemma) else { - rollbackTransaction() - return + _ = withTransaction { + for lemma in noiseLemmas { + guard deleteProfileLocked(lemma: lemma) else { + return false + } } + return true } - commitTransaction() } private func upsertExposure(lemma: String, documentID: String, count: Int, date: Date) -> Bool { @@ -433,16 +423,10 @@ final class PersonalVocabularyProfileStore { return body() } - private func beginTransaction() -> Bool { - executeRaw("BEGIN IMMEDIATE TRANSACTION", operation: "begin transaction") - } - - private func commitTransaction() { - executeRaw("COMMIT", operation: "commit transaction") - } - - private func rollbackTransaction() { - executeRaw("ROLLBACK", operation: "rollback transaction") + private func withTransaction(_ work: () -> Bool) -> Bool { + SQLiteTransactionExecutor { [weak self] sql, operation in + self?.executeRaw(sql, operation: operation) ?? false + }.perform(work) } private func executeStatement(sql: String, operation: String, bind: (OpaquePointer?) -> Void) -> Bool { @@ -462,6 +446,10 @@ final class PersonalVocabularyProfileStore { @discardableResult private func executeRaw(_ sql: String, operation: String) -> Bool { + guard !shouldFailOperation(operation) else { + NSLog("LeafReader personal vocabulary: injected SQLite failure for %@", operation) + return false + } var errorMessage: UnsafeMutablePointer? let result = sqlite3_exec(db, sql, nil, nil, &errorMessage) if result == SQLITE_OK { diff --git a/mac-app/ReaderWindowController+WebScript.swift b/mac-app/ReaderWindowController+WebScript.swift index 0b53d95..ecd6ca6 100644 --- a/mac-app/ReaderWindowController+WebScript.swift +++ b/mac-app/ReaderWindowController+WebScript.swift @@ -6,10 +6,12 @@ extension ReaderWindowController { let overlay = bundledWebScript(named: "reader-web-overlay"), let search = bundledWebScript(named: "reader-web-search"), let marks = bundledWebScript(named: "reader-web-marks"), + let tts = bundledWebScript(named: "reader-web-tts"), + let selection = bundledWebScript(named: "reader-web-selection"), let main = bundledWebScript(named: "reader-web") else { return "" } - return [helper, overlay, search, marks, main].joined(separator: "\n") + return [helper, overlay, search, marks, tts, selection, main].joined(separator: "\n") } private static func bundledWebScript(named name: String) -> String? { diff --git a/mac-app/Resources/reader-web-selection.js b/mac-app/Resources/reader-web-selection.js new file mode 100644 index 0000000..a76f0b6 --- /dev/null +++ b/mac-app/Resources/reader-web-selection.js @@ -0,0 +1,149 @@ +(() => { + const root = typeof globalThis !== 'undefined' ? globalThis : this; + + const install = ({ occurrenceIndexInText, scrollProgress }) => { + let lastScrollSent = 0; + let documentMouseDown = false; + + const clearLegacySelectionSpanHighlights = () => { + document.querySelectorAll('span.leaf-reader-selection-highlight').forEach((span) => { + const parent = span.parentNode; + if (!parent) return; + while (span.firstChild) parent.insertBefore(span.firstChild, span); + parent.removeChild(span); + parent.normalize(); + }); + }; + const clearLegacySelectionOverlay = () => { + if (window.CSS && CSS.highlights) CSS.highlights.delete('leaf-reader-selection'); + clearLegacySelectionSpanHighlights(); + }; + window.leafReaderClearSelection = () => { + clearLegacySelectionOverlay(); + const selection = window.getSelection(); + if (selection) selection.removeAllRanges(); + window.webkit.messageHandlers.selectionChanged.postMessage({ text: '', context: '' }); + }; + window.leafReaderClearSelectionVisualOnly = () => { + clearLegacySelectionOverlay(); + const selection = window.getSelection(); + if (selection) selection.removeAllRanges(); + }; + + const sendSelection = () => { + const selection = window.getSelection(); + const text = String(selection || '').trim(); + let context = ''; + if (selection && selection.rangeCount > 0 && text.length > 0) { + const range = selection.getRangeAt(0); + const container = range.commonAncestorContainer; + const element = container.nodeType === Node.ELEMENT_NODE ? container : container.parentElement; + const block = element ? element.closest('p,li,blockquote,pre,td,th,h1,h2,h3,h4,h5,h6,div') : null; + const source = block ? (block.innerText || block.textContent || '') : text; + context = source.replace(/\s+/g, ' ').trim().slice(0, 360); + let occurrenceIndex = 0; + const firstLineRect = Array.from(range.getClientRects()).find((rect) => rect.width > 0 && rect.height > 0); + const selectionRect = firstLineRect || range.getBoundingClientRect(); + const rect = { + x: selectionRect.left, + y: selectionRect.top, + width: selectionRect.width, + height: selectionRect.height + }; + if (block) { + try { + const beforeRange = document.createRange(); + beforeRange.selectNodeContents(block); + beforeRange.setEnd(range.startContainer, range.startOffset); + occurrenceIndex = occurrenceIndexInText(source, text, beforeRange.toString()); + } catch (_) {} + } + window.webkit.messageHandlers.selectionChanged.postMessage({ text, context, occurrenceIndex, rect }); + return; + } + if (documentMouseDown) clearLegacySelectionOverlay(); + window.webkit.messageHandlers.selectionChanged.postMessage({ text, context, occurrenceIndex: null, rect: null }); + }; + const sendScroll = (force = false) => { + const now = Date.now(); + if (!force && now - lastScrollSent < 200) return; + lastScrollSent = now; + window.webkit.messageHandlers.scrollChanged.postMessage(scrollProgress()); + }; + + window.leafReaderJumpToHref = (href) => { + href = String(href || ''); + const fragment = href.includes('#') ? href.split('#').pop() : (href.startsWith('#') ? href.slice(1) : ''); + const path = href.split('#')[0]; + const sections = Array.from(document.querySelectorAll('section.reader-section[data-leaf-href]')); + const matchingSection = path ? sections.find((section) => { + const value = section.dataset.leafHref || ''; + return value === path || value.endsWith(`/${path}`) || path.endsWith(`/${value}`); + }) : null; + if (fragment) { + const target = matchingSection + ? (window.CSS && CSS.escape + ? matchingSection.querySelector(`#${CSS.escape(fragment)}`) + : Array.from(matchingSection.querySelectorAll('[id]')).find((element) => element.id === fragment)) + : document.getElementById(fragment); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return true; + } + } + if (matchingSection) { + matchingSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return true; + } + return false; + }; + + document.addEventListener('mousedown', () => { + documentMouseDown = true; + clearLegacySelectionOverlay(); + const selection = window.getSelection(); + if (selection) selection.removeAllRanges(); + window.webkit.messageHandlers.selectionChanged.postMessage({ text: '', context: '' }); + }); + document.addEventListener('click', (event) => { + const aiSource = event.target?.closest?.('span.leaf-reader-ai-source-underline'); + if (aiSource) { + event.preventDefault(); + event.stopPropagation(); + window.webkit.messageHandlers.webAISourceClicked.postMessage(String(aiSource.dataset.leafAiSourceKey || '')); + return; + } + const link = event.target?.closest?.('a[data-leaf-href]'); + if (link) { + event.preventDefault(); + event.stopPropagation(); + window.leafReaderJumpToHref(link.dataset.leafHref || ''); + return; + } + const word = event.target?.closest?.('span.leaf-reader-linked-word'); + if (word) { + event.preventDefault(); + event.stopPropagation(); + window.webkit.messageHandlers.webWordClicked.postMessage(String(word.dataset.leafWordId || '')); + return; + } + const note = event.target?.closest?.('span.leaf-reader-note-highlight'); + if (!note) return; + event.preventDefault(); + event.stopPropagation(); + window.webkit.messageHandlers.webNoteClicked.postMessage(String(note.dataset.leafNoteId || '')); + }, true); + document.addEventListener('selectionchange', () => setTimeout(sendSelection, 0)); + document.addEventListener('mouseup', () => { + documentMouseDown = false; + sendSelection(); + }); + document.addEventListener('keyup', sendSelection); + window.addEventListener('scroll', () => sendScroll(false), { passive: true }); + window.addEventListener('load', () => sendScroll(true)); + setTimeout(() => sendScroll(true), 250); + }; + + root.LeafReaderWebSelection = { install }; + if (typeof module !== 'undefined' && module.exports) module.exports = { install }; +})(); diff --git a/mac-app/Resources/reader-web-tts.js b/mac-app/Resources/reader-web-tts.js new file mode 100644 index 0000000..6cc9778 --- /dev/null +++ b/mac-app/Resources/reader-web-tts.js @@ -0,0 +1,256 @@ +(() => { + const root = typeof globalThis !== 'undefined' ? globalThis : this; + + const install = (dependencies) => { + const { + installReaderOverlayStyle, + normalizedText, + normalizedIndexForRoot, + rangeFromNormalizedSpan, + rangeForNormalizedText, + leafReaderWordCount, + leafReaderHasCJK, + leafReaderSentenceSegments, + wrapRangeTextNodes, + unwrapSpans, + aiSourceKeyForRanges, + linkedWordIDsForRanges + } = dependencies; + const maxChineseSegmentLength = 120; + const maxBatchSegments = 16; + const maxBatchCharacters = 2400; + let anchorRanges = []; + let batchEndRange = null; + + const scrollProgress = () => { + const height = Math.max(1, document.documentElement.scrollHeight - window.innerHeight); + return Math.max(0, Math.min(1, window.scrollY / height)); + }; + const visibleRangeRect = (range) => { + if (!range) return null; + return Array.from(range.getClientRects()).find((item) => item.width > 0 && item.height > 0) || range.getBoundingClientRect(); + }; + const progressForRange = (range) => { + const rect = visibleRangeRect(range); + const height = Math.max(1, document.documentElement.scrollHeight - window.innerHeight); + const top = Math.max(0, (window.scrollY || 0) + (rect?.top || 0)); + return Math.max(0, Math.min(1, top / height)); + }; + const scrollRangeToCenter = (range) => { + const rect = visibleRangeRect(range); + if (rect && rect.height > 0) { + window.scrollBy({ top: rect.top - window.innerHeight * 0.42, behavior: 'smooth' }); + } + }; + const candidateBlocks = () => { + const primarySelector = 'p,li,blockquote,pre,td,th,h1,h2,h3,h4,h5,h6'; + const fallbackSelector = 'div,section,article'; + const primaryBlocks = Array.from(document.body.querySelectorAll(primarySelector)); + const fallbackBlocks = Array.from(document.body.querySelectorAll(fallbackSelector)).filter((block) => { + return !block.querySelector(primarySelector); + }); + const blocks = primaryBlocks.concat(fallbackBlocks).filter((block) => { + if (!normalizedText(block.innerText || block.textContent || '')) return false; + const rect = block.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }); + const visible = blocks.filter((block) => { + const rect = block.getBoundingClientRect(); + return rect.bottom >= 0 && rect.top <= window.innerHeight; + }); + return visible.concat(blocks.filter((block) => !visible.includes(block))); + }; + const blocksFromVisibleTop = () => { + const blocks = candidateBlocks().slice().sort((a, b) => { + if (a === b) return 0; + return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; + }); + const visibleTop = Math.max(0, window.scrollY || 0); + const startIndex = blocks.findIndex((block) => { + const rect = block.getBoundingClientRect(); + return rect.bottom + visibleTop >= visibleTop; + }); + return startIndex >= 0 ? blocks.slice(startIndex) : blocks; + }; + const containedRanges = (needle, blocks) => { + const ranges = []; + const seen = new Set(); + for (const block of blocks) { + const source = normalizedText(block.innerText || block.textContent || ''); + if (source.length < 8) continue; + if (!needle.includes(source.slice(0, Math.min(80, source.length)))) continue; + const range = rangeForNormalizedText(block, source, 0); + if (!range) continue; + const key = `${range.startContainer}_${range.startOffset}_${range.endContainer}_${range.endOffset}`; + if (seen.has(key)) continue; + seen.add(key); + ranges.push(range); + } + return ranges; + }; + const rangesForText = (needle) => { + const blocks = candidateBlocks(); + for (const block of blocks) { + const source = normalizedText(block.innerText || block.textContent || ''); + if (!source.includes(needle.slice(0, Math.min(80, needle.length)))) continue; + const range = rangeForNormalizedText(block, needle, 0); + if (range) return [range]; + } + const bodyRange = rangeForNormalizedText(document.body, needle, 0); + return bodyRange ? [bodyRange] : containedRanges(needle, blocks); + }; + const rangeKey = (range) => `${range.startContainer}_${range.startOffset}_${range.endContainer}_${range.endOffset}`; + const rangesText = (ranges) => ranges + .map((range) => String(range.toString ? range.toString() : '')) + .join(' ') + .trim(); + const rangesMatchText = (ranges, text) => { + const rangeText = normalizedText(rangesText(ranges)); + const targetText = normalizedText(text); + if (!rangeText || !targetText) return false; + return rangeText.includes(targetText) || targetText.includes(rangeText); + }; + const applyRanges = (ranges) => { + for (const range of ranges.slice().reverse()) { + wrapRangeTextNodes(range, 'leaf-reader-tts-underline'); + } + }; + const segmentRangesForBlock = (block) => { + const rawText = block.innerText || block.textContent || ''; + const segments = leafReaderSentenceSegments(rawText); + if (!segments.length) return []; + const normalizedIndex = normalizedIndexForRoot(block); + let cursor = 0; + const ranges = []; + for (const segment of segments) { + const needle = normalizedText(segment); + if (!needle) continue; + let matchIndex = normalizedIndex.text.indexOf(needle, cursor); + if (matchIndex < 0) matchIndex = normalizedIndex.text.indexOf(needle); + if (matchIndex < 0) continue; + const range = rangeFromNormalizedSpan(normalizedIndex, matchIndex, needle.length); + if (!range) continue; + ranges.push({ range, text: segment }); + cursor = matchIndex + Math.max(1, needle.length); + } + return ranges; + }; + + window.leafReaderClearTTSUnderline = () => { + if (window.CSS && CSS.highlights) CSS.highlights.delete('leaf-reader-tts'); + unwrapSpans('span.leaf-reader-tts-underline'); + }; + window.leafReaderPrepareReadAloudBatch = () => { + installReaderOverlayStyle(); + window.leafReaderClearTTSUnderline(); + if (window.leafReaderClearSelectionVisualOnly) { + window.leafReaderClearSelectionVisualOnly(); + } else { + const selection = window.getSelection && window.getSelection(); + if (selection) selection.removeAllRanges(); + } + const blocks = blocksFromVisibleTop(); + const seen = new Set(); + const rawSegments = []; + anchorRanges = []; + batchEndRange = null; + for (const block of blocks) { + for (const item of segmentRangesForBlock(block)) { + const rect = visibleRangeRect(item.range); + if (rect && rect.bottom < window.innerHeight * 0.05) continue; + const key = rangeKey(item.range); + if (seen.has(key)) continue; + seen.add(key); + rawSegments.push(item); + } + } + const segments = []; + let pendingText = ''; + let pendingRanges = []; + let pendingWordCount = 0; + let consumedRawCount = 0; + let reachedBatchLimit = false; + const flushPending = () => { + const text = pendingText.trim(); + if (text && pendingRanges.length) { + segments.push({ text, speechText: text }); + anchorRanges.push(pendingRanges); + batchEndRange = pendingRanges[pendingRanges.length - 1] || batchEndRange; + } + pendingText = ''; + pendingRanges = []; + pendingWordCount = 0; + }; + for (const item of rawSegments) { + const text = String(item.text || '').trim(); + if (!text) continue; + consumedRawCount += 1; + pendingText = pendingText ? `${pendingText} ${text}` : text; + pendingRanges.push(item.range); + pendingWordCount += leafReaderWordCount(text); + if (leafReaderHasCJK(pendingText) || pendingText.length >= maxChineseSegmentLength || pendingWordCount >= 4) { + flushPending(); + const totalCharacters = segments.reduce((sum, segment) => sum + String(segment.text || '').length, 0); + if (segments.length >= maxBatchSegments || totalCharacters >= maxBatchCharacters) { + reachedBatchLimit = true; + break; + } + } + } + if (pendingText && !reachedBatchLimit) { + if (segments.length && anchorRanges.length) { + const last = segments[segments.length - 1]; + last.text = `${last.text} ${pendingText.trim()}`; + last.speechText = last.text; + anchorRanges[anchorRanges.length - 1] = anchorRanges[anchorRanges.length - 1].concat(pendingRanges); + batchEndRange = pendingRanges[pendingRanges.length - 1] || batchEndRange; + } else { + flushPending(); + } + } + return { segments, hasMore: consumedRawCount < rawSegments.length }; + }; + window.leafReaderPrepareReadAloudSegments = () => { + const batch = window.leafReaderPrepareReadAloudBatch(); + return batch.segments || []; + }; + window.leafReaderAdvanceReadAloudBatch = () => { + const range = batchEndRange; + if (!range) return { ok: false, progress: scrollProgress() }; + const rect = visibleRangeRect(range); + if (!rect || rect.height <= 0) return { ok: false, progress: scrollProgress() }; + const delta = rect.bottom - window.innerHeight * 0.18; + if (Math.abs(delta) > 1) window.scrollBy({ top: delta, behavior: 'auto' }); + return { ok: true, progress: scrollProgress() }; + }; + window.leafReaderUnderlineTTSIndex = (segmentIndex, fallbackText) => { + installReaderOverlayStyle(); + window.leafReaderClearTTSUnderline(); + const numericIndex = Number(segmentIndex || 0); + if (!numericIndex || numericIndex < 1) return window.leafReaderUnderlineTTS(fallbackText); + const ranges = anchorRanges[numericIndex - 1] || []; + if (!ranges.length || !rangesMatchText(ranges, fallbackText)) return window.leafReaderUnderlineTTS(fallbackText); + return underlineRanges(ranges); + }; + const underlineRanges = (ranges) => { + const sourceKey = aiSourceKeyForRanges(ranges); + const wordIDs = linkedWordIDsForRanges(ranges); + const progress = progressForRange(ranges[0]); + applyRanges(ranges); + scrollRangeToCenter(ranges[0]); + return { ok: true, sourceKey, wordID: wordIDs[0] || '', wordIDs, progress }; + }; + window.leafReaderUnderlineTTS = (targetText) => { + installReaderOverlayStyle(); + window.leafReaderClearTTSUnderline(); + const needle = normalizedText(targetText); + if (!needle) return false; + const ranges = rangesForText(needle); + return ranges.length ? underlineRanges(ranges) : false; + }; + return { scrollProgress }; + }; + + root.LeafReaderWebTTS = { install }; + if (typeof module !== 'undefined' && module.exports) module.exports = { install }; +})(); diff --git a/mac-app/Resources/reader-web.js b/mac-app/Resources/reader-web.js index 9ebe988..1e4b1ad 100644 --- a/mac-app/Resources/reader-web.js +++ b/mac-app/Resources/reader-web.js @@ -1,8 +1,10 @@ (() => { - var lastScrollSent = 0; - var documentMouseDown = false; - if (typeof module !== 'undefined' && module.exports) { module.exports = require('./reader-web-text.js'); return; } + if (typeof module !== 'undefined' && module.exports) { + module.exports = require('./reader-web-text.js'); + return; + } if (typeof window === 'undefined' || typeof document === 'undefined') return; + const { installReaderOverlayStyle } = window.LeafReaderWebOverlay; const { normalizedText, @@ -16,7 +18,7 @@ leafReaderHasCJK, leafReaderSentenceSegments } = window.LeafReaderWebText; - const leafReaderMaxChineseTTSSegmentLength = 120; + const unwrapSpans = (selector) => { document.querySelectorAll(selector).forEach((span) => { const parent = span.parentNode; @@ -26,12 +28,14 @@ parent.normalize(); }); }; + const leafReaderSearchAPI = window.LeafReaderWebSearch.makeSearchAPI({ installReaderOverlayStyle, leafReaderFindSearchSpans }); window.leafReaderClearSearchHighlights = leafReaderSearchAPI.clearSearchHighlights; window.leafReaderSearch = leafReaderSearchAPI.search; + window.leafReaderFindTextRange = (word, context, occurrenceIndex = 0) => { const normalizedWord = normalizedText(word); const normalizedContext = normalizedText(context); @@ -71,16 +75,16 @@ } return null; }; + const wrapRangeTextNodes = (range, className, configureSpan = null) => { const walker = document.createTreeWalker(range.commonAncestorContainer, NodeFilter.SHOW_TEXT); const targets = []; let node; while ((node = walker.nextNode())) { if (!range.intersectsNode(node)) continue; - let start = node === range.startContainer ? range.startOffset : 0; - let end = node === range.endContainer ? range.endOffset : (node.nodeValue || '').length; - if (end <= start) continue; - if (!(node.nodeValue || '').slice(start, end).trim()) continue; + const start = node === range.startContainer ? range.startOffset : 0; + const end = node === range.endContainer ? range.endOffset : (node.nodeValue || '').length; + if (end <= start || !(node.nodeValue || '').slice(start, end).trim()) continue; targets.push({ node, start, end }); } if (range.startContainer.nodeType === Node.TEXT_NODE && !targets.some((target) => target.node === range.startContainer)) { @@ -101,398 +105,42 @@ } return targets.length > 0; }; - const leafReaderMarksAPI = window.LeafReaderWebMarks.makeMarksAPI({ + + const marks = window.LeafReaderWebMarks.makeMarksAPI({ installReaderOverlayStyle, normalizedText, wrapRangeTextNodes, findTextRange: window.leafReaderFindTextRange, unwrapSpans }); - const leafReaderAISourceKeyForRanges = leafReaderMarksAPI.aiSourceKeyForRanges; - const leafReaderLinkedWordIDsForRanges = leafReaderMarksAPI.linkedWordIDsForRanges; - window.leafReaderClearAISourceUnderlines = leafReaderMarksAPI.clearAISourceUnderlines; - window.leafReaderAddAISourceUnderlineForSelection = leafReaderMarksAPI.addAISourceUnderlineForSelection; - window.leafReaderRestoreAISourceUnderlines = leafReaderMarksAPI.restoreAISourceUnderlines; - window.leafReaderRestoreWordHighlights = leafReaderMarksAPI.restoreWordHighlights; - window.leafReaderMarkSelectionAsWord = leafReaderMarksAPI.markSelectionAsWord; - window.leafReaderRestoreNoteHighlights = leafReaderMarksAPI.restoreNoteHighlights; - window.leafReaderMarkSelectionAsNote = leafReaderMarksAPI.markSelectionAsNote; - window.leafReaderScrollToNote = leafReaderMarksAPI.scrollToNote; - window.leafReaderRemoveNoteHighlight = leafReaderMarksAPI.removeNoteHighlight; - window.leafReaderRemoveWordHighlight = leafReaderMarksAPI.removeWordHighlight; - window.leafReaderScrollToWord = leafReaderMarksAPI.scrollToWord; - window.leafReaderClearTTSUnderline = () => { - if (window.CSS && CSS.highlights) CSS.highlights.delete('leaf-reader-tts'); - unwrapSpans('span.leaf-reader-tts-underline'); - }; - const leafReaderCandidateTTSBlocks = () => { - const primarySelector = 'p,li,blockquote,pre,td,th,h1,h2,h3,h4,h5,h6'; - const fallbackSelector = 'div,section,article'; - const primaryBlocks = Array.from(document.body.querySelectorAll(primarySelector)); - const fallbackBlocks = Array.from(document.body.querySelectorAll(fallbackSelector)).filter((block) => { - return !block.querySelector(primarySelector); - }); - const blocks = primaryBlocks.concat(fallbackBlocks).filter((block) => { - if (!normalizedText(block.innerText || block.textContent || '')) return false; - const rect = block.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }); - const visible = blocks.filter((block) => { - const rect = block.getBoundingClientRect(); - return rect.bottom >= 0 && rect.top <= window.innerHeight; - }); - return visible.concat(blocks.filter((block) => !visible.includes(block))); - }; - const leafReaderBlocksFromVisibleTop = () => { - const blocks = leafReaderCandidateTTSBlocks().slice().sort((a, b) => { - if (a === b) return 0; - return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; - }); - const visibleTop = Math.max(0, window.scrollY || 0); - const startIndex = blocks.findIndex((block) => { - const rect = block.getBoundingClientRect(); - return rect.bottom + visibleTop >= visibleTop; - }); - return startIndex >= 0 ? blocks.slice(startIndex) : blocks; - }; - let leafReaderTTSAnchorRanges = []; - let leafReaderReadAloudBatchEndRange = null; - const leafReaderMaxReadAloudBatchSegments = 16; - const leafReaderMaxReadAloudBatchCharacters = 2400; - const leafReaderScrollProgress = () => { - const height = Math.max(1, document.documentElement.scrollHeight - window.innerHeight); - return Math.max(0, Math.min(1, window.scrollY / height)); - }; - const leafReaderVisibleRangeRect = (range) => { - if (!range) return null; - return Array.from(range.getClientRects()).find((item) => item.width > 0 && item.height > 0) || range.getBoundingClientRect(); - }; - const leafReaderProgressForRange = (range) => { - const rect = leafReaderVisibleRangeRect(range); - const height = Math.max(1, document.documentElement.scrollHeight - window.innerHeight); - const top = Math.max(0, (window.scrollY || 0) + (rect?.top || 0)); - return Math.max(0, Math.min(1, top / height)); - }; - const leafReaderScrollRangeToCenter = (range) => { - const rect = leafReaderVisibleRangeRect(range); - if (rect && rect.height > 0) { - window.scrollBy({ top: rect.top - window.innerHeight * 0.42, behavior: 'smooth' }); - } - }; - const leafReaderContainedTTSRanges = (needle, blocks) => { - const ranges = []; - const seen = new Set(); - for (const block of blocks) { - const source = normalizedText(block.innerText || block.textContent || ''); - if (source.length < 8) continue; - if (!needle.includes(source.slice(0, Math.min(80, source.length)))) continue; - const range = rangeForNormalizedText(block, source, 0); - if (!range) continue; - const key = `${range.startContainer}_${range.startOffset}_${range.endContainer}_${range.endOffset}`; - if (seen.has(key)) continue; - seen.add(key); - ranges.push(range); - } - return ranges; - }; - const leafReaderTTSRanges = (needle) => { - const blocks = leafReaderCandidateTTSBlocks(); - for (const block of blocks) { - const source = normalizedText(block.innerText || block.textContent || ''); - if (!source.includes(needle.slice(0, Math.min(80, needle.length)))) continue; - const range = rangeForNormalizedText(block, needle, 0); - if (range) return [range]; - } - const bodyRange = rangeForNormalizedText(document.body, needle, 0); - if (bodyRange) return [bodyRange]; - return leafReaderContainedTTSRanges(needle, blocks); - }; - const leafReaderRangeKey = (range) => `${range.startContainer}_${range.startOffset}_${range.endContainer}_${range.endOffset}`; - const leafReaderRangesText = (ranges) => ranges - .map((range) => String(range.toString ? range.toString() : '')) - .join(' ') - .trim(); - const leafReaderRangesMatchText = (ranges, text) => { - const rangeText = normalizedText(leafReaderRangesText(ranges)); - const targetText = normalizedText(text); - if (!rangeText || !targetText) return false; - return rangeText.includes(targetText) || targetText.includes(rangeText); - }; - const leafReaderApplyTTSRanges = (ranges) => { - for (const range of ranges.slice().reverse()) { - wrapRangeTextNodes(range, 'leaf-reader-tts-underline'); - } - }; - const leafReaderSegmentRangesForBlock = (block) => { - const rawText = block.innerText || block.textContent || ''; - const segments = leafReaderSentenceSegments(rawText); - if (!segments.length) return []; - const normalizedIndex = normalizedIndexForRoot(block); - let cursor = 0; - const ranges = []; - for (const segment of segments) { - const needle = normalizedText(segment); - if (!needle) continue; - let matchIndex = normalizedIndex.text.indexOf(needle, cursor); - if (matchIndex < 0) matchIndex = normalizedIndex.text.indexOf(needle); - if (matchIndex < 0) continue; - const range = rangeFromNormalizedSpan(normalizedIndex, matchIndex, needle.length); - if (!range) continue; - ranges.push({ range, text: segment }); - cursor = matchIndex + Math.max(1, needle.length); - } - return ranges; - }; - window.leafReaderPrepareReadAloudBatch = () => { - installReaderOverlayStyle(); - window.leafReaderClearTTSUnderline(); - if (window.leafReaderClearSelectionVisualOnly) { - window.leafReaderClearSelectionVisualOnly(); - } else { - const selection = window.getSelection && window.getSelection(); - if (selection) selection.removeAllRanges(); - } - const blocks = leafReaderBlocksFromVisibleTop(); - const seen = new Set(); - const rawSegments = []; - leafReaderTTSAnchorRanges = []; - leafReaderReadAloudBatchEndRange = null; - for (const block of blocks) { - for (const item of leafReaderSegmentRangesForBlock(block)) { - const rect = leafReaderVisibleRangeRect(item.range); - if (rect && rect.bottom < window.innerHeight * 0.05) continue; - const key = leafReaderRangeKey(item.range); - if (seen.has(key)) continue; - seen.add(key); - rawSegments.push(item); - } - } - const segments = []; - let pendingText = ''; - let pendingRanges = []; - let pendingWordCount = 0; - let consumedRawCount = 0; - let reachedBatchLimit = false; - const flushPending = () => { - const text = pendingText.trim(); - if (text && pendingRanges.length) { - segments.push({ text, speechText: text }); - leafReaderTTSAnchorRanges.push(pendingRanges); - leafReaderReadAloudBatchEndRange = pendingRanges[pendingRanges.length - 1] || leafReaderReadAloudBatchEndRange; - } - pendingText = ''; - pendingRanges = []; - pendingWordCount = 0; - }; - for (const item of rawSegments) { - const text = String(item.text || '').trim(); - if (!text) continue; - consumedRawCount += 1; - pendingText = pendingText ? `${pendingText} ${text}` : text; - pendingRanges.push(item.range); - pendingWordCount += leafReaderWordCount(text); - if (leafReaderHasCJK(pendingText) || pendingText.length >= leafReaderMaxChineseTTSSegmentLength || pendingWordCount >= 4) { - flushPending(); - const totalCharacters = segments.reduce((sum, segment) => sum + String(segment.text || '').length, 0); - if (segments.length >= leafReaderMaxReadAloudBatchSegments || totalCharacters >= leafReaderMaxReadAloudBatchCharacters) { - reachedBatchLimit = true; - break; - } - } - } - if (pendingText && !reachedBatchLimit) { - if (segments.length && leafReaderTTSAnchorRanges.length) { - const last = segments[segments.length - 1]; - last.text = `${last.text} ${pendingText.trim()}`; - last.speechText = last.text; - leafReaderTTSAnchorRanges[leafReaderTTSAnchorRanges.length - 1] = - leafReaderTTSAnchorRanges[leafReaderTTSAnchorRanges.length - 1].concat(pendingRanges); - leafReaderReadAloudBatchEndRange = pendingRanges[pendingRanges.length - 1] || leafReaderReadAloudBatchEndRange; - } else { - flushPending(); - } - } - return { segments, hasMore: consumedRawCount < rawSegments.length }; - }; - window.leafReaderPrepareReadAloudSegments = () => { - const batch = window.leafReaderPrepareReadAloudBatch(); - return batch.segments || []; - }; - window.leafReaderAdvanceReadAloudBatch = () => { - const range = leafReaderReadAloudBatchEndRange; - if (!range) return { ok: false, progress: leafReaderScrollProgress() }; - const rect = leafReaderVisibleRangeRect(range); - if (!rect || rect.height <= 0) return { ok: false, progress: leafReaderScrollProgress() }; - const delta = rect.bottom - window.innerHeight * 0.18; - if (Math.abs(delta) > 1) { - window.scrollBy({ top: delta, behavior: 'auto' }); - } - return { ok: true, progress: leafReaderScrollProgress() }; - }; - window.leafReaderUnderlineTTSIndex = (segmentIndex, fallbackText) => { - installReaderOverlayStyle(); - window.leafReaderClearTTSUnderline(); - const numericIndex = Number(segmentIndex || 0); - if (!numericIndex || numericIndex < 1) return window.leafReaderUnderlineTTS(fallbackText); - const index = numericIndex - 1; - const ranges = leafReaderTTSAnchorRanges[index] || []; - if (!ranges.length || !leafReaderRangesMatchText(ranges, fallbackText)) { - return window.leafReaderUnderlineTTS(fallbackText); - } - const sourceKey = leafReaderAISourceKeyForRanges(ranges); - const wordIDs = leafReaderLinkedWordIDsForRanges(ranges); - const progress = leafReaderProgressForRange(ranges[0]); - leafReaderApplyTTSRanges(ranges); - leafReaderScrollRangeToCenter(ranges[0]); - return { ok: true, sourceKey, wordID: wordIDs[0] || '', wordIDs, progress }; - }; - window.leafReaderUnderlineTTS = (targetText) => { - installReaderOverlayStyle(); - window.leafReaderClearTTSUnderline(); - const needle = normalizedText(targetText); - if (!needle) return false; - const ranges = leafReaderTTSRanges(needle); - if (!ranges.length) return false; - const sourceKey = leafReaderAISourceKeyForRanges(ranges); - const wordIDs = leafReaderLinkedWordIDsForRanges(ranges); - const progress = leafReaderProgressForRange(ranges[0]); - leafReaderApplyTTSRanges(ranges); - leafReaderScrollRangeToCenter(ranges[0]); - return { ok: true, sourceKey, wordID: wordIDs[0] || '', wordIDs, progress }; - }; - const clearLegacySelectionSpanHighlights = () => { - document.querySelectorAll('span.leaf-reader-selection-highlight').forEach((span) => { - const parent = span.parentNode; - if (!parent) return; - while (span.firstChild) parent.insertBefore(span.firstChild, span); - parent.removeChild(span); - parent.normalize(); - }); - }; - const clearLegacySelectionOverlay = () => { - if (window.CSS && CSS.highlights) CSS.highlights.delete('leaf-reader-selection'); - clearLegacySelectionSpanHighlights(); - }; - window.leafReaderClearSelection = () => { - clearLegacySelectionOverlay(); - const selection = window.getSelection(); - if (selection) selection.removeAllRanges(); - window.webkit.messageHandlers.selectionChanged.postMessage({ text: "", context: "" }); - }; - window.leafReaderClearSelectionVisualOnly = () => { - clearLegacySelectionOverlay(); - const selection = window.getSelection(); - if (selection) selection.removeAllRanges(); - }; - const sendSelection = () => { - const selection = window.getSelection(); - const text = String(selection || "").trim(); - let context = ""; - if (selection && selection.rangeCount > 0 && text.length > 0) { - const container = selection.getRangeAt(0).commonAncestorContainer; - const element = container.nodeType === Node.ELEMENT_NODE ? container : container.parentElement; - const block = element ? element.closest('p,li,blockquote,pre,td,th,h1,h2,h3,h4,h5,h6,div') : null; - const source = block ? (block.innerText || block.textContent || "") : text; - context = source.replace(/\s+/g, " ").trim().slice(0, 360); - let occurrenceIndex = 0; - const range = selection.getRangeAt(0); - const firstLineRect = Array.from(range.getClientRects()).find((rect) => rect.width > 0 && rect.height > 0); - const selectionRect = firstLineRect || range.getBoundingClientRect(); - const rect = { - x: selectionRect.left, - y: selectionRect.top, - width: selectionRect.width, - height: selectionRect.height - }; - if (block) { - try { - const beforeRange = document.createRange(); - beforeRange.selectNodeContents(block); - beforeRange.setEnd(selection.getRangeAt(0).startContainer, selection.getRangeAt(0).startOffset); - occurrenceIndex = occurrenceIndexInText(source, text, beforeRange.toString()); - } catch (_) {} - } - window.webkit.messageHandlers.selectionChanged.postMessage({ text, context, occurrenceIndex, rect }); - return; - } else if (documentMouseDown) { - clearLegacySelectionOverlay(); - } - window.webkit.messageHandlers.selectionChanged.postMessage({ text, context, occurrenceIndex: null, rect: null }); - }; - const sendScroll = (force = false) => { - const now = Date.now(); - if (!force && now - lastScrollSent < 200) return; - lastScrollSent = now; - window.webkit.messageHandlers.scrollChanged.postMessage(leafReaderScrollProgress()); - }; - window.leafReaderJumpToHref = (href) => { - href = String(href || ''); - const fragment = href.includes('#') ? href.split('#').pop() : (href.startsWith('#') ? href.slice(1) : ''); - const path = href.split('#')[0]; - const sections = Array.from(document.querySelectorAll('section.reader-section[data-leaf-href]')); - const matchingSection = path ? sections.find((section) => { - const value = section.dataset.leafHref || ''; - return value === path || value.endsWith('/' + path) || path.endsWith('/' + value); - }) : null; - if (fragment) { - const target = matchingSection - ? (window.CSS && CSS.escape - ? matchingSection.querySelector(`#${CSS.escape(fragment)}`) - : Array.from(matchingSection.querySelectorAll('[id]')).find(el => el.id === fragment)) - : document.getElementById(fragment); - if (target) { - target.scrollIntoView({ behavior: 'smooth', block: 'start' }); - return true; - } - } - if (matchingSection) { - matchingSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); - return true; - } - return false; - }; - document.addEventListener("mousedown", () => { - documentMouseDown = true; - clearLegacySelectionOverlay(); - const selection = window.getSelection(); - if (selection) selection.removeAllRanges(); - window.webkit.messageHandlers.selectionChanged.postMessage({ text: "", context: "" }); + window.leafReaderClearAISourceUnderlines = marks.clearAISourceUnderlines; + window.leafReaderAddAISourceUnderlineForSelection = marks.addAISourceUnderlineForSelection; + window.leafReaderRestoreAISourceUnderlines = marks.restoreAISourceUnderlines; + window.leafReaderRestoreWordHighlights = marks.restoreWordHighlights; + window.leafReaderMarkSelectionAsWord = marks.markSelectionAsWord; + window.leafReaderRestoreNoteHighlights = marks.restoreNoteHighlights; + window.leafReaderMarkSelectionAsNote = marks.markSelectionAsNote; + window.leafReaderScrollToNote = marks.scrollToNote; + window.leafReaderRemoveNoteHighlight = marks.removeNoteHighlight; + window.leafReaderRemoveWordHighlight = marks.removeWordHighlight; + window.leafReaderScrollToWord = marks.scrollToWord; + + const tts = window.LeafReaderWebTTS.install({ + installReaderOverlayStyle, + normalizedText, + normalizedIndexForRoot, + rangeFromNormalizedSpan, + rangeForNormalizedText, + leafReaderWordCount, + leafReaderHasCJK, + leafReaderSentenceSegments, + wrapRangeTextNodes, + unwrapSpans, + aiSourceKeyForRanges: marks.aiSourceKeyForRanges, + linkedWordIDsForRanges: marks.linkedWordIDsForRanges }); - document.addEventListener("click", (event) => { - const aiSource = event.target?.closest?.('span.leaf-reader-ai-source-underline'); - if (aiSource) { - event.preventDefault(); - event.stopPropagation(); - window.webkit.messageHandlers.webAISourceClicked.postMessage(String(aiSource.dataset.leafAiSourceKey || '')); - return; - } - const link = event.target?.closest?.('a[data-leaf-href]'); - if (link) { - event.preventDefault(); - event.stopPropagation(); - window.leafReaderJumpToHref(link.dataset.leafHref || ''); - return; - } - const target = event.target?.closest?.('span.leaf-reader-linked-word'); - if (target) { - event.preventDefault(); - event.stopPropagation(); - window.webkit.messageHandlers.webWordClicked.postMessage(String(target.dataset.leafWordId || '')); - return; - } - const note = event.target?.closest?.('span.leaf-reader-note-highlight'); - if (!note) return; - event.preventDefault(); - event.stopPropagation(); - window.webkit.messageHandlers.webNoteClicked.postMessage(String(note.dataset.leafNoteId || '')); - }, true); - document.addEventListener("selectionchange", () => setTimeout(sendSelection, 0)); - document.addEventListener("mouseup", () => { - documentMouseDown = false; - sendSelection(); + window.LeafReaderWebSelection.install({ + occurrenceIndexInText, + scrollProgress: tts.scrollProgress }); - document.addEventListener("keyup", sendSelection); - window.addEventListener("scroll", () => sendScroll(false), { passive: true }); - window.addEventListener("load", () => sendScroll(true)); - setTimeout(() => sendScroll(true), 250); })(); diff --git a/mac-app/SQLiteTransactionExecutor.swift b/mac-app/SQLiteTransactionExecutor.swift new file mode 100644 index 0000000..29ce34a --- /dev/null +++ b/mac-app/SQLiteTransactionExecutor.swift @@ -0,0 +1,46 @@ +import Foundation + +struct SQLiteTransactionExecutor { + struct Operations { + let begin: String + let commit: String + let rollback: String + + static let standard = Operations( + begin: "begin transaction", + commit: "commit transaction", + rollback: "rollback transaction" + ) + } + + private let operations: Operations + private let execute: (_ sql: String, _ operation: String) -> Bool + + init( + operations: Operations = .standard, + execute: @escaping (_ sql: String, _ operation: String) -> Bool + ) { + self.operations = operations + self.execute = execute + } + + @discardableResult + func perform(_ work: () -> Bool) -> Bool { + guard execute("BEGIN IMMEDIATE TRANSACTION", operations.begin) else { + return false + } + guard work() else { + rollback() + return false + } + guard execute("COMMIT", operations.commit) else { + rollback() + return false + } + return true + } + + private func rollback() { + _ = execute("ROLLBACK", operations.rollback) + } +} diff --git a/mac-app/WordRecordSQLiteStore.swift b/mac-app/WordRecordSQLiteStore.swift index f9fdc34..d875d91 100644 --- a/mac-app/WordRecordSQLiteStore.swift +++ b/mac-app/WordRecordSQLiteStore.swift @@ -52,28 +52,19 @@ final class WordRecordSQLiteStore { @discardableResult func savePDFRecords(documentID: String, records: [StoredPDFWordRecord]) -> Bool { locked { - guard beginTransaction() else { return false } - guard execute(sql: "DELETE FROM pdf_word_records WHERE document_id = ?", bindings: [documentID], operation: "delete existing PDF records") else { - rollbackTransaction() - return false - } - - var didFail = false - for record in records { - guard insertPDFRecord(documentID: documentID, record: record, prepareOperation: "prepare save PDF record", stepOperation: "insert PDF record") else { - didFail = true - break + withTransaction { + guard execute(sql: "DELETE FROM pdf_word_records WHERE document_id = ?", bindings: [documentID], operation: "delete existing PDF records") else { + return false + } + return records.allSatisfy { record in + insertPDFRecord( + documentID: documentID, + record: record, + prepareOperation: "prepare save PDF record", + stepOperation: "insert PDF record" + ) } } - if didFail { - rollbackTransaction() - return false - } - guard commitTransaction() else { - rollbackTransaction() - return false - } - return true } } @@ -105,28 +96,19 @@ final class WordRecordSQLiteStore { @discardableResult func saveWebRecords(documentID: String, records: [StoredWebWordRecord]) -> Bool { locked { - guard beginTransaction() else { return false } - guard execute(sql: "DELETE FROM web_word_records WHERE document_id = ?", bindings: [documentID], operation: "delete existing web records") else { - rollbackTransaction() - return false - } - - var didFail = false - for record in records { - guard insertWebRecord(documentID: documentID, record: record, prepareOperation: "prepare save web record", stepOperation: "insert web record") else { - didFail = true - break + withTransaction { + guard execute(sql: "DELETE FROM web_word_records WHERE document_id = ?", bindings: [documentID], operation: "delete existing web records") else { + return false + } + return records.allSatisfy { record in + insertWebRecord( + documentID: documentID, + record: record, + prepareOperation: "prepare save web record", + stepOperation: "insert web record" + ) } } - if didFail { - rollbackTransaction() - return false - } - guard commitTransaction() else { - rollbackTransaction() - return false - } - return true } } @@ -213,16 +195,10 @@ final class WordRecordSQLiteStore { return body() } - private func beginTransaction() -> Bool { - executeRaw("BEGIN IMMEDIATE TRANSACTION", operation: "begin transaction") - } - - private func commitTransaction() -> Bool { - executeRaw("COMMIT", operation: "commit transaction") - } - - private func rollbackTransaction() { - executeRaw("ROLLBACK", operation: "rollback transaction") + private func withTransaction(_ work: () -> Bool) -> Bool { + SQLiteTransactionExecutor { [weak self] sql, operation in + self?.executeRaw(sql, operation: operation) ?? false + }.perform(work) } private func execute(sql: String, bindings: [String], operation: String) -> Bool { @@ -320,31 +296,18 @@ final class WordRecordSQLiteStore { private func deleteRecords(table: String, documentID: String, ids: [String]) -> Bool { guard !ids.isEmpty else { return true } let sql = "DELETE FROM \(table) WHERE document_id = ? AND id = ?" - guard beginTransaction() else { return false } - var didFail = false - for id in ids { - let didDelete = executeStatement( - sql: sql, - prepareOperation: "prepare delete \(table) record", - stepOperation: "delete \(table) record" - ) { statement in - sqlite3_bind_text(statement, 1, documentID, -1, WORD_RECORD_SQLITE_TRANSIENT) - sqlite3_bind_text(statement, 2, id, -1, WORD_RECORD_SQLITE_TRANSIENT) - } - if !didDelete { - didFail = true + return withTransaction { + ids.allSatisfy { id in + executeStatement( + sql: sql, + prepareOperation: "prepare delete \(table) record", + stepOperation: "delete \(table) record" + ) { statement in + sqlite3_bind_text(statement, 1, documentID, -1, WORD_RECORD_SQLITE_TRANSIENT) + sqlite3_bind_text(statement, 2, id, -1, WORD_RECORD_SQLITE_TRANSIENT) + } } - if didFail { break } } - if didFail { - rollbackTransaction() - return false - } - guard commitTransaction() else { - rollbackTransaction() - return false - } - return true } @discardableResult diff --git a/tests/PersonalVocabularyProfileStoreTests.swift b/tests/PersonalVocabularyProfileStoreTests.swift index 25666bb..ee6538b 100644 --- a/tests/PersonalVocabularyProfileStoreTests.swift +++ b/tests/PersonalVocabularyProfileStoreTests.swift @@ -102,6 +102,23 @@ struct PersonalVocabularyProfileStoreTestRunner { let knownProfiles = store.loadKnownProfiles() assert(knownProfiles.contains { $0.lemma == "roistering" && $0.status == .known }, "known profile query should return inferred known words") + let gravityBeforeFailedCommit = store.loadProfile(lemma: "gravity")?.seenCount + let commitFailureStore = PersonalVocabularyProfileStore(databaseURL: dbURL) { operation in + operation == "commit transaction" + } + assert( + !commitFailureStore.recordExposure(documentID: "rollback-book", text: "gravity zephyr", date: date), + "commit failure should be reported" + ) + assert( + commitFailureStore.loadProfile(lemma: "gravity")?.seenCount == gravityBeforeFailedCommit, + "commit failure should preserve the existing profile" + ) + assert( + commitFailureStore.loadProfile(lemma: "zephyr") == nil, + "commit failure should roll back newly inserted profiles" + ) + print("PersonalVocabularyProfileStoreTests passed") } } diff --git a/tests/ReaderWebScriptTests.js b/tests/ReaderWebScriptTests.js index acdb7eb..79d83ce 100644 --- a/tests/ReaderWebScriptTests.js +++ b/tests/ReaderWebScriptTests.js @@ -1,5 +1,10 @@ const assert = require('assert'); const web = require('../mac-app/Resources/reader-web.js'); +const tts = require('../mac-app/Resources/reader-web-tts.js'); +const selection = require('../mac-app/Resources/reader-web-selection.js'); + +assert.strictEqual(typeof tts.install, 'function'); +assert.strictEqual(typeof selection.install, 'function'); global.NodeFilter = { SHOW_TEXT: 4 }; global.document = { diff --git a/tests/run.sh b/tests/run.sh index 61f5386..8b9ab1b 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -120,6 +120,7 @@ SQLITE_WORD_TEST_SOURCES=( mac-app/PDFWordRecordStore.swift mac-app/WebWordRecordStore.swift mac-app/SQLiteSchemaMigrator.swift + mac-app/SQLiteTransactionExecutor.swift mac-app/WordRecordSQLiteRowMapper.swift mac-app/WordRecordSQLiteStore.swift ) @@ -129,6 +130,7 @@ PERSONAL_VOCABULARY_TEST_SOURCES=( mac-app/PersonalVocabularyProfile.swift mac-app/PersonalVocabularyProfileSchema.swift mac-app/SQLiteSchemaMigrator.swift + mac-app/SQLiteTransactionExecutor.swift mac-app/PersonalVocabularyProfileStore.swift ) @@ -170,6 +172,8 @@ node --check mac-app/Resources/reader-web-text.js node --check mac-app/Resources/reader-web-overlay.js node --check mac-app/Resources/reader-web-search.js node --check mac-app/Resources/reader-web-marks.js +node --check mac-app/Resources/reader-web-tts.js +node --check mac-app/Resources/reader-web-selection.js node --check mac-app/Resources/reader-web.js node tests/ReaderWebScriptTests.js @@ -187,6 +191,7 @@ run_swift_test /tmp/leafreader-personal-vocabulary-tests \ run_swift_test /tmp/leafreader-pdf-embedding-store-tests \ tests/PDFEmbeddingStoreTests.swift \ mac-app/PDFEmbeddingStore.swift \ + mac-app/SQLiteTransactionExecutor.swift \ mac-app/PDFDocumentAgentIndex.swift \ mac-app/ReaderAIContextBuilder.swift \ mac-app/ReaderAIContextBuilder+PDF.swift \