💬

翻訳逆翻訳チャットサービスの構成

2023/12/05に公開

プログラミングのリハビリを兼ねて2日ほどで翻訳付きチャットを作ったのでその裏側を紹介します。

サービス概要

逆翻訳付きのリアルタイムチャットサービスです。会員登録不要ですぐ使えます。
https://tratrachat.vercel.app
外国人とチャットをする時に、機械翻訳が正しいのかよくわからないので、翻訳した英語を日本語に逆翻訳して、意味が変わってしまっていないか確かめている、という話を聞き、その仕組みを自動的に組み込んだチャットサービスとしました。
外国人と同じチャットルームに入り、翻訳しながら会話する使い方を想定しています。

使い方

トップページ (https://tratrachat.vercel.app)

まずはあなたの名前を入力して「保存」を押してください。デフォルトではランダムの名前(言語名+動物名)が表示されています。
次に、「Test Room」または「Create New Room」を選びます。「Test Room」では、
「Create New Room」選ぶと、ユニークなIDが振られたチャット部屋が作られます。連番ではないので、基本他の参加者に知られることはありません。他の参加者は次の画面からあなたが招待してください。
テスト部屋は、常に同じID https://tratrachat.vercel.app/chat/test なので、同時にアクセスした人と話すことができます。また、過去ログが少し残っているかも知れません。

チャット部屋

チャット部屋では、日本語を入力しEnterキーを押すか「TraTra」ボタンを押します。
すると、下の欄に、英訳および、英語を日本語に逆翻訳した文章が表示されます。
確認して、Enterまたは「Send」ボタンを押すと、他の参加者にメッセージが送られます。

英語を入力して「TraTra」すると、和訳と英語への逆翻訳が行われます。「👍」または「👀」はスタンプ代わりで、押すと翻訳なくメッセージが送られます。
なお、入室時に、過去のやり取りが最大10件まで表示されます。
タイトルの横にあるチャットIDか、画面右下の「Share」ボタンを押すと、このチャット部屋のURLが表示されますので、このURLをメールなどで別の参加者に伝えることで、外国人など、他の参加者を招待しましょう。

その他

  • PC、スマホのブラウザー上で使えます
  • 入室時に、過去のメッセージを最大10件まで表示させています
  • 参加中のメンバー名が画面下部に出ています
  • 自分のメッセージは右側から、相手のメッセージは左側から表示されます。ただし、現状ブラウザー画面を開き直したら、自分ではなく別のユーザーとみなされるため、左側から表示されます
  • 吹き出しにマウスカーソルを合わせると、誰がいつ送ったメッセージか表示されます
  • 会員登録の仕組みはありません

裏側

構成

  • Vercel (ホスティング)
  • Next.js 14 (node.jsフレームワーク)
  • Ably (リアルタイムメッセージング)
  • DeepL API (翻訳)

このアプリは、そもそもAblyが紹介しているサンプルを元にしています。動くデモやソースコードもこちらの記事からリンクされており、ここからフォークして作りました。デザインもほぼそのまま。だから2日でできたという訳です。
とはいえ、翻訳の機能を付け加える以外にも、実用を考えると部屋は複数必要だったり、幾つか改修を加えたので、そこを紹介していきます。

ファイル構成
TypeScriptに変えました。DeepLのAPIが必要なのでAblyのAPIの場所を変えたのと、複数のチャット部屋が必要なので、page.tsxをchat/[threadId]/に移して、新たにトップページを作り、UserSettingsとThreadSelectionのコンポーネントを作りました。

Vercel

VercelはNext.jsの開発中心元でもあり、私が説明するまでもないかと思いますが、Herokuなき、というか進化がよくわからなくなった今、私のようなPaaS好きにはとても使い勝手の良さそうなサービスですね。GitHubやGitLab等にプッシュするだけでビルドしてくれと、コミットごとにテスト用の別ドメインまで振ってくれます。もちろんプライベートリポジトリーにも対応していて、無料プランでも開発者が一名であれば複数のサービスを運営できるので、今回のような個人のサービスにはうってつけですね。

また、Next.jsだけでなく、様々なフレームワークで作られたサンプルプロジェクトが提供されていて1クリックでビルドできるのも良いですね。
https://vercel.com/templates

余計なお世話ですが、VercelもHeroku同様ビジネスモデル的には大変のような気もしますので、いずれM&Aを狙う感じですかね。日本法人はないけど日本の会社とパートナー契約結んでいるんですね。

翻訳

DeepLにはnode.jsのオフィシャルライブラリーdeepl-nodeがあり、非常に簡単に利用できます。APIキーも申し込めばすぐ使えました。

AblyのAPIはapi/ably/route.tsxに移して、DeepL用のAPIをapi/deepl/route.tsxに作りました。エンドポイントの保護をした方が良いのではなどありますが、DeepL自体は誰でも無償で利用できますので、わざわざのこのエンドポイントを頑張って使う意味もないと思いますので、簡単に動作させることを優先させています。

api/deep/route.tsx
import * as deepl from 'deepl-node';
import { NextRequest } from 'next/server';

const authKey = process.env.DEEPL_AUTH_KEY;

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const textToTranslate = searchParams.get('text');
  if (textToTranslate.length === 0)
    return Response.json({});

  const japaneseRegex = /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}-]/u;
  const includesJapanese = japaneseRegex.test(textToTranslate);
  const targetLang:deepl.TargetLanguageCode = includesJapanese ? 'en-US' : 'ja';
  
  const translater = new deepl.Translator(authKey);
  const responseData = await translater.translateText(textToTranslate, null, targetLang);

  return Response.json(responseData);
}

入力制御

チャット処理を行うChatBox.tsx内に、翻訳を行うtranslateText、翻訳と逆翻訳を呼び出すpreviewMessage、送信を行うsendChatMessageを用意しています。

テキストエリア内で改行を押すと、翻訳済みかどうかによって翻訳または送信が行われます。また、Shiftキーを押しながらEnterキーを押すと単に改行、Ctrlキーを押しながらEnterを押すと、翻訳状況にかかわらずテキストを送信する仕組みとしています。

previewMessage内でsetLastPreviewedTextを呼び出していますが、そうするとcurrentMessageIsPreviewedの結果がダイナミックに変わって処理やUIが変わるのがNext.js/Reactの驚くべきところですね。

  const [lastPreviewedText, setLastPreviewedText] = useState("");
  const currentMessageIsPreviewed = !messageTextIsEmpty && messageText === lastPreviewedText
  ...
  /**
   * Textarea Key event
   */
  const handleKeyPress = (event:KeyboardEvent) => {
    // If not enter key or shift-enter, do anything
    if (event.charCode !== 13 || messageTextIsEmpty || event.shiftKey) {
      return;
    }

    if (currentMessageIsPreviewed || event.ctrlKey) {
      // hidden feature - send directly with ctrl + enter
      sendChatMessage(messageText); 
    }
    else {
      // translate first
      previewMessage(messageText);
    }

    event.preventDefault();
  }
  ...
  <button type="button" className={styles.button} disabled={currentMessageIsPreviewed} onClick={previewMessage} title="Preview translations before sending your message">TraTra</button>
  <button type="submit" className={styles.button + ' ' + styles.previewbutton} disabled={!currentMessageIsPreviewed}>Send</button> 

スタンプ

翻訳せず、単に絵文字を文字列として送信しているだけです。

  const sendEmoji = async (emojiText) => {
    channel.publish({ name: ablyEventName, data: {sourceText: emojiText} });
  }

  const handleLike = (event) => {
    sendEmoji('👍')
  }

  const handleWatch = (event) => {
    sendEmoji('👀')
  }
  ...
  <button type="button" className={styles.button} onClick={handleLike}>👍</button>
  <button type="button" className={styles.button} onClick={handleWatch}>👀</button>

ランダムな部屋ID

nanoidというライブラリーがうってつけでした。nanoid()と呼べばランダムな文字列が返ってくる、という簡単仕様。書き方正しいのかちゃんと理解していませんが、サーバーサイドコンポーネットっぽく書きました。
Test Roomは常に"test"という名称になっています。

import { nanoid } from 'nanoid'
...
export async function getServerSideProps() {
    return { uniqueId: nanoid() }
}
...
export default async function ThreadSelection() {
  const props = await getServerSideProps()
  const randomRoomLink = '/chat/' + props.uniqueId
  ...
  <li><Link href='/chat/test'>Test Room</Link></li>
  <li>
  {/* Disable prefetching for the random link to reduce the chat room creation */}
     <a href={randomRoomLink}>Create New Room</a>
  </li> 

これをapp/chat/[threadId]/page.tsxで、Chatコンポーネントに渡しています。next.js何もわからない頃、まさか括弧を含むフォルダー名とは思わなかったことを思い出します。

export default function ChatPage({ params }: { params: { threadId: string } }) {
  const threadId = params.threadId;
  ...
        <Chat threadId={threadId} /> 

最終的に、ChatBoxコンポーネントにて利用しています。

  const ablyChannelNamespace = process.env.ABLY_NAMESPACE || 'tratrachat';
  ...
  const threadId = params.threadId
  const ablyChannelName = ablyChannelNamespace + ':' + threadId
  ...
  const { presenceData } = usePresence(ablyChannelName)
  ...
  const { channel, ably } = useChannel(ablyChannelName)

チャット履歴の取得

Ablyのドキュメントによると、チャンネル設定でrewind可能な設定にして、[?rewind=10]${ablyChannelName}のようなチャンネル名でuseChannelすれば無料プランでも直近24時間のメッセージが取得できるとのことなのですが何も戻ってこず、channel.setOptions({params: {rewind: '10'}})も動作しなかったので、仕方なくhistoryを呼び出しています。

  const [isHistoryCalled, setIsHistoryCalled] = useState(false)
  const handleHistory = (paginatedResult) => {
    if (isHistoryCalled) {
      return;
    }
    setIsHistoryCalled(true);

    // direction:backwards gets the latest message as the first item
    // stable reverse
    setMessages(paginatedResult.items.slice().reverse());
  }
  channel.history({limit: 10}).then(handleHistory).catch((err) => {
    console.log('err to get channel history', err)
  })

直近のメッセージを取得するためには、デフォルトであるdirection:backwardsで取得する必要があるものの、そうすると、直近がitems[0]、一個前がitems[1]などとなってしまい、逆順に表示されてしまうため、配列を逆順にしています。

サービス特性上、あまり過去に遡れても使いにくいと思い、10件としています。

ユーザー名の生成と保存

unique-names-generatorというライブラリーで非常に簡単にできました。

const { uniqueNamesGenerator, languages, animals } = require('unique-names-generator');

function generateDefaultUserName() {
  return uniqueNamesGenerator({ dictionaries: [languages, animals], style: 'capital', separator: '' });
}

シンプルなライブラリーなのでドキュメントを見ていただければと思いますが、名前の生成は、色や形容詞、ハイフンつなぎなど色々なバリエーションが用意されています。

ユーザー名はtoken取得時に必要なので、パラメーターを増やしています。

Chat.jsx
  const client = Ably.Realtime.Promise({ authUrl: '/api/ably', authParams: {userName: userName} })
api/ably/route.tsx
export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const userName = searchParams.get('userName') || defaultClientId;
  //  console.log(`userName=${userName}`)
 
  // https://ably.com/docs/auth/identified-clients
  const tokenRequestData = await client.auth.createTokenRequest({ clientId: userName });

保存はlocalStorageに行っており、これも仕組みについて自信のないところではありますが、クライアントコンポーネントではあるものの、サーバーサイドで動かそうとして動作しないケースがあるようで、windowのチェックを入れています。

export function useUserName() {
    const key:string = userNameKey
    const defaultValue:string = generateDefaultUserName()

    const [value, setValue] = useState<string>(getUserName());

    const setValueAndStorage = (newValue: string) => {
        if (typeof window !== 'undefined')
            window.localStorage.setItem(key, newValue);
        setValue(newValue);
    };

    useEffect(() => {
        const res = window.localStorage.getItem(key);
        if (!res) {
            console.log("local storage is empty. setting defaultValue=" + defaultValue);
            setValueAndStorage(defaultValue)
        }
        else
        {
            console.log('got userName=' + res)
            setValue(res);   
        }
    }, []);

    return { userName:value, setUserName:setValueAndStorage };
}

参加者情報

チャットルームに参加中のメンバー一覧はusePresence(ablyChannelName)で簡単に取得できます。カンマ区切りの方法はもう少し良い方法があると思います。

ChatBox.tsx
  {presenceData.map((member, index: number) => {
    const prefix = index !== 0 ? ', ' : ''
    const user = member.clientId + (member.connectionId === ably.connection.id ? "(me)" : '');
    return (
      <span className="" key={member.id}>
	{prefix}
	<span title={'connectionId=' + member.connectionId}>{user}</span>
      </span>
    )
  })}

日時、送信者

日時はLINEのように吹き出しの横に出したいところですが、デザイン調整が面倒そうだったので、カーソルを合わせた時に表示させる形としています。日時の書式も手抜きです。

ChatBox.tsx
const messages = receivedMessages.map((message, index) => {
    const isMyMessage = message.connectionId === ably.connection.id
    const author = isMyMessage ? "me" : `${message.clientId}(${message.connectionId})`;
    const {sourceText, translatedText} = message.data;
   
    let sep = translatedText ? <br /> : ''
    const timeString = new Date(message.timestamp).toLocaleString()
    const messageClassName = isMyMessage ? styles.myMessage : styles.message

    return <span key={index} className={messageClassName} title={`${timeString} by ${author}`}>
        {sourceText}{sep}{translatedText}
     </span>
  });

スタイルは元のデモでは基本はChatBox.module.cssで設定しつつ、自分の投稿にのみdata-author属性を付けて、global.cssにて[data-author="me"]内の!importantなどで制御する形になっていましたが、あまり筋が良くないように思います。

Next.js 14への移行

元のデモはNext 13で作られており、App Routerも使っていますが、14に移行したところ、ProductionビルドのみAblyのライブラリーの中で妙なエラーが出てしまい、ここの調査対応に時間を取られてしまいました。
Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'sqrt')
Math.sqrtを呼びたいけどMathがundefinedだから呼べない、ということなのですが、結論的にはNextの最適化に不具合があるようで、next.config.jsを作って解消しました。アプリケーションを動かすと、 ⚠ Disabling SWC Minifer will not be an option in the next major version. Please report any issues you may be experiencing to https://github.com/vercel/next.js/issuesとか出ているので、Ablyチームの人が障害報告をあげていて直ることに期待したいです。

next.config.js
/**
 * @type { import("next").NextConfig}
 */
const config = {
    swcMinify: false,
}

module.exports = config

インポートの警告

もう一つ、これは元々のデモの時点から警告が出ています。

 ⚠ ./node_modules/keyv/src/index.js
Critical dependency: the request of a dependency is an expression

Import trace for requested module:
./node_modules/keyv/src/index.js
./node_modules/cacheable-request/src/index.js
./node_modules/got/dist/source/core/index.js
./node_modules/got/dist/source/create.js
./node_modules/got/dist/source/index.js
./node_modules/ably/build/ably-node.js
./node_modules/ably/promises.js
./app/api/ably/route.tsx

keyvというモジュールとNextのモジュールの間で起きているらしいです。
https://github.com/jaredwray/keyv/issues/45
警告は気持ちが悪いので直すか抑制するかしたかったのですが、node_modules/の中のファイルを更新するのも避けたいので諦めました。
サードパーティーモジュールに起因する警告を抑制する仕組みがないと不便ですね。

今後

アプリケーションを改修するならログの長期保持や認証などの仕組みを入れると良いのだと思いますが、そういうことをするのであれば、Slackのアプリケーションとして作った方が良さげですね。

もしこんな活用ができるのでは、という案がありましたら、ユースケースを教えていただけると嬉しいです。

無料枠について

Vercelについては、開発メンバーが増えたり、長期ログが必要になったりしない限り、無料枠で使えそうです。
Ablyは、月間600万メッセージ、200チャンネルということで、サービス利用が増えたら無料枠では足りなくなるかも知れません。

DeepLですが、月間50万文字まで無料ですが、それ以降は1文字あたり$0.0025ドル。1000文字で$2.5ドルということです。ChatGPTの価格によると、GPT4 Turboでも1000トークン$0.04、GPT 3.5なら1000トークン$0.003なので、処理のオーバーヘッドのことを考えても、翻訳量が多くなるとなかなか割高感出ますね。

余談

そもそものそもそもで言うと、ChatGPTのGPTsを使って逆翻訳をするアプリケーションを作りました。これはご存じのようにプロンプトの設定だけなので数時間でできるのですが、もうちょっとちゃんと作ってみようかなというところで作ったという経緯です。
https://chat.openai.com/g/g-idbL41F04-tratrachatpoc

Discussion