From 8af48ede759593fbb1aba6208336ed538bec235f Mon Sep 17 00:00:00 2001 From: JoshuaNam Date: Wed, 1 Apr 2026 02:38:03 +0000 Subject: [PATCH 1/8] feat: add Python/Flask LLM translation microservice --- llm/Dockerfile | 11 ++++++++ llm/app.py | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 llm/Dockerfile create mode 100644 llm/app.py diff --git a/llm/Dockerfile b/llm/Dockerfile new file mode 100644 index 0000000000..c315a28817 --- /dev/null +++ b/llm/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN pip install --no-cache-dir flask requests + +COPY app.py . + +EXPOSE 5000 + +CMD ["python", "app.py"] diff --git a/llm/app.py b/llm/app.py new file mode 100644 index 0000000000..620fdd976f --- /dev/null +++ b/llm/app.py @@ -0,0 +1,76 @@ +import json +import re + +import requests +from flask import Flask, jsonify, request + +app = Flask(__name__) + +OLLAMA_URL = "http://localhost:11434/api/generate" +MODEL = "qwen2.5:7b" + + +def is_empty_or_emoji_only(text): + """Return True if text is empty, whitespace-only, or contains only emoji/symbols.""" + if not text or not text.strip(): + return True + # Remove emoji and common symbol ranges + stripped = re.sub( + r'[\U0001F600-\U0001F64F' # emoticons + r'\U0001F300-\U0001F5FF' # symbols & pictographs + r'\U0001F680-\U0001F6FF' # transport & map symbols + r'\U0001F1E0-\U0001F1FF' # flags + r'\U00002702-\U000027B0' # dingbats + r'\U0000FE00-\U0000FE0F' # variation selectors + r'\U0001F900-\U0001F9FF' # supplemental symbols + r'\U0001FA00-\U0001FA6F' # chess symbols + r'\U0001FA70-\U0001FAFF' # symbols extended-A + r'\U00002600-\U000026FF' # misc symbols + r'\U0000200D' # zero width joiner + r'\U00000020-\U0000002F' # spaces and punctuation + r'\U0000003A-\U00000040' # punctuation + r'\U0000005B-\U00000060' # punctuation + r'\U0000007B-\U0000007E' # punctuation + r']+', '', text) + return len(stripped.strip()) == 0 + + +@app.route("/") +def translate(): + content = request.args.get("content", "") + + if is_empty_or_emoji_only(content): + return jsonify({"is_english": True, "translated_content": ""}) + + prompt = ( + "You are a language detection and translation assistant. " + "Analyze the following text and determine if it is written in English. " + "If it is English, respond with exactly: {\"is_english\": true, \"translated_content\": \"\"}\n" + "If it is NOT English, translate it to English and respond with exactly: " + "{\"is_english\": false, \"translated_content\": \"\"}\n" + "Respond ONLY with the JSON object, no other text.\n\n" + f"Text: {content}" + ) + + try: + response = requests.post(OLLAMA_URL, json={ + "model": MODEL, + "prompt": prompt, + "stream": False, + }, timeout=60) + response.raise_for_status() + + result = response.json() + llm_response = result.get("response", "").strip() + + parsed = json.loads(llm_response) + return jsonify({ + "is_english": bool(parsed.get("is_english", True)), + "translated_content": str(parsed.get("translated_content", "")), + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000) From c6201104c72f510640c3a3b912e02b272b557546 Mon Sep 17 00:00:00 2001 From: JoshuaNam Date: Wed, 1 Apr 2026 02:39:00 +0000 Subject: [PATCH 2/8] feat: restore async translation on post creation --- src/posts/create.js | 22 ++++++++++++++++++---- src/posts/data.js | 8 +++++++- src/translate/index.js | 20 +++++++++++++++----- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/posts/create.js b/src/posts/create.js index 2e6caa4fbc..34ea41cdb4 100644 --- a/src/posts/create.js +++ b/src/posts/create.js @@ -19,9 +19,6 @@ module.exports = function (Posts) { const timestamp = data.timestamp || Date.now(); const isMain = data.isMain || false; let hasAttachment = false; - const [isEnglish, translatedContent] = await translate.translate(data); - - if (!uid && parseInt(uid, 10) !== 0) { throw new Error('[[error:invalid-uid]]'); } @@ -31,7 +28,7 @@ module.exports = function (Posts) { } const pid = data.pid || await db.incrObjectField('global', 'nextPid'); - let postData = { pid, uid, tid, content, sourceContent, timestamp, isEnglish, translatedContent}; + let postData = { pid, uid, tid, content, sourceContent, timestamp, isEnglish: 'loading', translatedContent: '', isTranslating: true }; if (data.toPid) { postData.toPid = data.toPid; @@ -75,6 +72,23 @@ module.exports = function (Posts) { ({ post: postData } = await plugins.hooks.fire('filter:post.create', { post: postData, data: data })); await db.setObject(`post:${postData.pid}`, postData); + // Fire-and-forget translation + const savedPid = postData.pid; + const savedTid = tid; + translate.translate(postData).then(([isEng, transContent]) => { + Posts.setPostFields(savedPid, { isEnglish: isEng, translatedContent: transContent, isTranslating: false }); + const websockets = require('../socket.io'); + if (websockets.server) { + websockets.in(`topic_${savedTid}`).emit('event:post_translated', { + pid: savedPid, + isEnglish: isEng, + translatedContent: transContent, + }); + } + }).catch(() => { + Posts.setPostFields(savedPid, { isEnglish: 'true', translatedContent: '', isTranslating: false }); + }); + const topicData = await topics.getTopicFields(tid, ['cid', 'pinned']); postData.cid = topicData.cid; await Promise.all([ diff --git a/src/posts/data.js b/src/posts/data.js index bb47359722..ef95603c3b 100644 --- a/src/posts/data.js +++ b/src/posts/data.js @@ -101,7 +101,13 @@ function modifyPost(post, fields) { } } // Mark post as "English" if decided by translator service or if it has no info - post.isEnglish = post.isEnglish == 'true' || post.isEnglish === undefined; + if (post.isEnglish === 'loading') { + // keep as 'loading' — translation in progress + } else { + post.isEnglish = post.isEnglish == 'true' || post.isEnglish === undefined; + } + // Ensure isTranslating is a boolean (stored as string in DB) + post.isTranslating = post.isTranslating === true || post.isTranslating === 'true'; // If translatedContent is undefined, default to empty string (no translation needed for English posts) if (post.translatedContent === undefined) { post.translatedContent = ''; diff --git a/src/translate/index.js b/src/translate/index.js index 4fcaf78128..0a748c6af1 100644 --- a/src/translate/index.js +++ b/src/translate/index.js @@ -1,9 +1,19 @@ - -/* eslint-disable strict */ -//var request = require('request'); +'use strict'; const translatorApi = module.exports; -translatorApi.translate = function (postData) { - return ['is_english',postData]; +const TRANSLATOR_URL = process.env.TRANSLATOR_URL || 'http://localhost:5000'; + +translatorApi.translate = async function (postData) { + try { + const response = await fetch( + `${TRANSLATOR_URL}/?content=${encodeURIComponent(postData.content)}` + ); + const data = await response.json(); + const isEnglish = data.is_english ? 'true' : 'false'; + const translatedContent = data.translated_content || ''; + return [isEnglish, translatedContent]; + } catch (err) { + return ['true', '']; + } }; From 266a68365a5f7b81db466601bc50d657338f3d4a Mon Sep 17 00:00:00 2001 From: JoshuaNam Date: Wed, 1 Apr 2026 02:39:18 +0000 Subject: [PATCH 3/8] feat: add translation loading state and socket update handler --- public/src/client/topic/events.js | 31 +++++++++++++++++++ .../templates/partials/topic/post.tpl | 14 ++++++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/public/src/client/topic/events.js b/public/src/client/topic/events.js index 1df0db3464..783fc66f68 100644 --- a/public/src/client/topic/events.js +++ b/public/src/client/topic/events.js @@ -47,6 +47,7 @@ define('forum/topic/events', [ 'event:new_notification': onNewNotification, 'event:new_post': posts.onNewPost, + 'event:post_translated': onPostTranslated, }; Events.init = function () { @@ -272,6 +273,36 @@ define('forum/topic/events', [ }).toggleClass('downvoted', data.downvote); } + function onPostTranslated(data) { + var postEl = $('[component="post"][data-pid="' + data.pid + '"]'); + if (!postEl.length) { + return; + } + + var contentEl = postEl.find('[component="post/content"]'); + // Remove loading spinner + contentEl.find('.translation-loading').remove(); + + if (data.isEnglish === 'true' || data.isEnglish === true) { + // English — remove any translation UI + contentEl.find('.sensitive-content-message').remove(); + contentEl.find('.translated-content').remove(); + } else { + // Not English — show toggle button and populate translation + contentEl.find('.sensitive-content-message').remove(); + contentEl.find('.translated-content').remove(); + + // Build elements safely using jQuery to prevent XSS + var msgDiv = $('
'); + var btn = $('').text('Click here to view the translated message.'); + msgDiv.append(btn); + + var transDiv = $('').text(data.translatedContent); + + contentEl.append(msgDiv).append(transDiv); + } + } + function onNewNotification(data) { const tid = ajaxify.data.tid; if (data && data.tid && String(data.tid) === String(tid)) { diff --git a/vendor/nodebb-theme-harmony-2.1.35/templates/partials/topic/post.tpl b/vendor/nodebb-theme-harmony-2.1.35/templates/partials/topic/post.tpl index 87192eea4f..778ca37039 100644 --- a/vendor/nodebb-theme-harmony-2.1.35/templates/partials/topic/post.tpl +++ b/vendor/nodebb-theme-harmony-2.1.35/templates/partials/topic/post.tpl @@ -83,14 +83,20 @@
{posts.content} - {{{if !posts.isEnglish }}} + {{{if posts.isTranslating }}} +
+ Translation in progress... +
+ + {{{end}}} + {{{if (!posts.isEnglish && !posts.isTranslating) }}} - {{{end}}} + {{{end}}}