diff --git a/Mac/AppDefaults.swift b/Mac/AppDefaults.swift index 51985e00d..f589005b9 100644 --- a/Mac/AppDefaults.swift +++ b/Mac/AppDefaults.swift @@ -43,6 +43,11 @@ final class AppDefaults: Sendable { static let defaultBrowserID = "defaultBrowserID" static let currentThemeName = "currentThemeName" static let articleContentJavascriptEnabled = "articleContentJavascriptEnabled" + static let ollamaBaseURL = "OllamaBaseURL" + static let ollamaModel = "OllamaModel" + static let ollamaPreferredLanguage = "OllamaPreferredLanguage" + static let ollamaPreloadCount = "OllamaPreloadCount" + static let ollamaAutoTranslate = "OllamaAutoTranslate" // Hidden prefs static let showDebugMenu = "ShowDebugMenu" @@ -344,7 +349,12 @@ final class AppDefaults: Sendable { Key.refreshInterval: RefreshInterval.every2Hours.rawValue, Key.showDebugMenu: showDebugMenu, Key.currentThemeName: Self.defaultThemeName, - Key.articleContentJavascriptEnabled: true + Key.articleContentJavascriptEnabled: true, + Key.ollamaBaseURL: "http://localhost:11434/api", + Key.ollamaModel: "llama3", + Key.ollamaPreferredLanguage: "Chinese", + Key.ollamaPreloadCount: 10, + Key.ollamaAutoTranslate: false ] UserDefaults.standard.register(defaults: defaults) diff --git a/Mac/MainWindow/Detail/DetailWebViewController.swift b/Mac/MainWindow/Detail/DetailWebViewController.swift index a64e8ec2a..2e5b9942d 100644 --- a/Mac/MainWindow/Detail/DetailWebViewController.swift +++ b/Mac/MainWindow/Detail/DetailWebViewController.swift @@ -230,11 +230,10 @@ extension DetailWebViewController: WKNavigationDelegate, WKUIDelegate { } public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - guard let windowScrollY else { - return + if let windowScrollY { + webView.evaluateJavaScript("window.scrollTo(0, \(windowScrollY));") + self.windowScrollY = nil } - webView.evaluateJavaScript("window.scrollTo(0, \(windowScrollY));") - self.windowScrollY = nil } // WKUIDelegate @@ -296,17 +295,72 @@ private extension DetailWebViewController { rendering = ArticleRenderer.articleHTML(article: article, extractedArticle: extractedArticle, theme: theme) } + var bodyHTML = rendering.html + var requestTranslation = false + var textToTranslate = "" + + if let article = self.article, UserDefaults.standard.bool(forKey: "OllamaAutoTranslate") { + textToTranslate = article.body ?? "" + if case .extracted(_, let extractedArticle, _) = state, let content = extractedArticle.content { + textToTranslate = content + } + textToTranslate = textToTranslate.strippingHTML(maxCharacters: 4000) + + if !textToTranslate.isEmpty { + if let cached = OllamaClient.shared.cachedTranslation(articleID: article.articleID) { + bodyHTML += "
\(cached)
" + } else { + bodyHTML += "
Translating...
" + requestTranslation = true + } + } + } + let substitutions = [ "title": rendering.title, "baseURL": rendering.baseURL, "style": rendering.style, - "body": rendering.html + "body": bodyHTML ] var html = try! MacroProcessor.renderedText(withTemplate: ArticleRenderer.page.html, substitutions: substitutions) html = ArticleRenderingSpecialCases.filterHTMLIfNeeded(baseURL: rendering.baseURL, html: html) WebViewConfiguration.addContentBlockingRules(to: webView) webView.loadHTMLString(html, baseURL: URL(string: rendering.baseURL)) + + if requestTranslation { + OllamaClient.shared.translate(articleID: self.article!.articleID, text: textToTranslate) { [weak self, articleID = self.article!.articleID] result in + DispatchQueue.main.async { + guard let self = self, self.article?.articleID == articleID else { return } + if case .success(let translated) = result { + self.injectTranslation(translated) + } else if case .failure(let error) = result { + self.injectTranslation("Translation failed: \(error.localizedDescription)") + } + } + } + } + } + + func injectTranslation(_ translatedText: String) { + let encoded = translatedText + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + + let js = """ + function updateTranslation() { + var translationDiv = document.getElementById('ollama-translation'); + if (translationDiv) { + translationDiv.innerText = \"\(encoded)\"; + } else { + setTimeout(updateTranslation, 100); + } + } + updateTranslation(); + """ + webView.evaluateJavaScript(js) } func fetchScrollInfo() async -> ScrollInfo? { diff --git a/Mac/MainWindow/Timeline/TimelineViewController.swift b/Mac/MainWindow/Timeline/TimelineViewController.swift index d7ac791f1..54326c7ec 100644 --- a/Mac/MainWindow/Timeline/TimelineViewController.swift +++ b/Mac/MainWindow/Timeline/TimelineViewController.swift @@ -939,6 +939,21 @@ extension TimelineViewController: NSTableViewDelegate { if !article.status.read { markArticles(Set([article]), statusKey: .read, flag: true) } + + if UserDefaults.standard.bool(forKey: "OllamaAutoTranslate") { + let count = UserDefaults.standard.object(forKey: "OllamaPreloadCount") != nil ? UserDefaults.standard.integer(forKey: "OllamaPreloadCount") : 10 + if count > 0, let index = articles.firstIndex(of: article) { + let nextIndex = index + 1 + if nextIndex < articles.count { + let limit = min(articles.count, nextIndex + count) + let itemsToPreload = articles[nextIndex.. (id: String, text: String)? in + let text = (a.body ?? "").strippingHTML(maxCharacters: 4000) + return text.isEmpty ? nil : (a.articleID, text) + } + OllamaClient.shared.preloadTranslations(items: itemsToPreload) + } + } + } } selectionDidChange(selectedArticles) diff --git a/Mac/Preferences/PreferencesWindowController.swift b/Mac/Preferences/PreferencesWindowController.swift index 5dcb8f361..ec7a36bc3 100644 --- a/Mac/Preferences/PreferencesWindowController.swift +++ b/Mac/Preferences/PreferencesWindowController.swift @@ -25,6 +25,7 @@ private struct ToolbarItemIdentifier { static let General = "General" static let Accounts = "Accounts" static let Advanced = "Advanced" + static let Ollama = "Ollama" } final class PreferencesWindowController: NSWindowController, NSToolbarDelegate { @@ -42,6 +43,9 @@ final class PreferencesWindowController: NSWindowController, NSToolbarDelegate { specs += [PreferencesToolbarItemSpec(identifierRawValue: ToolbarItemIdentifier.Advanced, name: NSLocalizedString("Advanced", comment: "Preferences"), image: Assets.Images.preferencesToolbarAdvanced)] + specs += [PreferencesToolbarItemSpec(identifierRawValue: ToolbarItemIdentifier.Ollama, + name: NSLocalizedString("Translation", comment: "Preferences"), + image: NSImage(systemSymbolName: "globe", accessibilityDescription: nil))] return specs }() @@ -152,6 +156,12 @@ private extension PreferencesWindowController { return cachedViewController } + if identifier == ToolbarItemIdentifier.Ollama { + let viewController = OllamaPreferencesViewController() + viewControllers[identifier] = viewController + return viewController + } + let storyboard = NSStoryboard(name: NSStoryboard.Name("Preferences"), bundle: nil) guard let viewController = storyboard.instantiateController(withIdentifier: NSStoryboard.SceneIdentifier(identifier)) as? NSViewController else { assertionFailure("Unknown preferences view controller: \(identifier)") @@ -190,3 +200,130 @@ private extension PreferencesWindowController { } } } + +final class OllamaPreferencesViewController: NSViewController { + + private let baseURLTextField = NSTextField() + private let modelTextField = NSTextField() + private let languageTextField = NSTextField() + private let autoTranslateCheckbox = NSButton(checkboxWithTitle: NSLocalizedString("Auto-Translate Articles", comment: ""), target: nil, action: nil) + private let preloadCountTextField = NSTextField() + + init() { + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = NSView(frame: NSRect(x: 0, y: 0, width: 512, height: 250)) + + let stackView = NSStackView() + stackView.orientation = .vertical + stackView.alignment = .leading + stackView.spacing = 16 + stackView.edgeInsets = NSEdgeInsets(top: 20, left: 40, bottom: 20, right: 40) + stackView.translatesAutoresizingMaskIntoConstraints = false + + self.view.addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: view.topAnchor), + stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + func addFormRow(title: String, control: NSView) { + let rowStack = NSStackView() + rowStack.orientation = .horizontal + rowStack.alignment = .firstBaseline + rowStack.spacing = 8 + + let label = NSTextField(labelWithString: title) + label.alignment = .right + label.widthAnchor.constraint(equalToConstant: 120).isActive = true + + rowStack.addArrangedSubview(label) + rowStack.addArrangedSubview(control) + stackView.addArrangedSubview(rowStack) + } + + // Base URL + baseURLTextField.translatesAutoresizingMaskIntoConstraints = false + baseURLTextField.widthAnchor.constraint(equalToConstant: 250).isActive = true + baseURLTextField.stringValue = UserDefaults.standard.string(forKey: "OllamaBaseURL") ?? "http://localhost:11434/api" + addFormRow(title: NSLocalizedString("Base URL:", comment: ""), control: baseURLTextField) + + // Model + modelTextField.translatesAutoresizingMaskIntoConstraints = false + modelTextField.widthAnchor.constraint(equalToConstant: 250).isActive = true + modelTextField.stringValue = UserDefaults.standard.string(forKey: "OllamaModel") ?? "llama3" + addFormRow(title: NSLocalizedString("Model:", comment: ""), control: modelTextField) + + // Language + languageTextField.translatesAutoresizingMaskIntoConstraints = false + languageTextField.widthAnchor.constraint(equalToConstant: 250).isActive = true + languageTextField.stringValue = UserDefaults.standard.string(forKey: "OllamaPreferredLanguage") ?? "Chinese" + addFormRow(title: NSLocalizedString("Target Language:", comment: ""), control: languageTextField) + + // Preload Count + preloadCountTextField.translatesAutoresizingMaskIntoConstraints = false + preloadCountTextField.widthAnchor.constraint(equalToConstant: 100).isActive = true + preloadCountTextField.stringValue = String(UserDefaults.standard.integer(forKey: "OllamaPreloadCount")) + + addFormRow(title: NSLocalizedString("Preload Next:", comment: ""), control: preloadCountTextField) + + // Auto-Translate + autoTranslateCheckbox.state = UserDefaults.standard.bool(forKey: "OllamaAutoTranslate") ? .on : .off + + let checkboxContainer = NSStackView() + checkboxContainer.orientation = .horizontal + let spacer = NSView() + spacer.translatesAutoresizingMaskIntoConstraints = false + spacer.widthAnchor.constraint(equalToConstant: 120 + 8).isActive = true // Label width + spacing + checkboxContainer.addArrangedSubview(spacer) + checkboxContainer.addArrangedSubview(autoTranslateCheckbox) + stackView.addArrangedSubview(checkboxContainer) + + // Targets/Actions + baseURLTextField.target = self + baseURLTextField.action = #selector(baseURLChanged(_:)) + + modelTextField.target = self + modelTextField.action = #selector(modelChanged(_:)) + + languageTextField.target = self + languageTextField.action = #selector(languageChanged(_:)) + + preloadCountTextField.target = self + preloadCountTextField.action = #selector(preloadCountChanged(_:)) + + autoTranslateCheckbox.target = self + autoTranslateCheckbox.action = #selector(autoTranslateChanged(_:)) + } + + @objc private func baseURLChanged(_ sender: NSTextField) { + UserDefaults.standard.set(sender.stringValue, forKey: "OllamaBaseURL") + } + + @objc private func modelChanged(_ sender: NSTextField) { + UserDefaults.standard.set(sender.stringValue, forKey: "OllamaModel") + } + + @objc private func languageChanged(_ sender: NSTextField) { + UserDefaults.standard.set(sender.stringValue, forKey: "OllamaPreferredLanguage") + } + + @objc private func preloadCountChanged(_ sender: NSTextField) { + let value = max(0, min(50, sender.integerValue)) + UserDefaults.standard.set(value, forKey: "OllamaPreloadCount") + sender.stringValue = String(value) + } + + @objc private func autoTranslateChanged(_ sender: NSButton) { + UserDefaults.standard.set(sender.state == .on, forKey: "OllamaAutoTranslate") + NotificationCenter.default.post(name: Notification.Name("OllamaAutoTranslateDidChange"), object: nil) + } +} diff --git a/Modules/RSCore/Sources/RSCore/OllamaClient.swift b/Modules/RSCore/Sources/RSCore/OllamaClient.swift new file mode 100644 index 000000000..656fe5b4e --- /dev/null +++ b/Modules/RSCore/Sources/RSCore/OllamaClient.swift @@ -0,0 +1,192 @@ +import Foundation + +public final class OllamaClient: @unchecked Sendable { + public static let shared = OllamaClient() + + private var baseURL: URL { + if let urlString = UserDefaults.standard.string(forKey: "OllamaBaseURL"), let url = URL(string: urlString) { + if urlString.hasSuffix("/api") || urlString.hasSuffix("/api/") { + return url + } + return url.appendingPathComponent("api") + } + return URL(string: "http://localhost:11434/api")! + } + + private var model: String { + return UserDefaults.standard.string(forKey: "OllamaModel") ?? "llama3" + } + + private var preferredLanguage: String { + return UserDefaults.standard.string(forKey: "OllamaPreferredLanguage") ?? "Chinese" + } + + private var translationCache = [String: String]() + private var activeTasks = [String: URLSessionDataTask]() + private var completionHandlers = [String: [(Result) -> Void]]() + private var explicitRequests = Set() + + private let cacheQueue = DispatchQueue(label: "com.netnewswire.ollamacache") + + private lazy var session: URLSession = { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 300 // 5 minutes to prevent timeout + config.timeoutIntervalForResource = 300 + return URLSession(configuration: config) + }() + + public init() {} + + @discardableResult + private func generate(prompt: String, completion: @escaping (Result) -> Void) -> URLSessionDataTask? { + let url = baseURL.appendingPathComponent("generate") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let payload: [String: Any] = [ + "model": model, + "prompt": prompt, + "stream": false + ] + + request.httpBody = try? JSONSerialization.data(withJSONObject: payload) + + let task = session.dataTask(with: request) { data, response, error in + if let error = error { + completion(.failure(error)) + return + } + + guard let data = data else { + let err = NSError(domain: "OllamaClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "No data received"]) + completion(.failure(err)) + return + } + + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let responseText = json["response"] as? String { + completion(.success(responseText)) + } else if let errorText = json["error"] as? String { + completion(.failure(NSError(domain: "OllamaClient", code: -1, userInfo: [NSLocalizedDescriptionKey: errorText]))) + } else { + let raw = String(data: data, encoding: .utf8) ?? "Unknown" + completion(.failure(NSError(domain: "OllamaClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid API response: \(raw)"]))) + } + } else { + let raw = String(data: data, encoding: .utf8) ?? "Unknown" + completion(.failure(NSError(domain: "OllamaClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON format. Raw: \(raw)"]))) + } + } catch { + let raw = String(data: data, encoding: .utf8) ?? "Unknown" + completion(.failure(NSError(domain: "OllamaClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to parse JSON. Raw: \(raw)"]))) + } + } + task.resume() + return task + } + + public func cachedTranslation(articleID: String) -> String? { + var result: String? + cacheQueue.sync { + result = translationCache[articleID] + } + return result + } + + public func translate(articleID: String, text: String, completion: @escaping (Result) -> Void) { + guard !text.isEmpty else { + completion(.failure(NSError(domain: "OllamaClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "No text available"]))) + return + } + + cacheQueue.async { + if let cached = self.translationCache[articleID] { + completion(.success(cached)) + return + } + + self.explicitRequests.insert(articleID) + + if self.activeTasks[articleID] != nil { + // Task is already running (e.g., from preload), attach completion handler + var handlers = self.completionHandlers[articleID] ?? [] + handlers.append(completion) + self.completionHandlers[articleID] = handlers + return + } + + let prompt = "Translate the following text to \(self.preferredLanguage). ONLY return the translated text and nothing else. Do not add any conversational filler, side notes, explanations, or multiple options. Provide exactly one translation:\n\n\(text)" + + self.completionHandlers[articleID] = [completion] + + let task = self.generate(prompt: prompt) { [weak self] result in + self?.cacheQueue.async { + self?.activeTasks.removeValue(forKey: articleID) + self?.explicitRequests.remove(articleID) + + if case .success(let translation) = result { + self?.translationCache[articleID] = translation + } + + if let handlers = self?.completionHandlers.removeValue(forKey: articleID) { + for handler in handlers { + handler(result) + } + } + } + } + + if let task = task { + self.activeTasks[articleID] = task + } + } + } + + public func preloadTranslations(items: [(id: String, text: String)]) { + let desiredIDs = Set(items.map { $0.id }) + + cacheQueue.async { + // Cancel active preloads that are no longer needed + for (id, task) in self.activeTasks { + if !desiredIDs.contains(id) && !self.explicitRequests.contains(id) { + task.cancel() + self.activeTasks.removeValue(forKey: id) + self.completionHandlers.removeValue(forKey: id) + } + } + + // Start new preloads + for item in items { + let id = item.id + let text = item.text + + guard self.translationCache[id] == nil, self.activeTasks[id] == nil else { + continue + } + guard !text.isEmpty else { continue } + + let prompt = "Translate the following text to \(self.preferredLanguage). ONLY return the translated text and nothing else. Do not add any conversational filler, side notes, explanations, or multiple options. Provide exactly one translation:\n\n\(text)" + + let task = self.generate(prompt: prompt) { [weak self] result in + self?.cacheQueue.async { + self?.activeTasks.removeValue(forKey: id) + if case .success(let translation) = result { + self?.translationCache[id] = translation + } + if let handlers = self?.completionHandlers.removeValue(forKey: id) { + for handler in handlers { + handler(result) + } + } + } + } + + if let task = task { + self.activeTasks[id] = task + } + } + } + } +} diff --git a/iOS/AppDefaults.swift b/iOS/AppDefaults.swift index e54d9bcfb..5bc803c28 100644 --- a/iOS/AppDefaults.swift +++ b/iOS/AppDefaults.swift @@ -399,7 +399,12 @@ final class AppDefaults: Sendable { Key.confirmMarkAllAsRead: true, Key.articleContentJavascriptEnabled: true, Key.currentThemeName: Self.defaultThemeName, - Key.splitViewPreferredDisplayMode: UISplitViewController.DisplayMode.oneBesideSecondary.rawValue] + Key.splitViewPreferredDisplayMode: UISplitViewController.DisplayMode.oneBesideSecondary.rawValue, + Key.ollamaBaseURL: "http://localhost:11434/api", + Key.ollamaModel: "llama3", + Key.ollamaPreferredLanguage: "Chinese", + Key.ollamaPreloadCount: 10, + Key.ollamaAutoTranslate: false] AppDefaults.store.register(defaults: defaults) } } diff --git a/iOS/Article/WebViewController.swift b/iOS/Article/WebViewController.swift index 993dffdd8..1f7773ff0 100644 --- a/iOS/Article/WebViewController.swift +++ b/iOS/Article/WebViewController.swift @@ -606,11 +606,28 @@ private extension WebViewController { rendering = ArticleRenderer.noSelectionHTML(theme: theme) } + var bodyHTML = rendering.html + var requestTranslation = false + var textToTranslate = "" + + if let article = self.article, UserDefaults.standard.bool(forKey: "OllamaAutoTranslate") { + textToTranslate = (self.extractedArticle?.content ?? article.body ?? "").strippingHTML(maxCharacters: 4000) + + if !textToTranslate.isEmpty { + if let cached = OllamaClient.shared.cachedTranslation(articleID: article.articleID) { + bodyHTML += "
\(cached)
" + } else { + bodyHTML += "
Translating...
" + requestTranslation = true + } + } + } + let substitutions = [ "title": rendering.title, "baseURL": rendering.baseURL, "style": rendering.style, - "body": rendering.html, + "body": bodyHTML, "windowScrollY": String(windowScrollY) ] @@ -626,6 +643,40 @@ private extension WebViewController { WebViewConfiguration.addContentBlockingRules(to: webView) webView.loadHTMLString(html, baseURL: ArticleRenderer.page.baseURL) + + if requestTranslation { + OllamaClient.shared.translate(articleID: self.article!.articleID, text: textToTranslate) { [weak self, articleID = self.article!.articleID] result in + DispatchQueue.main.async { + guard let self = self, self.article?.articleID == articleID else { return } + if case .success(let translated) = result { + self.injectTranslation(translated) + } else if case .failure(let error) = result { + self.injectTranslation("Translation failed: \(error.localizedDescription)") + } + } + } + } + } + + func injectTranslation(_ translatedText: String) { + let encoded = translatedText + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + + let js = """ + function updateTranslation() { + var translationDiv = document.getElementById('ollama-translation'); + if (translationDiv) { + translationDiv.innerText = \"\(encoded)\"; + } else { + setTimeout(updateTranslation, 100); + } + } + updateTranslation(); + """ + webView?.evaluateJavaScript(js) } func finalScrollPosition(scrollingUp: Bool) -> CGFloat { diff --git a/iOS/MainTimeline/MainTimelineModernViewController.swift b/iOS/MainTimeline/MainTimelineModernViewController.swift index db21f9d8b..1cc89649e 100644 --- a/iOS/MainTimeline/MainTimelineModernViewController.swift +++ b/iOS/MainTimeline/MainTimelineModernViewController.swift @@ -504,6 +504,26 @@ extension MainTimelineModernViewController: UICollectionViewDelegate { becomeFirstResponder() if let dataSource { let article = dataSource.itemIdentifier(for: indexPath) + + if UserDefaults.standard.bool(forKey: "OllamaAutoTranslate") { + let count = UserDefaults.standard.object(forKey: "OllamaPreloadCount") != nil ? UserDefaults.standard.integer(forKey: "OllamaPreloadCount") : 10 + if count > 0 { + let snapshot = dataSource.snapshot() + let articles = snapshot.itemIdentifiers + if let article = article, let index = articles.firstIndex(of: article) { + let nextIndex = index + 1 + if nextIndex < articles.count { + let limit = min(articles.count, nextIndex + count) + let itemsToPreload = articles[nextIndex.. (id: String, text: String)? in + let text = (a.body ?? "").strippingHTML(maxCharacters: 4000) + return text.isEmpty ? nil : (a.articleID, text) + } + OllamaClient.shared.preloadTranslations(items: itemsToPreload) + } + } + } + } + coordinator?.selectArticle(article, animations: [.scroll, .select, .navigation]) } }