diff --git a/docker-compose-redis.yml b/docker-compose-redis.yml index 755744d962..5181ebd683 100644 --- a/docker-compose-redis.yml +++ b/docker-compose-redis.yml @@ -1,27 +1,33 @@ version: '3.8' - services: nodebb: user: "0" build: . - # image: ghcr.io/nodebb/nodebb:latest restart: unless-stopped ports: - - '4567:4567' # comment this out if you don't want to expose NodeBB to the host, or change the first number to any port you want + - '4567:4567' volumes: - nodebb-build:/usr/src/app/build - nodebb-uploads:/usr/src/app/public/uploads - nodebb-config:/opt/config - ./install/docker/setup.json:/usr/src/app/setup.json - + environment: + - OLLAMA_URL=http://ollama:11434/api/chat + depends_on: + - redis + - ollama redis: image: redis:8.4.0-alpine restart: unless-stopped command: ['redis-server', '--appendonly', 'yes', '--loglevel', 'warning'] - # command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"] # uncomment if you want to use snapshotting instead of AOF volumes: - redis-data:/data - + ollama: + image: ollama/ollama:latest + restart: unless-stopped + volumes: + - ollama-data:/root/.ollama + entrypoint: ["/bin/sh", "-c", "ollama serve & sleep 5 && ollama pull llama3.1:8b && wait"] volumes: redis-data: driver: local @@ -29,24 +35,23 @@ volumes: o: bind type: none device: ./.docker/database/redis - nodebb-build: driver: local driver_opts: o: bind type: none device: ./.docker/build - nodebb-uploads: driver: local driver_opts: o: bind type: none device: ./.docker/public/uploads - nodebb-config: driver: local driver_opts: o: bind type: none device: ./.docker/config + ollama-data: + driver: local \ No newline at end of file diff --git a/public/openapi/write.yaml b/public/openapi/write.yaml index b235ee6a72..4cdb07ea82 100644 --- a/public/openapi/write.yaml +++ b/public/openapi/write.yaml @@ -51,246 +51,250 @@ tags: description: File upload routes paths: /ping: - $ref: 'write/ping.yaml' + $ref: "write/ping.yaml" /utilities/login: - $ref: 'write/login.yaml' + $ref: "write/login.yaml" /users/: - $ref: 'write/users.yaml' + $ref: "write/users.yaml" + /users/translator/language: + $ref: "write/users/translator/language.yaml" /users/{uid}: - $ref: 'write/users/uid.yaml' + $ref: "write/users/uid.yaml" /users/{uid}/picture: - $ref: 'write/users/uid/picture.yaml' + $ref: "write/users/uid/picture.yaml" /users/{uid}/content: - $ref: 'write/users/uid/content.yaml' + $ref: "write/users/uid/content.yaml" /users/{uid}/account: - $ref: 'write/users/uid/account.yaml' + $ref: "write/users/uid/account.yaml" /users/{uid}/status: - $ref: 'write/users/uid/status.yaml' + $ref: "write/users/uid/status.yaml" /users/{uid}/status/{status}: - $ref: 'write/users/uid/status/status.yaml' + $ref: "write/users/uid/status/status.yaml" /users/{uid}/chat: - $ref: 'write/users/uid/chat.yaml' + $ref: "write/users/uid/chat.yaml" /users/{uid}/settings: - $ref: 'write/users/uid/settings.yaml' + $ref: "write/users/uid/settings.yaml" /users/{uid}/password: - $ref: 'write/users/uid/password.yaml' + $ref: "write/users/uid/password.yaml" /users/{uid}/follow: - $ref: 'write/users/uid/follow.yaml' + $ref: "write/users/uid/follow.yaml" /users/{uid}/ban: - $ref: 'write/users/uid/ban.yaml' + $ref: "write/users/uid/ban.yaml" /users/{uid}/mute: - $ref: 'write/users/uid/mute.yaml' + $ref: "write/users/uid/mute.yaml" /users/{uid}/tokens: - $ref: 'write/users/uid/tokens.yaml' + $ref: "write/users/uid/tokens.yaml" /users/{uid}/tokens/{token}: - $ref: 'write/users/uid/tokens/token.yaml' + $ref: "write/users/uid/tokens/token.yaml" /users/{uid}/sessions/{uuid}: - $ref: 'write/users/uid/sessions/uuid.yaml' + $ref: "write/users/uid/sessions/uuid.yaml" /users/{uid}/invites: - $ref: 'write/users/uid/invites.yaml' + $ref: "write/users/uid/invites.yaml" /users/{uid}/invites/groups: - $ref: 'write/users/uid/invites/groups.yaml' + $ref: "write/users/uid/invites/groups.yaml" /users/{uid}/emails: - $ref: 'write/users/uid/emails.yaml' + $ref: "write/users/uid/emails.yaml" /users/{uid}/emails/{email}: - $ref: 'write/users/uid/emails/email.yaml' + $ref: "write/users/uid/emails/email.yaml" /users/{uid}/emails/{email}/confirm: - $ref: 'write/users/uid/emails/email/confirm.yaml' + $ref: "write/users/uid/emails/email/confirm.yaml" /users/{uid}/exports/{type}: - $ref: 'write/users/uid/exports/type.yaml' + $ref: "write/users/uid/exports/type.yaml" /groups/: - $ref: 'write/groups.yaml' + $ref: "write/groups.yaml" /groups/{slug}: - $ref: 'write/groups/slug.yaml' + $ref: "write/groups/slug.yaml" /groups/{slug}/members: - $ref: 'write/groups/slug/members.yaml' + $ref: "write/groups/slug/members.yaml" /groups/{slug}/membership/{uid}: - $ref: 'write/groups/slug/membership/uid.yaml' + $ref: "write/groups/slug/membership/uid.yaml" /groups/{slug}/ownership/{uid}: - $ref: 'write/groups/slug/ownership/uid.yaml' + $ref: "write/groups/slug/ownership/uid.yaml" /groups/{slug}/pending: - $ref: 'write/groups/slug/pending.yaml' + $ref: "write/groups/slug/pending.yaml" /groups/{slug}/pending/{uid}: - $ref: 'write/groups/slug/pending/uid.yaml' + $ref: "write/groups/slug/pending/uid.yaml" /groups/{slug}/invites: - $ref: 'write/groups/slug/invites.yaml' + $ref: "write/groups/slug/invites.yaml" /groups/{slug}/invites/{uid}: - $ref: 'write/groups/slug/invites/uid.yaml' + $ref: "write/groups/slug/invites/uid.yaml" /categories/: - $ref: 'write/categories.yaml' + $ref: "write/categories.yaml" /categories/{cid}: - $ref: 'write/categories/cid.yaml' + $ref: "write/categories/cid.yaml" /categories/{cid}/count: - $ref: 'write/categories/cid/count.yaml' + $ref: "write/categories/cid/count.yaml" /categories/{cid}/posts: - $ref: 'write/categories/cid/posts.yaml' + $ref: "write/categories/cid/posts.yaml" /categories/{cid}/children: - $ref: 'write/categories/cid/children.yaml' + $ref: "write/categories/cid/children.yaml" /categories/{cid}/topics: - $ref: 'write/categories/cid/topics.yaml' + $ref: "write/categories/cid/topics.yaml" /categories/{cid}/watch: - $ref: 'write/categories/cid/watch.yaml' + $ref: "write/categories/cid/watch.yaml" /categories/{cid}/privileges: - $ref: 'write/categories/cid/privileges.yaml' + $ref: "write/categories/cid/privileges.yaml" /categories/{cid}/privileges/{privilege}: - $ref: 'write/categories/cid/privileges/privilege.yaml' + $ref: "write/categories/cid/privileges/privilege.yaml" /categories/{cid}/moderator/{uid}: - $ref: 'write/categories/cid/moderator/uid.yaml' + $ref: "write/categories/cid/moderator/uid.yaml" /categories/{cid}/follow: - $ref: 'write/categories/cid/follow.yaml' + $ref: "write/categories/cid/follow.yaml" /topics/: - $ref: 'write/topics.yaml' + $ref: "write/topics.yaml" /topics/{tid}: - $ref: 'write/topics/tid.yaml' + $ref: "write/topics/tid.yaml" /topics/{tid}/state: - $ref: 'write/topics/tid/state.yaml' + $ref: "write/topics/tid/state.yaml" /topics/{tid}/lock: - $ref: 'write/topics/tid/lock.yaml' + $ref: "write/topics/tid/lock.yaml" /topics/{tid}/resolve: - $ref: 'write/topics/tid/resolve.yaml' + $ref: "write/topics/tid/resolve.yaml" /topics/{tid}/answer/{pid}: - $ref: 'write/topics/tid/answer/pid.yaml' + $ref: "write/topics/tid/answer/pid.yaml" /topics/{tid}/answer: - $ref: 'write/topics/tid/answer.yaml' + $ref: "write/topics/tid/answer.yaml" /topics/{tid}/pin: - $ref: 'write/topics/tid/pin.yaml' + $ref: "write/topics/tid/pin.yaml" /topics/{tid}/follow: - $ref: 'write/topics/tid/follow.yaml' + $ref: "write/topics/tid/follow.yaml" /topics/{tid}/ignore: - $ref: 'write/topics/tid/ignore.yaml' + $ref: "write/topics/tid/ignore.yaml" /topics/{tid}/tags: - $ref: 'write/topics/tid/tags.yaml' + $ref: "write/topics/tid/tags.yaml" /topics/{tid}/thumbs: - $ref: 'write/topics/tid/thumbs.yaml' + $ref: "write/topics/tid/thumbs.yaml" /topics/{tid}/thumbs/order: - $ref: 'write/topics/tid/thumbs/order.yaml' + $ref: "write/topics/tid/thumbs/order.yaml" /topics/{tid}/events: - $ref: 'write/topics/tid/events.yaml' + $ref: "write/topics/tid/events.yaml" /topics/{tid}/events/{eventId}: - $ref: 'write/topics/tid/events/eventId.yaml' + $ref: "write/topics/tid/events/eventId.yaml" /topics/{tid}/read: - $ref: 'write/topics/tid/read.yaml' + $ref: "write/topics/tid/read.yaml" /topics/{tid}/bump: - $ref: 'write/topics/tid/bump.yaml' + $ref: "write/topics/tid/bump.yaml" /topics/{tid}/move: - $ref: 'write/topics/tid/move.yaml' + $ref: "write/topics/tid/move.yaml" /topics/{tid}/crossposts: - $ref: 'write/topics/tid/crossposts.yaml' + $ref: "write/topics/tid/crossposts.yaml" /tags/{tag}/follow: - $ref: 'write/tags/tag/follow.yaml' + $ref: "write/tags/tag/follow.yaml" /posts/{pid}: - $ref: 'write/posts/pid.yaml' + $ref: "write/posts/pid.yaml" /posts/{pid}/index: - $ref: 'write/posts/pid/index.yaml' + $ref: "write/posts/pid/index.yaml" /posts/{pid}/raw: - $ref: 'write/posts/pid/raw.yaml' + $ref: "write/posts/pid/raw.yaml" /posts/{pid}/summary: - $ref: 'write/posts/pid/summary.yaml' + $ref: "write/posts/pid/summary.yaml" /posts/{pid}/state: - $ref: 'write/posts/pid/state.yaml' + $ref: "write/posts/pid/state.yaml" /posts/{pid}/move: - $ref: 'write/posts/pid/move.yaml' + $ref: "write/posts/pid/move.yaml" /posts/{pid}/vote: - $ref: 'write/posts/pid/vote.yaml' + $ref: "write/posts/pid/vote.yaml" /posts/{pid}/voters: - $ref: 'write/posts/pid/voters.yaml' + $ref: "write/posts/pid/voters.yaml" /posts/{pid}/upvoters: - $ref: 'write/posts/pid/upvoters.yaml' + $ref: "write/posts/pid/upvoters.yaml" /posts/{pid}/announcers: - $ref: 'write/posts/pid/announcers.yaml' + $ref: "write/posts/pid/announcers.yaml" /posts/{pid}/announcers/tooltip: - $ref: 'write/posts/pid/announcers-tooltip.yaml' + $ref: "write/posts/pid/announcers-tooltip.yaml" /posts/{pid}/bookmark: - $ref: 'write/posts/pid/bookmark.yaml' + $ref: "write/posts/pid/bookmark.yaml" /posts/{pid}/diffs: - $ref: 'write/posts/pid/diffs.yaml' + $ref: "write/posts/pid/diffs.yaml" /posts/{pid}/diffs/{since}: - $ref: 'write/posts/pid/diffs/since.yaml' + $ref: "write/posts/pid/diffs/since.yaml" /posts/{pid}/diffs/{timestamp}: - $ref: 'write/posts/pid/diffs/timestamp.yaml' + $ref: "write/posts/pid/diffs/timestamp.yaml" /posts/{pid}/replies: - $ref: 'write/posts/pid/replies.yaml' + $ref: "write/posts/pid/replies.yaml" /posts/queue/{id}: - $ref: 'write/posts/queue/id.yaml' + $ref: "write/posts/queue/id.yaml" /posts/queue/{id}/notify: - $ref: 'write/posts/queue/notify.yaml' + $ref: "write/posts/queue/notify.yaml" /posts/{pid}/owner: - $ref: 'write/posts/pid/owner.yaml' + $ref: "write/posts/pid/owner.yaml" /posts/owner: - $ref: 'write/posts/owner.yaml' + $ref: "write/posts/owner.yaml" + /posts/translate: + $ref: "write/posts/translate.yaml" /chats/: - $ref: 'write/chats.yaml' + $ref: "write/chats.yaml" /chats/unread: - $ref: 'write/chats/unread.yaml' + $ref: "write/chats/unread.yaml" /chats/sort: - $ref: 'write/chats/sort.yaml' + $ref: "write/chats/sort.yaml" /chats/{roomId}: - $ref: 'write/chats/roomId.yaml' + $ref: "write/chats/roomId.yaml" /chats/{roomId}/state: - $ref: 'write/chats/roomId/state.yaml' + $ref: "write/chats/roomId/state.yaml" /chats/{roomId}/watch: - $ref: 'write/chats/roomId/watch.yaml' + $ref: "write/chats/roomId/watch.yaml" /chats/{roomId}/typing: - $ref: 'write/chats/roomId/typing.yaml' + $ref: "write/chats/roomId/typing.yaml" /chats/{roomId}/users: - $ref: 'write/chats/roomId/users.yaml' + $ref: "write/chats/roomId/users.yaml" /chats/{roomId}/users/{uid}: - $ref: 'write/chats/roomId/users/uid.yaml' + $ref: "write/chats/roomId/users/uid.yaml" /chats/{roomId}/owners/{uid}: - $ref: 'write/chats/roomId/owners/uid.yaml' + $ref: "write/chats/roomId/owners/uid.yaml" /chats/{roomId}/messages: - $ref: 'write/chats/roomId/messages.yaml' + $ref: "write/chats/roomId/messages.yaml" /chats/{roomId}/messages/pinned: - $ref: 'write/chats/roomId/messages/pinned.yaml' + $ref: "write/chats/roomId/messages/pinned.yaml" /chats/{roomId}/messages/{mid}: - $ref: 'write/chats/roomId/messages/mid.yaml' + $ref: "write/chats/roomId/messages/mid.yaml" /chats/{roomId}/messages/{mid}/pin: - $ref: 'write/chats/roomId/messages/mid/pin.yaml' + $ref: "write/chats/roomId/messages/mid/pin.yaml" /chats/{roomId}/messages/{mid}/raw: - $ref: 'write/chats/roomId/messages/mid/raw.yaml' + $ref: "write/chats/roomId/messages/mid/raw.yaml" /chats/{roomId}/messages/{mid}/ip: - $ref: 'write/chats/roomId/messages/mid/ip.yaml' + $ref: "write/chats/roomId/messages/mid/ip.yaml" /flags/: - $ref: 'write/flags.yaml' + $ref: "write/flags.yaml" /flags/{flagId}: - $ref: 'write/flags/flagId.yaml' + $ref: "write/flags/flagId.yaml" /flags/{flagId}/report: - $ref: 'write/flags/flagId/report.yaml' + $ref: "write/flags/flagId/report.yaml" /flags/{flagId}/notes: - $ref: 'write/flags/flagId/notes.yaml' + $ref: "write/flags/flagId/notes.yaml" /flags/{flagId}/notes/{datetime}: - $ref: 'write/flags/flagId/notes/datetime.yaml' + $ref: "write/flags/flagId/notes/datetime.yaml" /search/categories: - $ref: 'write/search/categories.yaml' + $ref: "write/search/categories.yaml" /search/chats/{roomId}/users: - $ref: 'write/search/chats/roomId/users.yaml' + $ref: "write/search/chats/roomId/users.yaml" /search/chats/{roomId}/messages: - $ref: 'write/search/chats/roomId/messages.yaml' + $ref: "write/search/chats/roomId/messages.yaml" /admin/settings/{setting}: - $ref: 'write/admin/settings/setting.yaml' + $ref: "write/admin/settings/setting.yaml" /admin/analytics: - $ref: 'write/admin/analytics.yaml' + $ref: "write/admin/analytics.yaml" /admin/analytics/{set}: - $ref: 'write/admin/analytics/set.yaml' + $ref: "write/admin/analytics/set.yaml" /admin/tokens: - $ref: 'write/admin/tokens.yaml' + $ref: "write/admin/tokens.yaml" /admin/tokens/{token}: - $ref: 'write/admin/tokens/token.yaml' + $ref: "write/admin/tokens/token.yaml" /admin/tokens/{token}/roll: - $ref: 'write/admin/tokens/token/roll.yaml' + $ref: "write/admin/tokens/token/roll.yaml" /admin/chats/{roomId}: - $ref: 'write/admin/chats/roomId.yaml' + $ref: "write/admin/chats/roomId.yaml" /admin/groups: - $ref: 'write/admin/groups.yaml' + $ref: "write/admin/groups.yaml" /admin/activitypub/rules: - $ref: 'write/admin/activitypub/rules.yaml' + $ref: "write/admin/activitypub/rules.yaml" /admin/activitypub/rules/{rid}: - $ref: 'write/admin/activitypub/rules/rid.yaml' + $ref: "write/admin/activitypub/rules/rid.yaml" /admin/activitypub/relays: - $ref: 'write/admin/activitypub/relays.yaml' + $ref: "write/admin/activitypub/relays.yaml" /admin/activitypub/relays/{url}: - $ref: 'write/admin/activitypub/relays/url.yaml' + $ref: "write/admin/activitypub/relays/url.yaml" /files/: - $ref: 'write/files.yaml' + $ref: "write/files.yaml" /files/folder: - $ref: 'write/files/folder.yaml' + $ref: "write/files/folder.yaml" diff --git a/public/openapi/write/posts/translate.yaml b/public/openapi/write/posts/translate.yaml new file mode 100644 index 0000000000..4d2aeeaccd --- /dev/null +++ b/public/openapi/write/posts/translate.yaml @@ -0,0 +1,44 @@ +post: + tags: + - posts + summary: Translate a post + description: Translates a post into another language. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + pid: + type: integer + description: A valid post id + example: 2 + language: + type: string + description: The target language to translate to + example: en + required: + - pid + responses: + '200': + description: Post successfully translated + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../components/schemas/Status.yaml#/Status + response: + type: object + properties: {} + '400': + description: Translation failed or bad request + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../components/schemas/Status.yaml#/Status diff --git a/public/openapi/write/users/translator/language.yaml b/public/openapi/write/users/translator/language.yaml new file mode 100644 index 0000000000..72237e5077 --- /dev/null +++ b/public/openapi/write/users/translator/language.yaml @@ -0,0 +1,45 @@ +get: + tags: + - users + summary: get language configuration for the nodebb-plugin-translator + description: Returns the user language settings for translator + responses: + "200": + description: Returns translator language config + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: object +post: + tags: + - users + summary: save language configuration for the nodebb-plugin-translator + description: Saves the user language settings for translator + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + language: + type: string + description: The language to translate to + example: en + responses: + "200": + description: Returns success status + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: object diff --git a/public/src/client/topic.js b/public/src/client/topic.js index b54185bf80..c58b1fd5f3 100644 --- a/public/src/client/topic.js +++ b/public/src/client/topic.js @@ -70,6 +70,7 @@ define('forum/topic', [ handleBookmark(tid); handleThumbs(); addCrosspostsHandler(); + configureTranslateButton(); $(window).on('scroll', utils.debounce(updateTopicTitle, 250)); @@ -507,6 +508,48 @@ define('forum/topic', [ } } + function configureTranslateButton() { + $('.topic').on('click', '.translate-btn', async function (e) { + e.preventDefault(); + const controls = $(this).closest('.translate-controls'); + const pid = controls.data('pid'); + const translatedDiv = controls.next('.translated-content'); + const statusEl = controls.find('.translate-status'); + + // If already translated, just toggle + if (translatedDiv.text().trim()) { + translatedDiv.toggle(); + const isVisible = translatedDiv.is(':visible'); + $(this).html(isVisible ? + ' Hide translation' : + ' Translate' + ); + return; + } + + // Show loading state + $(this).html(' Translating...'); + + try { + const result = await api.post('/posts/translate', { pid: pid }); + if (result && result.ok) { + if (result.translated) { + translatedDiv.html(result.translation).show(); + $(this).html(' Hide translation'); + } else { + statusEl.text('Post is already in your preferred language.').show(); + $(this).html(' Translate'); + } + } else { + statusEl.text('Translation failed. Please try again.').show(); + $(this).html(' Translate'); + } + } catch (err) { + statusEl.text('Translation unavailable.').show(); + $(this).html(' Translate'); + } + }); + } return Topic; }); diff --git a/src/api/posts.js b/src/api/posts.js index c9a570dec4..3be1604385 100644 --- a/src/api/posts.js +++ b/src/api/posts.js @@ -2,7 +2,7 @@ const validator = require('validator'); const _ = require('lodash'); - +const postTranslator = require('../llm/postTranslator'); const db = require('../database'); const utils = require('../utils'); const user = require('../user'); @@ -44,6 +44,30 @@ postsAPI.get = async function (caller, data) { return post; }; +postsAPI.translate = async function (caller, data) { + console.log('api.posts.translate input', data); + + const pid = parseInt(data.pid, 10); + + if (!Number.isInteger(pid) || pid <= 0) { + console.log('api.posts.translate invalid pid', data.pid); + return { ok: false, error: 'invalid-pid' }; + } + + let targetLanguage = data.language; + + if (!targetLanguage && caller.uid) { + targetLanguage = await user.getUserField(caller.uid, 'translatorPreferredLanguage'); + } + + console.log('api.posts.translate before helper', { pid, targetLanguage }); + + const result = await postTranslator.translatePost(pid, targetLanguage || 'en'); + + console.log('api.posts.translate result', result); + + return result; +}; postsAPI.getIndex = async (caller, { pid, sort }) => { const tid = await posts.getPostField(pid, 'tid'); diff --git a/src/api/users.js b/src/api/users.js index 4fb8155734..99ea3a49b3 100644 --- a/src/api/users.js +++ b/src/api/users.js @@ -5,7 +5,7 @@ const fs = require('fs').promises; const validator = require('validator'); const winston = require('winston'); - +const postTranslator = require('../llm/postTranslator'); const db = require('../database'); const user = require('../user'); const groups = require('../groups'); @@ -28,6 +28,29 @@ const hasAdminPrivilege = async (uid, privilege) => { throw new Error('[[error:no-privileges]]'); } }; +usersAPI.getTranslatorLanguage = async function (caller) { + const preferredLanguage = caller.uid ? + await user.getUserField(caller.uid, 'translatorPreferredLanguage') : + 'en'; + + return { + language: postTranslator.sanitizeLanguage(preferredLanguage), + }; +}; + +usersAPI.setTranslatorLanguage = async function (caller, data) { + if (!caller.uid) { + throw new Error('[[error:no-privileges]]'); + } + + const language = postTranslator.sanitizeLanguage(data.language); + + await user.setUserField(caller.uid, 'translatorPreferredLanguage', language); + + return { + language, + }; +}; usersAPI.create = async function (caller, data) { if (!data) { diff --git a/src/controllers/write/posts.js b/src/controllers/write/posts.js index 9e8053d17d..6f0ba2a26c 100644 --- a/src/controllers/write/posts.js +++ b/src/controllers/write/posts.js @@ -89,6 +89,11 @@ Posts.purge = async (req, res) => { helpers.formatApiResponse(200, res); }; +Posts.translate = async function (req, res) { + const result = await api.posts.translate(req, req.body); + return res.status(result.ok ? 200 : 400).json(result); +}; + Posts.restore = async (req, res) => { await api.posts.restore(req, { pid: req.params.pid }); helpers.formatApiResponse(200, res); diff --git a/src/controllers/write/users.js b/src/controllers/write/users.js index a5cde6dad2..db51d7dcd1 100644 --- a/src/controllers/write/users.js +++ b/src/controllers/write/users.js @@ -57,6 +57,16 @@ Users.deleteAccount = async (req, res) => { helpers.formatApiResponse(200, res); }; +Users.getTranslatorLanguage = async function (req, res) { + const result = await api.users.getTranslatorLanguage(req); + res.json(result); +}; + +Users.setTranslatorLanguage = async function (req, res) { + const result = await api.users.setTranslatorLanguage(req, req.body); + res.json(result); +}; + Users.deleteMany = async (req, res) => { await api.users.deleteMany(req, req.body); helpers.formatApiResponse(200, res); diff --git a/src/llm/postTranslator.js b/src/llm/postTranslator.js new file mode 100644 index 0000000000..0b7535bce6 --- /dev/null +++ b/src/llm/postTranslator.js @@ -0,0 +1,214 @@ +'use strict'; + +const db = require('./../database'); +const posts = require('./../posts'); + +const DEFAULT_LANGUAGE = 'en'; +const OLLAMA_URL = process.env.OLLAMA_URL || 'http://127.0.0.1:11434/api/chat'; +const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'llama3.1:8b'; + +const allowedLanguages = new Set([ + 'en', + 'es', + 'fr', + 'de', + 'it', + 'pt', + 'zh', + 'ja', + 'ko', + 'ar', + 'ru', + 'hi', +]); + +function sanitizeLanguage(language) { + if (typeof language !== 'string') { + return DEFAULT_LANGUAGE; + } + + const normalized = language.trim().toLowerCase(); + + if (!allowedLanguages.has(normalized)) { + return DEFAULT_LANGUAGE; + } + + return normalized; +} + +function tryParseJson(content) { + if (typeof content !== 'string' || !content.trim()) { + return null; + } + + try { + return JSON.parse(content); + } catch (err) { + const match = content.match(/\{[\s\S]*\}/); + if (!match) { + return null; + } + + try { + return JSON.parse(match[0]); + } catch (err2) { + return null; + } + } +} + +async function queryOllama(text, targetLanguage) { + const prompt = [ + 'You are a language classification and translation assistant.', + `Analyze the user text and determine whether it is already in the target language: ${targetLanguage}.`, + 'Return ONLY valid JSON with exactly these keys:', + '{"is_target_language": boolean, "detected_language": string, "translation": string}', + 'If the text is already in the target language, set "is_target_language" to true and return the original text in "translation".', + 'Otherwise translate the text into the target language.', + 'Do not include markdown. Do not include explanation.', + ].join(' '); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + + try { + const response = await fetch(OLLAMA_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: OLLAMA_MODEL, + stream: false, + format: 'json', + messages: [ + { + role: 'system', + content: prompt, + }, + { + role: 'user', + content: text, + }, + ], + }), + signal: controller.signal, + }); + + clearTimeout(timeout); + + if (!response.ok) { + return { ok: false }; + } + + const data = await response.json(); + const content = data && data.message && typeof data.message.content === 'string' ? + data.message.content.trim() : + ''; + const parsed = tryParseJson(content); + + if (!parsed) { + return { ok: false }; + } + + let { + is_target_language: isTargetLanguage, + detected_language: detectedLanguage, + translation, + } = parsed; + + if (typeof isTargetLanguage !== 'boolean') { + isTargetLanguage = String(isTargetLanguage).toLowerCase() === 'true'; + } + + if (typeof detectedLanguage !== 'string') { + detectedLanguage = ''; + } + + if (typeof translation !== 'string') { + translation = String(translation || ''); + } + + if (!translation.length) { + return { ok: false }; + } + + return { + ok: true, + isTargetLanguage, + detectedLanguage, + translation, + }; + } catch (err) { + clearTimeout(timeout); + return { ok: false }; + } +} + +async function translatePost(pid, targetLanguage) { + const normalizedLanguage = sanitizeLanguage(targetLanguage); + const cacheKey = `translator:cache:${pid}:${normalizedLanguage}`; + + const cached = await db.getObject(cacheKey); + if (cached && typeof cached.translation === 'string' && cached.translation.length) { + return { + ok: true, + translated: true, + fromCache: true, + targetLanguage: normalizedLanguage, + detectedLanguage: cached.detectedLanguage || '', + translation: cached.translation, + }; + } + + const postData = await posts.getPostFields(pid, ['pid', 'content']); + const rawContent = typeof postData.content === 'string' ? postData.content.trim() : ''; + + if (!rawContent) { + return { + ok: false, + error: 'empty-post', + }; + } + + const llmResult = await queryOllama(rawContent, normalizedLanguage); + + if (!llmResult.ok) { + return { + ok: false, + error: 'translation-failed', + }; + } + + if (llmResult.isTargetLanguage) { + return { + ok: true, + translated: false, + fromCache: false, + targetLanguage: normalizedLanguage, + detectedLanguage: llmResult.detectedLanguage || '', + translation: rawContent, + }; + } + + await db.setObject(cacheKey, { + translation: llmResult.translation, + detectedLanguage: llmResult.detectedLanguage || '', + updatedAt: Date.now(), + }); + + return { + ok: true, + translated: true, + fromCache: false, + targetLanguage: normalizedLanguage, + detectedLanguage: llmResult.detectedLanguage || '', + translation: llmResult.translation, + }; +} + +module.exports = { + sanitizeLanguage, + queryOllama, + translatePost, +}; \ No newline at end of file diff --git a/src/routes/write/posts.js b/src/routes/write/posts.js index ed2c372461..8c5bdd1d69 100644 --- a/src/routes/write/posts.js +++ b/src/routes/write/posts.js @@ -45,7 +45,7 @@ module.exports = function () { setupApiRoute(router, 'delete', '/queue/:id', controllers.write.posts.removeQueuedPost); setupApiRoute(router, 'put', '/queue/:id', controllers.write.posts.editQueuedPost); setupApiRoute(router, 'post', '/queue/:id/notify', [middleware.checkRequired.bind(null, ['message'])], controllers.write.posts.notifyQueuedPostOwner); - + setupApiRoute(router, 'post', '/translate', [middleware.ensureLoggedIn, middleware.checkRequired.bind(null, ['pid'])], controllers.write.posts.translate); setupApiRoute(router, 'put', '/:pid/owner', [middleware.ensureLoggedIn, middleware.assert.post, middleware.checkRequired.bind(null, ['uid'])], controllers.write.posts.changeOwner); setupApiRoute(router, 'post', '/owner', [middleware.ensureLoggedIn, middleware.checkRequired.bind(null, ['pids', 'uid'])], controllers.write.posts.changeOwner); diff --git a/src/routes/write/users.js b/src/routes/write/users.js index 139ed483c3..5a608c981a 100644 --- a/src/routes/write/users.js +++ b/src/routes/write/users.js @@ -14,7 +14,8 @@ function guestRoutes() { function authenticatedRoutes() { const middlewares = [middleware.ensureLoggedIn]; - + setupApiRoute(router, 'get', '/translator/language', [], controllers.write.users.getTranslatorLanguage); + setupApiRoute(router, 'post', '/translator/language', [...middlewares, middleware.checkRequired.bind(null, ['language'])], controllers.write.users.setTranslatorLanguage); setupApiRoute(router, 'post', '/', [...middlewares, middleware.checkRequired.bind(null, ['username'])], controllers.write.users.create); setupApiRoute(router, 'delete', '/', [...middlewares, middleware.checkRequired.bind(null, ['uids'])], controllers.write.users.deleteMany); 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 e975b2bf36..8ca9c145b5 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 @@ -82,8 +82,15 @@