From fd1548ae8e7eda94a95fd0a6576efe50c8c18319 Mon Sep 17 00:00:00 2001
From: Ian729 <438410248@qq.com>
Date: Wed, 15 Apr 2026 16:06:11 +0800
Subject: [PATCH 1/2] Add Ollama auto-translation and preloading settings
- Add Ollama configuration tab in macOS Preferences
- Auto-translate articles and display translation at the bottom
- Show 'Translating...' indicator while translation is in progress
- Implement background preloading for the next X articles in the timeline
---
.../Detail/DetailWebViewController.swift | 63 +++++++-
.../Timeline/TimelineViewController.swift | 15 ++
.../PreferencesWindowController.swift | 140 +++++++++++++++++
.../RSCore/Sources/RSCore/OllamaClient.swift | 146 ++++++++++++++++++
iOS/Article/WebViewController.swift | 53 ++++++-
.../MainTimelineModernViewController.swift | 20 +++
6 files changed, 431 insertions(+), 6 deletions(-)
create mode 100644 Modules/RSCore/Sources/RSCore/OllamaClient.swift
diff --git a/Mac/MainWindow/Detail/DetailWebViewController.swift b/Mac/MainWindow/Detail/DetailWebViewController.swift
index a64e8ec2a5..764a12fba8 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,71 @@ 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
+ }
+
+ 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.innerHTML = \"\(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 d7ac791f1f..e3f023384a 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 ?? ""
+ 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 5dcb8f3615..ddc215d9b4 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,133 @@ 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"))
+ if preloadCountTextField.stringValue == "0" {
+ preloadCountTextField.stringValue = "10"
+ UserDefaults.standard.set(10, 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 0000000000..08d271f58e
--- /dev/null
+++ b/Modules/RSCore/Sources/RSCore/OllamaClient.swift
@@ -0,0 +1,146 @@
+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 inProgressTranslations = 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() {}
+
+ public func generate(prompt: String, completion: @escaping (Result) -> Void) {
+ 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()
+ }
+
+ 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
+ }
+
+ 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.generate(prompt: prompt) { [weak self] result in
+ if case .success(let translation) = result {
+ self?.cacheQueue.async {
+ self?.translationCache[articleID] = translation
+ }
+ }
+ completion(result)
+ }
+ }
+ }
+
+ public func preloadTranslations(items: [(id: String, text: String)]) {
+ for item in items {
+ let id = item.id
+ let text = item.text
+
+ cacheQueue.async {
+ guard self.translationCache[id] == nil, !self.inProgressTranslations.contains(id) else {
+ return
+ }
+ guard !text.isEmpty else { return }
+
+ self.inProgressTranslations.insert(id)
+
+ 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.generate(prompt: prompt) { [weak self] result in
+ self?.cacheQueue.async {
+ self?.inProgressTranslations.remove(id)
+ if case .success(let translation) = result {
+ self?.translationCache[id] = translation
+ }
+ }
+ }
+ }
+ }
+ }
+
+ }
+
diff --git a/iOS/Article/WebViewController.swift b/iOS/Article/WebViewController.swift
index 993dffdd8d..3a4f666422 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 ?? ""
+
+ 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.innerHTML = \"\(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 db21f9d8ba..e714382e5d 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 ?? ""
+ return text.isEmpty ? nil : (a.articleID, text)
+ }
+ OllamaClient.shared.preloadTranslations(items: itemsToPreload)
+ }
+ }
+ }
+ }
+
coordinator?.selectArticle(article, animations: [.scroll, .select, .navigation])
}
}
From 0cbd43c041a1453e2a5f2ef01080041241202239 Mon Sep 17 00:00:00 2001
From: Ian729 <438410248@qq.com>
Date: Wed, 15 Apr 2026 16:25:21 +0800
Subject: [PATCH 2/2] Fix security and performance issues with Ollama
translation
Fixes several critical issues introduced in the Ollama auto-translation feature:
- Fix Queue Starvation: Preloading tasks are now proper URLSessionDataTasks that get cancelled if the user scrolls away, preventing the network queue from filling up.
- Fix Race Condition: When translating an article that is already being preloaded, it attaches to the existing network task instead of firing a duplicate request.
- Fix XSS Vulnerabilities: Use instead of to inject the translated text into the WebView.
- Fix Context Window Limits: Apply to the article payload before sending it to Ollama to prevent model truncation and token overflow.
- Clean up UserDefaults: Move initialization logic out of the Preferences view controller and correctly define them in using .
---
Mac/AppDefaults.swift | 12 ++-
.../Detail/DetailWebViewController.swift | 3 +-
.../Timeline/TimelineViewController.swift | 2 +-
.../PreferencesWindowController.swift | 5 +-
.../RSCore/Sources/RSCore/OllamaClient.swift | 86 ++++++++++++++-----
iOS/AppDefaults.swift | 7 +-
iOS/Article/WebViewController.swift | 4 +-
.../MainTimelineModernViewController.swift | 2 +-
8 files changed, 90 insertions(+), 31 deletions(-)
diff --git a/Mac/AppDefaults.swift b/Mac/AppDefaults.swift
index 51985e00da..f589005b9a 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 764a12fba8..2e5b9942d3 100644
--- a/Mac/MainWindow/Detail/DetailWebViewController.swift
+++ b/Mac/MainWindow/Detail/DetailWebViewController.swift
@@ -304,6 +304,7 @@ private extension DetailWebViewController {
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) {
@@ -352,7 +353,7 @@ private extension DetailWebViewController {
function updateTranslation() {
var translationDiv = document.getElementById('ollama-translation');
if (translationDiv) {
- translationDiv.innerHTML = \"\(encoded)\";
+ translationDiv.innerText = \"\(encoded)\";
} else {
setTimeout(updateTranslation, 100);
}
diff --git a/Mac/MainWindow/Timeline/TimelineViewController.swift b/Mac/MainWindow/Timeline/TimelineViewController.swift
index e3f023384a..54326c7ec1 100644
--- a/Mac/MainWindow/Timeline/TimelineViewController.swift
+++ b/Mac/MainWindow/Timeline/TimelineViewController.swift
@@ -947,7 +947,7 @@ extension TimelineViewController: NSTableViewDelegate {
if nextIndex < articles.count {
let limit = min(articles.count, nextIndex + count)
let itemsToPreload = articles[nextIndex.. (id: String, text: String)? in
- let text = a.body ?? ""
+ let text = (a.body ?? "").strippingHTML(maxCharacters: 4000)
return text.isEmpty ? nil : (a.articleID, text)
}
OllamaClient.shared.preloadTranslations(items: itemsToPreload)
diff --git a/Mac/Preferences/PreferencesWindowController.swift b/Mac/Preferences/PreferencesWindowController.swift
index ddc215d9b4..ec7a36bc3a 100644
--- a/Mac/Preferences/PreferencesWindowController.swift
+++ b/Mac/Preferences/PreferencesWindowController.swift
@@ -272,10 +272,7 @@ final class OllamaPreferencesViewController: NSViewController {
preloadCountTextField.translatesAutoresizingMaskIntoConstraints = false
preloadCountTextField.widthAnchor.constraint(equalToConstant: 100).isActive = true
preloadCountTextField.stringValue = String(UserDefaults.standard.integer(forKey: "OllamaPreloadCount"))
- if preloadCountTextField.stringValue == "0" {
- preloadCountTextField.stringValue = "10"
- UserDefaults.standard.set(10, forKey: "OllamaPreloadCount")
- }
+
addFormRow(title: NSLocalizedString("Preload Next:", comment: ""), control: preloadCountTextField)
// Auto-Translate
diff --git a/Modules/RSCore/Sources/RSCore/OllamaClient.swift b/Modules/RSCore/Sources/RSCore/OllamaClient.swift
index 08d271f58e..656fe5b4e0 100644
--- a/Modules/RSCore/Sources/RSCore/OllamaClient.swift
+++ b/Modules/RSCore/Sources/RSCore/OllamaClient.swift
@@ -22,7 +22,10 @@ public final class OllamaClient: @unchecked Sendable {
}
private var translationCache = [String: String]()
- private var inProgressTranslations = Set()
+ 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 = {
@@ -34,7 +37,8 @@ public final class OllamaClient: @unchecked Sendable {
public init() {}
- public func generate(prompt: String, completion: @escaping (Result) -> Void) {
+ @discardableResult
+ private func generate(prompt: String, completion: @escaping (Result) -> Void) -> URLSessionDataTask? {
let url = baseURL.appendingPathComponent("generate")
var request = URLRequest(url: url)
request.httpMethod = "POST"
@@ -80,6 +84,7 @@ public final class OllamaClient: @unchecked Sendable {
}
}
task.resume()
+ return task
}
public func cachedTranslation(articleID: String) -> String? {
@@ -101,46 +106,87 @@ public final class OllamaClient: @unchecked Sendable {
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.generate(prompt: prompt) { [weak self] result in
- if case .success(let translation) = result {
- self?.cacheQueue.async {
+ 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)
+ }
+ }
}
- completion(result)
+ }
+
+ if let task = task {
+ self.activeTasks[articleID] = task
}
}
}
public func preloadTranslations(items: [(id: String, text: String)]) {
- for item in items {
- let id = item.id
- let text = item.text
+ 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)
+ }
+ }
- cacheQueue.async {
- guard self.translationCache[id] == nil, !self.inProgressTranslations.contains(id) else {
- return
+ // 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 { return }
+ guard !text.isEmpty else { continue }
- self.inProgressTranslations.insert(id)
-
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.generate(prompt: prompt) { [weak self] result in
+ let task = self.generate(prompt: prompt) { [weak self] result in
self?.cacheQueue.async {
- self?.inProgressTranslations.remove(id)
+ 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 e54d9bcfb8..5bc803c285 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 3a4f666422..1f7773ff08 100644
--- a/iOS/Article/WebViewController.swift
+++ b/iOS/Article/WebViewController.swift
@@ -611,7 +611,7 @@ private extension WebViewController {
var textToTranslate = ""
if let article = self.article, UserDefaults.standard.bool(forKey: "OllamaAutoTranslate") {
- textToTranslate = self.extractedArticle?.content ?? article.body ?? ""
+ textToTranslate = (self.extractedArticle?.content ?? article.body ?? "").strippingHTML(maxCharacters: 4000)
if !textToTranslate.isEmpty {
if let cached = OllamaClient.shared.cachedTranslation(articleID: article.articleID) {
@@ -669,7 +669,7 @@ private extension WebViewController {
function updateTranslation() {
var translationDiv = document.getElementById('ollama-translation');
if (translationDiv) {
- translationDiv.innerHTML = \"\(encoded)\";
+ translationDiv.innerText = \"\(encoded)\";
} else {
setTimeout(updateTranslation, 100);
}
diff --git a/iOS/MainTimeline/MainTimelineModernViewController.swift b/iOS/MainTimeline/MainTimelineModernViewController.swift
index e714382e5d..1cc89649ee 100644
--- a/iOS/MainTimeline/MainTimelineModernViewController.swift
+++ b/iOS/MainTimeline/MainTimelineModernViewController.swift
@@ -515,7 +515,7 @@ extension MainTimelineModernViewController: UICollectionViewDelegate {
if nextIndex < articles.count {
let limit = min(articles.count, nextIndex + count)
let itemsToPreload = articles[nextIndex.. (id: String, text: String)? in
- let text = a.body ?? ""
+ let text = (a.body ?? "").strippingHTML(maxCharacters: 4000)
return text.isEmpty ? nil : (a.articleID, text)
}
OllamaClient.shared.preloadTranslations(items: itemsToPreload)