diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index 16e72ba2bc..11bd91d744 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -577,6 +577,7 @@ services: # - HULYLAKE_URL=http://huly.local:8096 # - QUEUE_CONFIG=${QUEUE_CONFIG} # - QUEUE_REGION=cockroach +# - OPENAI_API_KEY= # restart: unless-stopped backup-cockroach: image: hardcoreeng/backup diff --git a/plugins/communication-assets/lang/cs.json b/plugins/communication-assets/lang/cs.json index 3ab7284306..30e55e3ea2 100644 --- a/plugins/communication-assets/lang/cs.json +++ b/plugins/communication-assets/lang/cs.json @@ -89,6 +89,7 @@ "AnonymousQuiz": "Anonymní test", "Direct": "Přímý", "Directs": "Přímé", - "Members": "Členové" + "Members": "Členové", + "Translate": "Přeložit" } } diff --git a/plugins/communication-assets/lang/de.json b/plugins/communication-assets/lang/de.json index ffc84c8520..919e842c86 100644 --- a/plugins/communication-assets/lang/de.json +++ b/plugins/communication-assets/lang/de.json @@ -89,6 +89,7 @@ "AnonymousQuiz": "Anonyme quiz", "Direct": "Direkt", "Directs": "Direkte", - "Members": "Mitglieder" + "Members": "Mitglieder", + "Translate": "Übersetzen" } } diff --git a/plugins/communication-assets/lang/en.json b/plugins/communication-assets/lang/en.json index 3f8dd1deee..2685cc37be 100644 --- a/plugins/communication-assets/lang/en.json +++ b/plugins/communication-assets/lang/en.json @@ -89,6 +89,7 @@ "AnonymousQuiz": "Anonymous quiz", "Direct": "Direct", "Directs": "Directs", - "Members": "Members" + "Members": "Members", + "Translate": "Translate" } } diff --git a/plugins/communication-assets/lang/es.json b/plugins/communication-assets/lang/es.json index 4a1964247f..dd75030327 100644 --- a/plugins/communication-assets/lang/es.json +++ b/plugins/communication-assets/lang/es.json @@ -89,6 +89,7 @@ "AnonymousQuiz": "Quiz anónimo", "Direct": "Directo", "Directs": "Directos", - "Members": "Miembros" + "Members": "Miembros", + "Translate": "Traducir" } } diff --git a/plugins/communication-assets/lang/fr.json b/plugins/communication-assets/lang/fr.json index b29d778746..1b0211e518 100644 --- a/plugins/communication-assets/lang/fr.json +++ b/plugins/communication-assets/lang/fr.json @@ -89,6 +89,7 @@ "AnonymousQuiz": "Quiz anonyme", "Direct": "Direct", "Directs": "Directs", - "Members": "Membres" + "Members": "Membres", + "Translate": "Traduire" } } diff --git a/plugins/communication-assets/lang/it.json b/plugins/communication-assets/lang/it.json index adc10d8daa..e8a4154f20 100644 --- a/plugins/communication-assets/lang/it.json +++ b/plugins/communication-assets/lang/it.json @@ -89,6 +89,7 @@ "AnonymousQuiz": "Quiz anonimo", "Direct": "Diretto", "Directs": "Diretti", - "Members": "Membri" + "Members": "Membri", + "Translate": "Traduci" } } diff --git a/plugins/communication-assets/lang/ja.json b/plugins/communication-assets/lang/ja.json index ab54ef672e..285a81c42c 100644 --- a/plugins/communication-assets/lang/ja.json +++ b/plugins/communication-assets/lang/ja.json @@ -89,6 +89,7 @@ "AnonymousQuiz": "匿名クイズ", "Direct": "ダイレクト", "Directs": "ダイレクト", - "Members": "メンバー" + "Members": "メンバー", + "Translate": "翻訳" } } diff --git a/plugins/communication-assets/lang/pt.json b/plugins/communication-assets/lang/pt.json index ed75b1dda5..3c4d03340a 100644 --- a/plugins/communication-assets/lang/pt.json +++ b/plugins/communication-assets/lang/pt.json @@ -89,6 +89,7 @@ "AnonymousQuiz": "Quiz anônimo", "Direct": "Direto", "Directs": "Diretos", - "Members": "Membros" + "Members": "Membros", + "Translate": "Traduzir" } } diff --git a/plugins/communication-assets/lang/ru.json b/plugins/communication-assets/lang/ru.json index f981320948..442e53f2f9 100644 --- a/plugins/communication-assets/lang/ru.json +++ b/plugins/communication-assets/lang/ru.json @@ -89,6 +89,7 @@ "AnonymousQuiz": "Анонимная викторина", "Direct": "Личные сообщения", "Directs": "Личные сообщения", - "Members": "Участники" + "Members": "Участники", + "Translate": "Перевести" } } diff --git a/plugins/communication-assets/lang/tr.json b/plugins/communication-assets/lang/tr.json index ee8b722a0a..90e9daed84 100644 --- a/plugins/communication-assets/lang/tr.json +++ b/plugins/communication-assets/lang/tr.json @@ -89,6 +89,7 @@ "AnonymousQuiz": "Anonim quiz", "Direct": "Direkt", "Directs": "Direktler", - "Members": "Üyeler" + "Members": "Üyeler", + "Translate": "Çevir" } } diff --git a/plugins/communication-assets/lang/zh.json b/plugins/communication-assets/lang/zh.json index 9a1545a38c..e8be955972 100644 --- a/plugins/communication-assets/lang/zh.json +++ b/plugins/communication-assets/lang/zh.json @@ -89,6 +89,7 @@ "AnonymousQuiz": "匿名测验", "Direct": "私信", "Directs": "私信", - "Members": "成员" + "Members": "成员", + "Translate": "翻译" } } diff --git a/plugins/communication-resources/src/actions.ts b/plugins/communication-resources/src/actions.ts index b5b8eaf091..c5cb1adfb4 100644 --- a/plugins/communication-resources/src/actions.ts +++ b/plugins/communication-resources/src/actions.ts @@ -47,7 +47,8 @@ import { messageEditingStore, showOriginalMessagesStore, threadCreateMessageStore, - translateMessagesStore + translateMessagesStore, + translateToStore } from './stores' export const addReaction: MessageActionFunction = async (message, card: Card, evt, onOpen, onClose) => { @@ -155,15 +156,16 @@ export const canReplyInThread: MessageActionVisibilityTester = (message: Message } export const translateMessage: MessageActionFunction = async (message: Message): Promise => { + const language = get(translateToStore) ?? get(languageStore) + if (isMessageManualTranslating(message.cardId, message.id)) return const result = get(translateMessagesStore).find((it) => it.cardId === message.cardId && it.messageId === message.id) - if (result != null) { - showOriginalMessagesStore.update((store) => - store.filter(([cId, mId]) => cId !== message.cardId || mId !== message.id) - ) - return - } + showOriginalMessagesStore.update((store) => + store.filter(([cId, mId]) => cId !== message.cardId || mId !== message.id) + ) + + if (result != null) return translateMessagesStore.update((store) => { store.push({ inProgress: true, messageId: message.id, cardId: message.cardId }) @@ -171,9 +173,8 @@ export const translateMessage: MessageActionFunction = async (message: Message): }) const markup = toMarkup(message.content) - const lang = get(languageStore) - const currentTranslate = message?.translates?.[lang] ?? '' - const response = currentTranslate !== '' ? toMarkup(currentTranslate) : (await aiTranslate(markup, lang))?.text + const currentTranslate = message?.translates?.[language] ?? '' + const response = currentTranslate !== '' ? toMarkup(currentTranslate) : (await aiTranslate(markup, language))?.text if (response !== undefined) { translateMessagesStore.update((store) => { diff --git a/plugins/communication-resources/src/components/message/MessageBody.svelte b/plugins/communication-resources/src/components/message/MessageBody.svelte index d7951c5486..31532b6e5a 100644 --- a/plugins/communication-resources/src/components/message/MessageBody.svelte +++ b/plugins/communication-resources/src/components/message/MessageBody.svelte @@ -31,9 +31,10 @@ showOriginalMessagesStore, isMessageTranslated, translateToStore, - dontTranslateStore + dontTranslateStore, + isMessageOriginalShown } from '../../stores' - import { showOriginalMessage } from '../../actions' + import { showOriginalMessage, translateMessage } from '../../actions' export let card: Card export let author: Person | undefined @@ -68,6 +69,13 @@ $translateMessagesStore, $showOriginalMessagesStore ) + $: isOriginalShown = isMessageOriginalShown( + message, + $translateToStore, + $dontTranslateStore, + $translateMessagesStore, + $showOriginalMessagesStore + ) {#if compact || hideHeader} @@ -132,11 +140,14 @@
- {/if} - {#if isTranslated} + {:else if isTranslated}
showOriginalMessage(message, card)}>
+ {:else if isOriginalShown} +
translateMessage(message, card)}> +
{/if} {#if !isEditing} @@ -224,7 +235,8 @@ font-weight: 400; } - .message__show-original { + .message__show-original, + .message__translate { font-size: 0.75rem; color: var(--global-tertiary-TextColor); cursor: pointer; diff --git a/plugins/communication-resources/src/components/message/MessageContentViewer.svelte b/plugins/communication-resources/src/components/message/MessageContentViewer.svelte index 2622a7c43e..56199b931e 100644 --- a/plugins/communication-resources/src/components/message/MessageContentViewer.svelte +++ b/plugins/communication-resources/src/components/message/MessageContentViewer.svelte @@ -51,6 +51,7 @@ $translateMessagesStore, $showOriginalMessagesStore ) + $: displayMarkup = translatedMarkup ?? toMarkup(message.content) $: if (prevContent !== message.content) { diff --git a/plugins/communication-resources/src/plugin.ts b/plugins/communication-resources/src/plugin.ts index 1d2b93786b..c48754cbd8 100644 --- a/plugins/communication-resources/src/plugin.ts +++ b/plugins/communication-resources/src/plugin.ts @@ -78,6 +78,7 @@ export default mergeIds(communicationId, communication, { LeftThe: '' as IntlString, Translating: '' as IntlString, ShowOriginal: '' as IntlString, + Translate: '' as IntlString, AddReaction: '' as IntlString, ReplyInThread: '' as IntlString, TranslateMessage: '' as IntlString, diff --git a/plugins/communication-resources/src/stores.ts b/plugins/communication-resources/src/stores.ts index 000870f32d..cb1f85c1c4 100644 --- a/plugins/communication-resources/src/stores.ts +++ b/plugins/communication-resources/src/stores.ts @@ -13,7 +13,14 @@ import { get, writable, derived } from 'svelte/store' import { createLabelsQuery, createQuery, onClient, onCommunicationClient } from '@hcengineering/presentation' -import { type CardID, type Label, type LabelID, type Message, type MessageID } from '@hcengineering/communication-types' +import { + MessageType, + type CardID, + type Label, + type LabelID, + type Message, + type MessageID +} from '@hcengineering/communication-types' import core, { getCurrentAccount, type Markup, type Ref } from '@hcengineering/core' import { languageStore } from '@hcengineering/ui' import cardPlugin, { type Card } from '@hcengineering/card' @@ -64,16 +71,13 @@ export function isShownManualTranslatedMessage (cardId: CardID, messageId: Messa return !showOriginal && result?.result != null } -export function isMessageTranslated ( +export function hasTranslate ( message: Message, translateTo: string | undefined, dontTranslate: string[], - translatedMessages: TranslateMessagesStatus[], - showOriginalMessage: Array<[CardID, MessageID]> + translatedMessages: TranslateMessagesStatus[] ): boolean { - const showOriginal = showOriginalMessage.some(([cId, mId]) => cId === message.cardId && mId === message.id) - if (showOriginal) return false - + if (message.type !== MessageType.Text) return false const manualTranslate = translatedMessages.find((it) => it.cardId === message.cardId && it.messageId === message.id) if (manualTranslate?.result != null) return true @@ -87,6 +91,32 @@ export function isMessageTranslated ( return res !== '' } +export function isMessageTranslated ( + message: Message, + translateTo: string | undefined, + dontTranslate: string[], + translatedMessages: TranslateMessagesStatus[], + showOriginalMessage: Array<[CardID, MessageID]> +): boolean { + const showOriginal = showOriginalMessage.some(([cId, mId]) => cId === message.cardId && mId === message.id) + if (showOriginal) return false + + return hasTranslate(message, translateTo, dontTranslate, translatedMessages) +} + +export function isMessageOriginalShown ( + message: Message, + translateTo: string | undefined, + dontTranslate: string[], + translatedMessages: TranslateMessagesStatus[], + showOriginalMessage: Array<[CardID, MessageID]> +): boolean { + const showOriginal = showOriginalMessage.some(([cId, mId]) => cId === message.cardId && mId === message.id) + if (!showOriginal) return false + + return hasTranslate(message, translateTo, dontTranslate, translatedMessages) +} + export function getMessageTranslation ( message: Message, translateTo: string | undefined, @@ -94,7 +124,9 @@ export function getMessageTranslation ( translatedMessages: TranslateMessagesStatus[], showOriginalMessage: Array<[CardID, MessageID]> ): Markup | undefined { + if (message.type !== MessageType.Text) return undefined const showOriginal = showOriginalMessage.some(([cId, mId]) => cId === message.cardId && mId === message.id) + if (showOriginal) return undefined const manualTranslate = translatedMessages.find((it) => it.cardId === message.cardId && it.messageId === message.id) diff --git a/services/translate/src/conroller.ts b/services/translate/src/conroller.ts index 4085d5bc5c..37820d1dfd 100644 --- a/services/translate/src/conroller.ts +++ b/services/translate/src/conroller.ts @@ -33,7 +33,6 @@ import { BlobID, CardID, Markdown, Message, MessageID } from '@hcengineering/com import { withRetry } from '@hcengineering/retry' import { Analytics } from '@hcengineering/analytics' import OpenAI from 'openai' -import { PlatformQueue } from '@hcengineering/server-core' import { MessageEventType, TranslateMessageEvent, UpdatePatchEvent } from '@hcengineering/communication-sdk-types' import { Storage } from './storage' @@ -46,8 +45,7 @@ export class Controller { constructor ( private readonly ctx: MeasureContext, - private readonly openai: OpenAI, - private readonly queue: PlatformQueue + private readonly openai: OpenAI ) { this.storage = new Storage(this.ctx) } @@ -139,6 +137,7 @@ export class Controller { blobId: BlobID ): Promise { const initialLanguage = message.language + const translateTo = await this.getLanguages(workspace) if (translateTo.length === 0) return @@ -183,6 +182,7 @@ export class Controller { } if (originalLanguage != null && originalLanguage !== '' && originalLanguage !== initialLanguage) { + ctx.info('update original language', { originalLanguage, id: message.id, content: message.content.slice(0, 50) }) txes.unshift(this.getUpdateLanguageTx(message.cardId, message.id, originalLanguage)) } @@ -281,24 +281,29 @@ export class Controller { markdown: string, lang: string ): Promise<{ original_language?: string, translation?: string } | undefined> { - const systemPropmpt = `You are a translation model. -You are a translation model. Your only task is to translate the given markdown text into the ${lang} language. + const systemPrompt = ` +You are a translation model. +Your only task is to translate the given Markdown text into the ${lang} language. Detect the original language of the input. -If the input markdown is already written in ${lang}, do not translate. Preserve names and terms if they have no clear equivalent. - Be as literal and accurate as possible while keeping the meaning natural. Keep markdown structire. +If the input Markdown is already written in ${lang}, do not translate. +Preserve names and terms if they have no clear equivalent. +Be as literal and accurate as possible while keeping the meaning natural. +Keep the Markdown structure. Output the result strictly as a JSON object in the following format: { "original_language": "", "translation": "" } -Do not add any explanations, comments, or extra text outside the JSON.` +Do not add any explanations, comments, or extra text outside the JSON. +`.trim() const response = await this.openai.chat.completions.create({ model: config.OpenAIModel, + response_format: { type: 'json_object' }, messages: [ { role: 'system', - content: systemPropmpt + content: systemPrompt }, { role: 'user', @@ -307,17 +312,17 @@ Do not add any explanations, comments, or extra text outside the JSON.` ] }) - const res = response.choices[0].message.content ?? '' + const res = response.choices[0]?.message.content ?? '' try { const parsed = JSON.parse(res) if (typeof parsed !== 'object' || parsed === null) { - console.log('Failed to parse translation response', { response: res }) + console.error('Failed to parse translation response', { response: res }) return undefined } return parsed } catch (e) { - console.log('Failed to parse translation response', { response: res, error: e }) + console.error('Failed to parse translation response', { response: res, error: e }) return undefined } } diff --git a/services/translate/src/index.ts b/services/translate/src/index.ts index 34397c2e4d..673e86a0a9 100644 --- a/services/translate/src/index.ts +++ b/services/translate/src/index.ts @@ -55,7 +55,7 @@ async function main (): Promise { }) const queue = getPlatformQueue(config.ServiceId, config.QueueRegion) - const controller = new Controller(ctx, openai, queue) + const controller = new Controller(ctx, openai) const consumer = queue.createConsumer(ctx, QueueTopic.Tx, queue.getClientId(), async (ctx, queueMessage) => { const ws = queueMessage.workspace