iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
⁉️

Technical Details of Nani Translation

に公開4件

Recently, I released an AI translation app called Nani. With the macOS/Windows app, you can easily call up translations with explanations via shortcuts.

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

In this article, I'll summarize the technical aspects of Nani. Many users have mentioned that "the response is fast," so I'll also touch on some of the optimizations that make that possible.

Rough Architecture

The main technologies and services used are as follows:

  • Turso: SQLite for storing user settings, etc.
  • Upstash (Redis): Caching, rate limiting
  • Drizzle: Used as an ORM for SQLite
  • Stripe: Payment management
  • Next.js (App Router): Web application. Deployed on Vercel.
  • Hono: For writing APIs comfortably. Used on top of Next.js Route Handlers.
  • Electron: Desktop application
  • TypeScript / React / TailwindCSS: Shared UI between web and desktop

Monorepo Structure

To share UI and logic between the web and desktop versions, I manage the project as a monorepo using pnpm and Turborepo. The directory structure is divided into apps and packages (shared libraries).

  • apps/web: Next.js (web version and API)
  • apps/desktop: Electron (desktop version)
  • packages/lib: Shared library (UI components, schemas, domain logic, etc.)
  • packages/svg: SVG assets such as icons

Common parts are placed under packages/* and imported from apps/web and apps/desktop. pnpm makes monorepo management easy and convenient.

For building the shared libraries (packages/*), I adopted tsdown because of its speed and ease of use.

Using Next.js for the Web Version

For the web version, I adopted Next.js, which I'm comfortable with. To reduce development effort, I also run the APIs called from the desktop version on Next.js. To write APIs comfortably, I set up a configuration using Hono on top of Route Handlers.

For update operations specific to the web version, I sometimes use Server Actions for simplicity. However, Server Actions have a bit more overhead, so for frequently called update operations, I use fetch to call the Hono API.

Eventually Using Electron for the Desktop Version

At the beginning of development, I used Tauri for performance reasons. However, in my environment, Electron sometimes started faster, so I switched to Electron mid-project for its convenience. Using Electron Vite and Electron Builder made the setup smooth and development easy.

When the app is inactive, I stop any unnecessary background processes to minimize CPU and memory usage as much as possible.

Frontend

For both, I build the UI with TypeScript, React, and TailwindCSS. For schema management and validation, I use the lightweight valibot. There's no particularly deep reason for these choices; I simply selected technologies I'm familiar with.

Persistence

For the database, I chose SQLite with Turso because it's free to start, easy to connect from edge environments like Cloudflare, and has low latency. The following article provides actual measurements and comparisons of latency across different database services, which was very helpful:

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

As an ORM, I adopted Drizzle. Its lighter weight compared to Prisma is appealing. Since Nani doesn't require very complex queries, Drizzle works comfortably.

Translation history is stored in local storage on the web version, and in a local SQLite database (also using Drizzle) on the desktop version.

By the way, when using Drizzle, I recommend adding the ESLint plugin eslint-plugin-drizzle. It can warn you about UPDATE or DELETE queries without a WHERE clause.

Caching and Rate Limiting

For per-user rate limiting and small data caching, I use Upstash (Redis). I've unified the regions of Upstash, Turso, and Vercel all to US-East to minimize latency.

Performance Optimizations

To make page load times and translation response as fast as possible, Nani implements various small optimizations.

Reducing Bundle Size

This isn't unique to Nani, but when building web apps, I always pay attention to the following points:

  • Avoid unnecessary libraries whenever possible
  • Don't increase Tailwind class names unnecessarily (try to avoid arbitrary values like text-[○○])
  • Use React's lazy + <Suspense> for components that are rarely shown to some users (e.g., the contact form modal)
  • Load large SVGs via <img> tags (importing complex SVG icons directly can add several KB to the JS bundle)

Also, I use micromark for converting Markdown to HTML. Since adding extensions increases the bundle size, I've implemented custom solutions for features where a small extension doesn't exist.

Replacing vercel/ai with a Custom Implementation

Probably the most impactful change was implementing our own AI SDK. Most recent AI chat web applications use the AI SDK (vercel/ai). It's a well-made, feature-rich library, but its size is a drawback.

Nani initially used vercel/ai, but due to the difficulty of migrating to v5 mid-development and the significant increase in client-side JS size compared to v4, I decided to switch to a custom implementation.

I was quite hesitant, but imagining the pain of having to adapt to a future v6 after release was enough to motivate me to start from scratch.

vercel/ai is designed to support a wide range of use cases, but this app only uses a small fraction of its features. By implementing only the necessary functionality, I reduced the parsed size of the main client JS by about 100 KB (from 389 KB to 282 KB).

Managing Server Streaming with Global State

In the custom AI SDK, I manage text streaming with a global state (React.Context). This allows streaming to continue even if the user navigates to another page, and the content will resume when they return.

This was quite difficult to achieve with vercel/ai, and it's one of the benefits of the migration.

Starting Server Requests Before Page Navigation

Perhaps the most impactful optimization for user experience is the ability to start server requests before page navigation.

In most React-based AI chat apps, the response is fetched in the following flow:

  1. Input is submitted
  2. Page switches
  3. After navigation, a request is sent to the server via useEffect or similar
  4. Server response returns

In web applications, step 3 (page navigation) often takes significant time.

In Nani, the order of steps 2 and 3 is reversed:

  1. Input is submitted
  2. Request is sent to the server 👈️
  3. Page switches
  4. Server response returns

By sending the request to the API before starting page navigation, the page transition time and API response time overlap, making it feel faster to the user. In some cases, step 4 even completes before step 3.

This technique is only possible because streaming is managed with global state.

Prioritizing AI Models with Low TTFT (Time To First Token)

Nani switches between AI models from three providers (Google, OpenAI, Groq) based on functionality, settings, and input. Since OpenAI models tend to have a higher TTFT (time until the first token is returned), I use Google or Groq models for basic translations and language detection.

Pre-sending Requests to Cache Preflight Requests

On the desktop version, API requests trigger a preflight (OPTIONS) request.

To reduce latency for translation API responses, I send a preliminary request at app startup and every few hours (before the OPTIONS response cache expires) to pre-complete the preflight request.

Multilingual Support

Nani allows users to select not only Japanese but also English, Chinese, and other languages as their interface language.

For multi-language support, I couldn't find a library that satisfies the requirements of sharing UI across Web and desktop while keeping the client-side bundle size small, so I ended up implementing it myself.

Storing Dictionaries in the Monorepo’s Shared Library

Translation dictionaries are managed in /packages/lib with the following structure:

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

common.ts defines keys and values shared across all languages, while each language's common.ts defines its specific translations. By using TypeScript files instead of JSON, we can manage them in a type-safe manner.

On the server side, a function like getDictionary loads only the dictionary for the configured language, which is then passed into a React Context. Components can access the dictionary via a useDictionary hook.

SEO for Multilingual Sites

For SEO, besides switching title and meta descriptions per language, you need to inform search engines that pages exist in different languages.

Specifically, add <link rel="alternate" hreflang="..."> entries in the <head>:

<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" /><!-- Default language -->

This tells search engines that the Chinese page corresponding to https://nani.now/ja is https://nani.now/zh, and so on.

Ctrl + Enter Submission

In Japanese input, the Enter key is often used to confirm IME input, so a setting that sends the message on Enter can lead to accidental submissions.

Therefore, Nani lets users choose between "Enter" or "Ctrl + Enter" as the send key.

In Nani, you can toggle whether Enter sends the message in settings

However, most users won't bother changing such a setting even if it's slightly inconvenient. So, if the browser language is set to Japanese or Chinese, "Ctrl + Enter" is the default; otherwise, "Enter" is the default.

Also, implementing the two patterns in a textarea:

  • Enter for newline / Ctrl + Enter for send
  • Enter for send / Shift + Enter for newline

was surprisingly tricky, so I created and published a small library to handle this easily.

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

The most difficult part of development was adjusting prompts for multi-language support. I switch prompts very granularly based on input value, settings, user language, etc. There's still room for improvement, and once I gather more insights, I plan to write an article or scrap about it.

Build and Release

The web version uses Vercel, which deploys smoothly with no special configuration.

The desktop version is deployed via GitHub Actions. When the version in apps/desktop/package.json is bumped and merged into main, GitHub Actions detects the version change, builds, and deploys new files.

Files like macOS .dmg, update .zip, and latest-mac.yml (referenced by in-app updates) are uploaded directly to Cloudflare R2 from within the Actions.

For Windows, the .appx file is linked to the repository's Releases within the Actions.

Distributing the Windows App

To make releases easy, I initially planned to distribute both Windows and macOS versions as unsigned apps. However, Windows certificates are expensive, and even with signing, warnings can appear during installation. So I decided to distribute via the Microsoft Store.

Creating the organization account for the Microsoft Store required a long approval process, but everything else was relatively smooth. Update reviews are surprisingly fast, sometimes completing in half a day.

Submitting for review every time is a hassle, but it's been beneficial—I've noticed a small increase in users from Uganda, presumably due to Store distribution.

Conclusion

Nani is released as a semi-beta version, and I plan to add major features soon. I'll share technical insights on Zenn and X.

Discussion

Masaki KatoMasaki Kato

サービス選定について質問させてください。もしよかったら回答頂けますと幸いです。
‪Tursoについて:Cloudflareのエッジからもアクセスしやすいとのことでしたが同様の特徴のCloudflare D1も視野に入ってくると感じており、もしTursoとD1を比較されていましたら決め手について教えていただきたいです。

4
catnosecatnose

D1を選択しなかった理由はいくつかあります。

  1. サイズの問題: D1は有料プランでも現状最大サイズが10GBまでとなっています。超えた場合にはDBを移行する必要があり、そのあたりを考えると長期的にやっていきたいプロジェクトでは選択しづらいと感じています(翻訳データはDBに保存していないので10GBには達することはないと思いますが一応)
  2. Cloudflare Workersを使っていない: のでD1は候補にも上がりませんでした。ちなみにVercelのエッジランタイムはもともとCloudflare Workersでしたが、Naniを開発してる途中にVercel独自のランタイムに変更されたようです。とはいえ、Vercelから移行する可能性も考えてWorkersからでも使いやすいTursoを選びました。
16
飯田 亮吾 (Iida Ryogo)飯田 亮吾 (Iida Ryogo)

使用したLLMのAPIのサービスについてお伺いしたいです。もちろん答えられる範囲だけでも幸いです。
翻訳結果を出力するAPIにはどのモデルを使用しているのでしょうか?また、APIリクエスト前後の処理最適化を除いた、LLM自体の回答速度を速めるのにどのような工夫があるのでしょうか?
質問のきっかけ
Nani!? を使ってまず驚いたのは翻訳結果のレスポンスの速さと質でした。(画像のように曲のほんの一部の引用でも当てて訳を提案してくれて感動)何を使えばこんなに高速になるんだ、ローカルかよってくらい早くてビビりました。

加えて、僕は現在個人開発で音声入力アプリ(Aquavoiceっぽいやつ)を作っておりまして、ただ逐語的に文字起こしするだけでなく、音声内容が自分のSlackのメッセのクセに特化して入力できるようなアプリにしたいと考えています。その点、Nani!? はUI, UX共に僕が求めているものだと確信し、この速さと質を再現するにはどうすればいいのかぜひ知りたいです!ちなみに僕もElectronで作っていますっ!