⁉️

Nani翻訳の技術的な話

に公開

先日 Nani というAI翻訳アプリをリリースしました。macOS / Windowsアプリを使うとショートカットで解説つきの翻訳を簡単に呼び出すことができます。

https://x.com/catnose99/status/1973218927158424056

この記事ではNaniの技術的な話をまとめておきます。「レスポンスが速い」という声も多くいただいているので、そのあたりの工夫についても触れたいと思います。

ざっくりとした構成

主な使用技術やサービスは以下のようになっています。

  • Turso: SQLiteでユーザーの設定などを保存
  • Upstash (Redis): キャッシュ、レートリミット
  • Drizzle: SQLiteのORMとして利用
  • Stripe: 決済管理
  • Next.js(App Router): Webアプリ。Vercelにデプロイ
  • Hono: APIを快適に書くため。Next.jsのRoute Handler上で使用
  • Electron: デスクトップアプリ
  • TypeScript / React / TailwindCSS: WebとデスクトップでUIを共通化

モノレポ構成

Web版とデスクトップ版でUIやロジックを共通化するためにpnpmとTurborepoを使ってモノレポとして管理しています。ディレクトリの構成は以下のようにappsと共有ライブラリを配置するpackagesに分けています。

  • apps/web: Next.js(Web版とAPI)
  • apps/desktop: Electron(デスクトップ版)
  • packages/lib: 共有ライブラリ(UIコンポーネント、スキーマ、ドメインロジックなど)
  • packages/svg: アイコンなどのSVGアセット

共通化したい部分はpackages/*に配置し、apps/webapps/desktopからimportしています。pnpmを使うとモノレポ管理が楽でいいですね。

共有ライブラリ(packages/*)のビルドには速度と使いやすさからtsdownを採用しました。

Web版ではNext.jsを使用

Web版は使い慣れているNext.jsを採用しました。工数削減のためデスクトップ版から呼び出すAPIもNext.js上で動かしています。快適にAPIを書くためにRoute Handler上でHonoを使う構成にしました。

Web版固有の更新処理ではさらっと書けるServer Actionsを使ったりもしていますが、Server Actionsはオーバーヘッドがやや大きいため、頻繁に呼び出す更新処理はfetchでHonoのAPIを叩く形にしています。

デスクトップ版では最終的にElectronを使用

開発初期はパフォーマンスを求めてTauriを使っていましたが、私の環境だとElectronの方が起動が速いケースもあり、痒いところに手が届くElectronへと途中で切り替えました。Electron ViteElectron Builderを使うとセットアップもスムーズで楽に開発を進めることができました。

アプリが非アクティブなときは余計なバックグラウンド処理は停止するなど、CPUやメモリの使用をできる限り抑えるよう注意して実装しています。

フロントエンド

どちらもTypeScript / React / TailwindCSSでUIを作っています。スキーマ管理やバリデーションには軽量なvalibotを使っています。このあたりは特に深い理由はなく、自分が慣れている技術を選定しました。

永続化

DBについては、無料ではじめられる / Cloudflareなどのエッジからでも接続しやすい / レイテンシが小さいなどの理由からSQLiteのTursoを採用しました。DBサービスごとのレイテンシの比較については以下の記事で実測・比較されており、とても参考になります。

https://pilcrow.vercel.app/blog/serverless-database-latency

ORMとしてはDrizzleを採用しました。Prismaより軽量なのが魅力です。Naniではそこまで複雑なクエリは使わないので、Drizzleで十分快適に使えています。

翻訳結果の履歴はWebではローカルストレージ、デスクトップではローカルのSQLite(こちらもORMはDrizzle)に保存しています。

ちなみにDrizzleを使うときにはeslint-plugin-drizzleというESLintのプラグインを入れるのがおすすめです。WHERE句のないUPDATEやDELETEに対して警告を出してくれたりします。

キャッシュとレートリミット

ユーザーごとのレートリミットやちょっとしたデータのキャッシュにはUpstash(Redis)を使っています。Upstash / Turso / VercelのリージョンはすべてUS-Eastに統一し、レイテンシが小さくなるようにしています。

パフォーマンス面での工夫

Naniではページの読み込み速度や翻訳結果のレスポンスをなるべく速くするために、細々とした工夫をしています。

バンドルサイズを小さくする

Naniに限った話ではなく、Webアプリを作るときにはいつも以下のような点に気をつけています。

  • 基本的に不必要なライブラリは入れない
  • Tailwindのクラス名をむやみに増やさない(text-[◯◯]みたいなArbitrary valuesはなるべく使わない)
  • 一部のユーザーに対して稀にしか表示されないコンポーネントなどはReactのlazy + <Suspense>などで遅延読み込み(例: 問い合わせ画面のモーダル)
  • SVGもサイズが大きいものは<img>として読み込む(複雑なsvgアイコンをそのままimportするとそれだけでJSが数KB増えることがある)

また、markdownからhtmlへの変換にはmicromarkを使っていますが、これも拡張を入れるとどんどんサイズが増えるので、サイズが小さい拡張が存在しないものについては自前で実装しています。

vercel/aiを自前実装に

一番効果があったのはおそらくオレオレAI SDKを実装したことです。最近のAIチャット系のWebアプリケーションではおそらくほとんどがAI SDK(vercel/ai)を使っています。多機能でとても良くできたライブラリなのですが、その分サイズが大きいのが難点です。

Naniでも開発初期はvercel/aiを使っていましたが、開発途中に発表されたv5への移行が大変すぎたこと、クライアントのJSのサイズがv4以上に大きくなってしまったことから、自前実装に切り替えることにしました。

ここはかなり迷いましたが、リリース後にv6が発表されてまた苦しむことを想像するとそれだけで心が折れそうだったので、重い腰を上げて、1から実装し直しました。

vercel/aiは様々なユースケースに対応できるように設計されており、今回のアプリではそのうちのごく一部の機能しか使いません。必要な機能だけに絞って実装したところ、クライアントで読み込まれるメインのJSのサイズ(parsed)が約100KBほど軽くなりました(389KB → 282KB)。

サーバーからのストリーミングをグローバルステートで管理する

オレオレAI SDKでは、テキストのストリーミングをグローバルステート(React.Context)で管理するようにしました。これにより、ストリーミング中に別ページに移動してもストリーミングが止まらず、元ページに戻ったときに続きを表示できるようになります。

これはvercel/aiで実現するのがなかなか難しく、移行してよかったポイントの一つです。

ページ遷移前にサーバーへのリクエストをはじめる

おそらくユーザー体験として最も効果が大きかったのは、ページ遷移前にサーバーへのリクエストを開始することが可能になった点です。

ほとんどのReact製のAIチャットアプリでは、以下のような流れでレスポンスを取得しています。

  1. 入力内容が送信される
  2. ページが切り替わる
  3. ページ遷移後、useEffectなどでサーバーへリクエストが送られる
  4. サーバーからのレスポンスが返ってくる

Webアプリケーションでは3のページ遷移に特に時間がかかるケースが多いのではないかと思います。

一方でNaniでは以下のように2と3の順番が逆になっています。

  1. 入力内容が送信される
  2. サーバーへのリクエストが送られる 👈️
  3. ページが切り替わる
  4. サーバーからのレスポンスが返ってくる

あらかじめAPIへリクエストを送ってからページ遷移をはじめることで、ページ遷移の時間とAPIのレスポンスの時間が重なり、体感的に速く感じられるようになっています。場合によっては(3)よりも(4)の方が先に来ることもあります。

これはグローバルステートでストリーミングを管理しているからこそ可能になったテクニックと言えます。

TTFT(Time To First Token)が小さいAIモデルを中心に組む

NaniではGoogle、OpenAI、Groqの3社が提供するAPIのうち、機能や設定、入力値に応じて使用するAIモデルを切り替えています。OpenAIのモデルはTTFT(最初のトークンが返ってくるまでの時間)が大きい傾向があるため、基本的な翻訳や言語判定にはGoogleかGroqのモデルを使うようにしています。

プリフライトリクエストのキャッシュを兼ねた事前リクエスト

デスクトップ版ではAPIリクエスト時にプリフライト(OPTIONS)リクエストが発生します。

翻訳APIのレスポンスが少しでも速くなるように、アプリ起動時と数時間おきに(OPTIONSレスポンスのキャッシュが切れる前を想定)事前にリクエストを送っておき、プリフライトリクエストをあらかじめ済ませるようにしています。

多言語対応

Naniではユーザーの設定言語として、日本語だけでなく英語や中国語などを選べるようにしています。

多言語対応にあたり、Webとデスクトップで共通化しつつ、なるべくクライアントで読み込まれるバンドルサイズは小さくしたいという要件をうまく満たせるライブラリが見当たらず、結局自前で実装することにしました。

辞書をモノレポの共通ライブラリに配置

翻訳辞書は/packages/lib内で以下のようなイメージで管理しています。

dictionaries/
  common.ts
  en/
    common.ts
  ja/
    common.ts
  zh/
    common.ts

common.tsには全言語で共通のキーと値を定義し、各言語のcommon.tsではその言語固有の翻訳を定義しています。JSONではなくtsファイルにすることで、型安全に管理できるようにしています。

サーバー側では設定言語に応じてgetDictionaryのような関数を使って必要な言語の辞書だけを読み込み、これをReactのContextに渡しています。各コンポーネントではuseDictionaryフックを使って辞書を参照できるようにしています。

多言語サイトのSEO対策

SEO対策としては、titleやmetaデスクリプションなどを言語によって切り替えるだけでなく、検索エンジンに対して、言語ごとに対応しているページがあることを伝えてあげる必要があります。

具体的には、<head>内に以下のような<link rel="alternate" hreflang="...">を追加します。

<link rel="alternate" href="https://nani.now/ja" hreflang="ja" />
<link rel="alternate" href="https://nani.now/en" hreflang="en" />
<link rel="alternate" href="https://nani.now/zh" hreflang="zh" />
<link rel="alternate" href="https://nani.now/en" hreflang="x-default" /><!-- デフォルト言語 -->

これによりhttps://nani.now/jaに対応する中国語のページはhttps://nani.now/zhであることを検索エンジンに伝えられるというイメージです。

Ctrl + Enterの送信

日本語入力ではIMEの確定にEnterキーを使うことが多く、Enterキーで送信される設定では誤って送信されてしまうことがあります。

そこでNaniでは送信キーを「Enter」か「Ctrl + Enter」のどちらかから選べるようにしています。

NaniではEnterキーで送信するかどうかを設定で切り替えられる

とはいえ、このような設定は大半のユーザーが多少不便であっても、わざわざ設定を切り替えてくれないものです。そこでブラウザの言語設定が日本語 / 中国語の場合は「Ctrl + Enter」、それ以外の言語の場合は「Enter」がデフォルトの送信キーになるようにしています。

また、意外とtextareaにおける

  • Enterで改行 / Ctrl + Enterで送信
  • Enterで送信 / Shift + Enterで改行

という2パターンの実装が面倒だったので、この部分を楽に実装するための小さなライブラリを作って公開しました。

https://github.com/catnose99/use-chat-submit

翻訳関連のプロンプトの工夫

開発を進める中で最も苦労したのは多言語に対応したプロンプトの調整です。入力値や設定値、ユーザーの言語などの条件に応じて、めちゃくちゃ細かくプロンプトを切り替えています。ここはまだまだ改善の余地があるので、知見が溜まったら記事やスクラップにまとめたいところです。

ビルドとリリース

Web版はVercelを使っており、特別な設定なしでスムーズにデプロイできます。

デスクトップ版のデプロイはGitHub Actionsにより行っています。apps/desktop/package.jsonversionを上げてからmainにマージするとGitHub Actionsでバージョンの変更が検知され、ビルドと新しいファイルの配置が行われるようになっています。

macOS用の.dmgと更新用の.ziplatest-mac.yml(アプリ内からの更新で参照されるファイル)といったファイルをActionsの中でそのままCloudflare R2にアップロードされます。

Windows用の.appxファイルは、Actionsの中でリポジトリのReleasesに紐づけられる仕組みになっています。

Windowsアプリの配布

リリースを気楽にできるように、最初はWindows版もmacOS版も野良アプリとして配布しようとしていました。しかしWindows版の証明書が高価なうえ、署名してもインストール時に警告が出ることがあると知り、Microsoft Storeで配布することにしました。

Microsoft Storeで組織アカウントを作成するときにはなかなか審査が下りず苦労しましたが、それ以外は比較的スムーズに設定が完了しました。アップデート時の審査は意外と早く、場合によっては半日くらいで完了することもあります。

毎回審査に出すのは面倒ですが、おかげで何故かウガンダのユーザーが地味に増えるなど、Store配布の恩恵も感じています。

おわりに

Naniは半分ベータ版のような位置づけてリリースしており、近いうちに大きな機能追加もする予定です。技術的な話はZennやXで共有していきたいと思います。

Discussion