title: "5. Web UIを追加する"
order: 5
4章までで、翻訳API・SSEストリーミング・モデル管理と、サーバーの機能は一通り揃いました。でも今のところ操作手段はcurlだけです。この章ではWeb UIを追加して、ブラウザから翻訳できるようにします。
完成するとこんな画面になります。

- テキストを入力すると、自動でトークンが逐次表示される(debounce付き)
- ヘッダーのドロップダウンでモデルと言語を切り替えられる
- 未ダウンロードのモデルを選ぶと、進捗バー付きでダウンロードが始まる(キャンセル可能)
HTML・CSS・JavaScriptのコードは最小限です。CSSフレームワークは使わず、素のCSS(約100行)だけでレイアウトします。C++の本なので、フロントエンドの詳しい解説はしません。「こう書くとこう動く」を見せていきます。
5.1 ファイル構成
この章で追加するファイルです。public/ディレクトリにHTML・CSS・JavaScriptを置き、サーバーから配信します。
translate-app/
├── public/
│ ├── index.html
│ ├── style.css
│ └── script.js
└── src/
└── main.cpp # set_mount_point を追加
5.2 静的ファイル配信を設定する
cpp-httplibのset_mount_pointを使うと、ディレクトリをそのままHTTPで配信できます。public/ディレクトリを作って、空のindex.htmlを置きましょう。
mkdir public
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Translate App</title>
</head>
<body>
<h1>Hello!</h1>
</body>
</html>
サーバーのコードにset_mount_pointを1行追加してビルドし直します。
// `main()`内、`svr.listen()`の前に追加
svr.set_mount_point("/", "./public");
サーバーを起動してブラウザでhttp://127.0.0.1:8080を開くと、「Hello!」が表示されるはずです。静的ファイルなので、index.htmlを編集したらブラウザをリロードするだけで反映されます。サーバーの再起動は不要です。
5.3 レイアウトを作る
index.htmlをレイアウトの完成形に書き換えます。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Translate App</title>
<!-- インラインSVG絵文字でfaviconを設定(画像ファイル不要) -->
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌐</text></svg>">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<!-- ヘッダー: タイトル + モデル選択 + 言語選択 -->
<header>
<strong>Translate App</strong>
<div>
<!-- 選択肢はscript.jsが`GET /models`で取得して動的に埋める -->
<select id="model-select" aria-label="Model"></select>
<select id="target-lang" aria-label="Target language">
<option value="ja">Japanese</option>
<option value="en">English</option>
<option value="zh">Chinese</option>
<option value="ko">Korean</option>
<option value="fr">French</option>
<option value="de">German</option>
<option value="es">Spanish</option>
</select>
</div>
</header>
<!-- 左右2カラム: 入力と翻訳結果 -->
<main>
<textarea id="input-text" placeholder="Enter text to translate..."></textarea>
<output id="output-text"></output>
</main>
<!-- モデルダウンロード中に表示するモーダル -->
<dialog id="download-dialog">
<h3>Downloading model...</h3>
<progress id="download-progress" max="100" value="0"></progress>
<p id="download-status"></p>
<button id="download-cancel">Cancel</button>
</dialog>
<script src="/script.js"></script>
</body>
</html>
HTMLのポイントです。
- FaviconはインラインSVG絵文字なので、画像ファイルは不要です
<dialog>はモデルダウンロード中の進捗表示に使います。HTML標準の要素で、showModal()でモーダルとして表示できます
<output>は翻訳結果の表示用です。意味的に「計算結果の出力」を表す要素です
- 翻訳ボタンはありません。テキストを入力すると自動で翻訳が始まります(5.4節で実装)
CSSをpublic/style.cssに書きます。CSSフレームワークは使わず、素のCSSだけでレイアウトします。
:root {
--gap: 0.5rem;
--color-border: #ccc;
--font: system-ui, sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: var(--font);
}
body {
display: flex;
flex-direction: column;
padding: var(--gap);
gap: var(--gap);
}
/* ヘッダー: タイトル + ドロップダウン */
header {
display: flex;
align-items: center;
justify-content: space-between;
}
header div {
display: flex;
gap: var(--gap);
}
/* メイン: 左右2カラム */
main {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--gap);
min-height: 0;
}
#input-text {
resize: none;
padding: 0.75rem;
font-family: var(--font);
font-size: 1rem;
border: 1px solid var(--color-border);
border-radius: 4px;
}
textarea:focus,
select:focus {
outline: 1px solid #4a9eff;
outline-offset: -1px;
}
#output-text {
display: block;
padding: 0.75rem;
font-size: 1rem;
border: 1px solid var(--color-border);
border-radius: 4px;
white-space: pre-wrap;
overflow-y: auto;
}
/* ダウンロードモーダル */
dialog {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1.5rem;
max-width: 400px;
width: 90%;
margin: auto;
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.4);
}
dialog h3 {
margin-bottom: 0.75rem;
}
dialog progress {
width: 100%;
height: 1.25rem;
}
dialog p {
margin-top: 0.5rem;
text-align: center;
color: #666;
}
dialog button {
display: block;
margin: 0.75rem auto 0;
padding: 0.4rem 1.5rem;
cursor: pointer;
}
/* 翻訳中・モデル切替中にUI全体をブロックする */
body.busy {
cursor: wait;
}
body.busy select,
body.busy textarea {
pointer-events: none;
opacity: 0.6;
}
レイアウトのポイントです。
bodyをFlexboxで縦並びにし、mainがflex: 1で残りの高さを占めます。入力欄と出力欄がウィンドウ下端まで伸びます
mainはCSS Gridの1fr 1frで左右2カラムに分割しています
--gap変数で全てのスペーシングを統一しています。ヘッダー上端、ヘッダーとBox間、Box下端が全て同じ幅です
body.busyクラスは、翻訳中やモデル切り替え中にUIをブロックするために使います。JavaScriptから付け外しします
ブラウザをリロードすると、入力欄と出力欄が横に並んだ画面が表示されるはずです。まだ何も入力しても何も起きませんが、レイアウトは完成です。
5.4 翻訳機能をつなぐ
いよいよJavaScriptでサーバーのAPIを呼び出します。public/script.jsを作ります。
SSEストリームの読み方
3章で作った/translate/streamはPOSTエンドポイントです。ブラウザのEventSourceはGETしか使えないので、fetch() + ReadableStreamでSSEを読みます。基本パターンはこうです。
fetch()でPOSTリクエストを送る
res.body.getReader()でストリームを取得
- チャンクを読みながら
data:で始まる行を処理する
チャンクはSSEの行の途中で切れることがあるので、バッファに溜めて行単位で処理する必要があります。
debounce付き自動翻訳
翻訳ボタンの代わりに、テキスト入力や言語変更をトリガーにして自動で翻訳を開始します。300msのdebounceを入れて、タイピング中に毎回リクエストが飛ばないようにします。
入力中に前の翻訳を中断するため、AbortControllerを使います。新しい入力があるとabort()で前のfetchをキャンセルし、新しい翻訳を開始します。fetchにキャンセル用のsignalを渡す必要があるので、SSEの読み取りはインラインで書いています。
const inputText = document.getElementById("input-text");
const outputText = document.getElementById("output-text");
const targetLang = document.getElementById("target-lang");
let debounceTimer = null;
let abortController = null;
async function translate() {
const text = inputText.value.trim();
if (!text) {
outputText.textContent = "";
return;
}
// 進行中の翻訳があればキャンセル
if (abortController) abortController.abort();
abortController = new AbortController();
const { signal } = abortController;
outputText.textContent = "";
document.body.classList.add("busy");
try {
const res = await fetch("/translate/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, target_lang: targetLang.value }),
signal,
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || `HTTP ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") return;
const parsed = JSON.parse(data);
if (parsed && parsed.error) {
outputText.textContent = "Error: " + parsed.error;
return;
}
outputText.textContent += parsed;
}
}
}
} catch (e) {
if (e.name === "AbortError") return; // 新しい入力でキャンセルされた
outputText.textContent = "Error: " + e.message;
} finally {
document.body.classList.remove("busy");
}
}
function scheduleTranslation() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(translate, 300);
}
inputText.addEventListener("input", scheduleTranslation);
targetLang.addEventListener("change", scheduleTranslation);
AbortControllerのsignalを渡す必要があるため、fetchを直接使っています。サーバーからエラーがJSONオブジェクトで返ってくることがあるので(3章で追加したtry/catch)、parsed.errorのチェックも入れています。
ブラウザをリロードして、テキストを入力してみましょう。300ms後にトークンが1つずつ表示されるはずです。入力を変えると前の翻訳が中断され、新しい翻訳が始まります。
5.5 モデル選択をつなぐ
モデル一覧の読み込み
ページを開いた時にGET /modelsを呼んで、ドロップダウンを初期化します。
const modelSelect = document.getElementById("model-select");
// `GET /models`からモデル一覧を取得し、ドロップダウンを構築する
async function loadModels() {
const res = await fetch("/models");
const { models } = await res.json();
modelSelect.innerHTML = ""; // 既存の選択肢をクリア
for (const m of models) {
const opt = document.createElement("option");
opt.value = m.name;
// 未ダウンロードのモデルには ⬇ マークを付けて区別する
opt.textContent = m.downloaded
? `${m.name} (${m.params})`
: `${m.name} (${m.params}) ⬇`;
opt.selected = m.selected; // サーバーが返す`selected`フラグで現在のモデルを選択状態に
modelSelect.appendChild(opt);
}
}
loadModels(); // ページ読み込み時に実行
未ダウンロードのモデルには⬇マークを付けて区別します。
モデルの切り替え
ドロップダウンを変更するとPOST /models/selectを呼びます。ダウンロードが必要な場合は<dialog>で進捗バーを表示します。キャンセルボタンで中断もできます。
翻訳と同様にAbortControllerを使います。キャンセルボタンが押されたらabort()で接続を切断します。サーバー側は切断を検知してダウンロードを中断します(4章のdownload_modelでsink.os.good()を返しているおかげです)。
const dialog = document.getElementById("download-dialog");
const progressBar = document.getElementById("download-progress");
const downloadStatus = document.getElementById("download-status");
const downloadCancel = document.getElementById("download-cancel");
let modelAbort = null;
downloadCancel.addEventListener("click", () => {
if (modelAbort) modelAbort.abort();
});
modelSelect.addEventListener("change", async () => {
const name = modelSelect.value;
document.body.classList.add("busy");
modelAbort = new AbortController();
const { signal } = modelAbort;
try {
const res = await fetch("/models/select", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: name }),
signal,
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || `HTTP ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") return;
const event = JSON.parse(data);
switch (event.status) {
case "downloading":
if (!dialog.open) dialog.showModal(); // モーダルを表示
progressBar.value = event.progress; // 進捗バーを更新
downloadStatus.textContent = `${event.progress}%`;
break;
case "loading":
// `value`属性を消すと`<progress>`がアニメーション(不確定)状態になる
progressBar.removeAttribute("value");
downloadStatus.textContent = "Loading model...";
break;
case "ready":
if (dialog.open) dialog.close();
break;
case "error":
if (dialog.open) dialog.close();
alert("Download failed: " + event.message);
break;
}
}
}
}
await loadModels(); // `selected`フラグが変わったので一覧を再取得
scheduleTranslation(); // 新しいモデルで再翻訳
} catch (e) {
if (e.name === "AbortError") {
// キャンセルされた — 元のモデルに戻す
await loadModels();
} else {
alert("Error: " + e.message);
}
} finally {
document.body.classList.remove("busy");
if (dialog.open) dialog.close();
modelAbort = null;
}
});
progressBar.removeAttribute("value")で<progress>をindeterminate(アニメーション)状態にしています。ダウンロード完了後のモデルロード中に使います。
5.6 全体のコード
全体のコード(index.html)
```html
Translate App
Japanese
English
Chinese
Korean
French
German
Spanish
Downloading model...
Cancel
```
全体のコード(style.css)
```css
:root {
--gap: 0.5rem;
--color-border: #ccc;
--font: system-ui, sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: var(--font);
}
body {
display: flex;
flex-direction: column;
padding: var(--gap);
gap: var(--gap);
}
/* ヘッダー: タイトル + ドロップダウン */
header {
display: flex;
align-items: center;
justify-content: space-between;
}
header div {
display: flex;
gap: var(--gap);
}
/* メイン: 左右2カラム */
main {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--gap);
min-height: 0;
}
#input-text {
resize: none;
padding: 0.75rem;
font-family: var(--font);
font-size: 1rem;
border: 1px solid var(--color-border);
border-radius: 4px;
}
textarea:focus,
select:focus {
outline: 1px solid #4a9eff;
outline-offset: -1px;
}
#output-text {
display: block;
padding: 0.75rem;
font-size: 1rem;
border: 1px solid var(--color-border);
border-radius: 4px;
white-space: pre-wrap;
overflow-y: auto;
}
/* ダウンロードモーダル */
dialog {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1.5rem;
max-width: 400px;
width: 90%;
margin: auto;
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.4);
}
dialog h3 {
margin-bottom: 0.75rem;
}
dialog progress {
width: 100%;
height: 1.25rem;
}
dialog p {
margin-top: 0.5rem;
text-align: center;
color: #666;
}
dialog button {
display: block;
margin: 0.75rem auto 0;
padding: 0.4rem 1.5rem;
cursor: pointer;
}
/* 翻訳中・モデル切替中にUI全体をブロックする */
body.busy {
cursor: wait;
}
body.busy select,
body.busy textarea {
pointer-events: none;
opacity: 0.6;
}
```
全体のコード(script.js)
```js
// --- DOM要素 ---
const inputText = document.getElementById("input-text");
const outputText = document.getElementById("output-text");
const targetLang = document.getElementById("target-lang");
const modelSelect = document.getElementById("model-select");
const dialog = document.getElementById("download-dialog");
const progressBar = document.getElementById("download-progress");
const downloadStatus = document.getElementById("download-status");
const downloadCancel = document.getElementById("download-cancel");
// --- モデル一覧 ---
// `GET /models`からモデル一覧を取得し、ドロップダウンを構築する
async function loadModels() {
const res = await fetch("/models");
const { models } = await res.json();
modelSelect.innerHTML = ""; // 既存の選択肢をクリア
for (const m of models) {
const opt = document.createElement("option");
opt.value = m.name;
// 未ダウンロードのモデルには ⬇ マークを付けて区別する
opt.textContent = m.downloaded
? `${m.name} (${m.params})`
: `${m.name} (${m.params}) ⬇`;
opt.selected = m.selected; // サーバーが返す`selected`フラグで現在のモデルを選択状態に
modelSelect.appendChild(opt);
}
}
loadModels(); // ページ読み込み時に実行
// --- 翻訳(debounce付き自動翻訳) ---
let debounceTimer = null;
let abortController = null;
async function translate() {
const text = inputText.value.trim();
if (!text) {
outputText.textContent = "";
return;
}
// 進行中の翻訳があればキャンセル
if (abortController) abortController.abort();
abortController = new AbortController();
const { signal } = abortController;
outputText.textContent = "";
document.body.classList.add("busy");
try {
const res = await fetch("/translate/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, target_lang: targetLang.value }),
signal,
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || `HTTP ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") return;
const parsed = JSON.parse(data);
if (parsed && parsed.error) {
outputText.textContent = "Error: " + parsed.error;
return;
}
outputText.textContent += parsed;
}
}
}
} catch (e) {
if (e.name === "AbortError") return; // 新しい入力でキャンセルされた
outputText.textContent = "Error: " + e.message;
} finally {
document.body.classList.remove("busy");
}
}
function scheduleTranslation() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(translate, 300);
}
inputText.addEventListener("input", scheduleTranslation);
targetLang.addEventListener("change", scheduleTranslation);
// --- モデル選択 ---
let modelAbort = null;
downloadCancel.addEventListener("click", () => {
if (modelAbort) modelAbort.abort();
});
modelSelect.addEventListener("change", async () => {
const name = modelSelect.value;
document.body.classList.add("busy");
modelAbort = new AbortController();
const { signal } = modelAbort;
try {
const res = await fetch("/models/select", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: name }),
signal,
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || `HTTP ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") return;
const event = JSON.parse(data);
switch (event.status) {
case "downloading":
if (!dialog.open) dialog.showModal();
progressBar.value = event.progress;
downloadStatus.textContent = `${event.progress}%`;
break;
case "loading":
progressBar.removeAttribute("value");
downloadStatus.textContent = "Loading model...";
break;
case "ready":
if (dialog.open) dialog.close();
break;
case "error":
if (dialog.open) dialog.close();
alert("Download failed: " + event.message);
break;
}
}
}
}
await loadModels();
scheduleTranslation(); // 新しいモデルで再翻訳
} catch (e) {
if (e.name === "AbortError") {
// キャンセルされた — 元のモデルに戻す
await loadModels();
} else {
alert("Error: " + e.message);
}
} finally {
document.body.classList.remove("busy");
if (dialog.open) dialog.close();
modelAbort = null;
}
});
```
全体のコード(main.cpp)
サーバー側の変更は`set_mount_point`の1行だけです。4章の全体コードの`svr.listen()`の前に追加してください。
```cpp
#include
#include
#include
#include
#include
#include
#include
#include
using json = nlohmann::json;
// -------------------------------------------------------------------------
// モデル定義
// -------------------------------------------------------------------------
struct ModelInfo {
std::string name;
std::string params;
std::string size;
std::string repo;
std::string filename;
};
const std::vector MODELS = {
{
.name = "gemma-2-2b-it",
.params = "2B",
.size = "1.6 GB",
.repo = "bartowski/gemma-2-2b-it-GGUF",
.filename = "gemma-2-2b-it-Q4_K_M.gguf",
},
{
.name = "gemma-2-9b-it",
.params = "9B",
.size = "5.8 GB",
.repo = "bartowski/gemma-2-9b-it-GGUF",
.filename = "gemma-2-9b-it-Q4_K_M.gguf",
},
{
.name = "Llama-3.1-8B-Instruct",
.params = "8B",
.size = "4.9 GB",
.repo = "bartowski/Meta-Llama-3.1-8B-Instruct-GGUF",
.filename = "Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf",
},
};
// -------------------------------------------------------------------------
// モデル保存ディレクトリ
// -------------------------------------------------------------------------
std::filesystem::path get_models_dir() {
#ifdef _WIN32
auto env = std::getenv("APPDATA");
auto base = env ? std::filesystem::path(env) : std::filesystem::path(".");
return base / "translate-app" / "models";
#else
auto env = std::getenv("HOME");
auto base = env ? std::filesystem::path(env) : std::filesystem::path(".");
return base / ".translate-app" / "models";
#endif
}
// -------------------------------------------------------------------------
// モデルダウンロード
// -------------------------------------------------------------------------
// progress_cbがfalseを返したらダウンロードを中断する
bool download_model(const ModelInfo &model,
std::function progress_cb) {
httplib::Client cli("https://huggingface.co");
cli.set_follow_location(true); // Hugging FaceはCDNにリダイレクトする
cli.set_read_timeout(std::chrono::hours(1)); // 大きなモデルに備えて長めに
auto url = "/" + model.repo + "/resolve/main/" + model.filename;
auto path = get_models_dir() / model.filename;
auto tmp_path = std::filesystem::path(path).concat(".tmp");
std::ofstream ofs(tmp_path, std::ios::binary);
if (!ofs) { return false; }
auto res = cli.Get(url,
// content_receiver: チャンクごとにデータを受け取ってファイルに書き込む
[&](const char *data, size_t len) {
ofs.write(data, len);
return ofs.good();
},
// progress: ダウンロード進捗を通知(falseを返すと中断)
[&, last_pct = -1](size_t current, size_t total) mutable {
int pct = total ? (int)(current * 100 / total) : 0;
if (pct == last_pct) return true; // 同じ値なら通知をスキップ
last_pct = pct;
return progress_cb(pct);
});
ofs.close();
if (!res || res->status != 200) {
std::filesystem::remove(tmp_path);
return false;
}
// ダウンロード完了後にリネーム
std::filesystem::rename(tmp_path, path);
return true;
}
// -------------------------------------------------------------------------
// サーバー
// -------------------------------------------------------------------------
httplib::Server svr;
void signal_handler(int sig) {
if (sig == SIGINT || sig == SIGTERM) {
std::cout << "\nReceived signal, shutting down gracefully...\n";
svr.stop();
}
}
int main() {
// モデル保存ディレクトリを作成
auto models_dir = get_models_dir();
std::filesystem::create_directories(models_dir);
// デフォルトモデルが未ダウンロードなら自動取得
std::string selected_model = MODELS[0].filename;
auto path = models_dir / selected_model;
if (!std::filesystem::exists(path)) {
std::cout << "Downloading " << selected_model << "..." << std::endl;
if (!download_model(MODELS[0], [](int pct) {
std::cout << "\r" << pct << "%" << std::flush;
return true;
})) {
std::cerr << "\nFailed to download model." << std::endl;
return 1;
}
std::cout << std::endl;
}
auto llm = llamalib::Llama{path};
// LLM推論は時間がかかるのでタイムアウトを長めに設定(デフォルトは5秒)
svr.set_read_timeout(300);
svr.set_write_timeout(300);
svr.set_logger([](const auto &req, const auto &res) {
std::cout << req.method << " " << req.path << " -> " << res.status
<< std::endl;
});
svr.Get("/health", [](const httplib::Request &, httplib::Response &res) {
res.set_content(json{{"status", "ok"}}.dump(), "application/json");
});
// --- 翻訳エンドポイント(2章) -----------------------------------------
svr.Post("/translate",
[&](const httplib::Request &req, httplib::Response &res) {
auto input = json::parse(req.body, nullptr, false);
if (input.is_discarded()) {
res.status = 400;
res.set_content(json{{"error", "Invalid JSON"}}.dump(),
"application/json");
return;
}
if (!input.contains("text") || !input["text"].is_string() ||
input["text"].get().empty()) {
res.status = 400;
res.set_content(json{{"error", "'text' is required"}}.dump(),
"application/json");
return;
}
auto text = input["text"].get();
auto target_lang = input.value("target_lang", "ja");
auto prompt = "Translate the following text to " + target_lang +
". Output only the translation, nothing else.\n\n" + text;
try {
auto translation = llm.chat(prompt);
res.set_content(json{{"translation", translation}}.dump(),
"application/json");
} catch (const std::exception &e) {
res.status = 500;
res.set_content(json{{"error", e.what()}}.dump(), "application/json");
}
});
// --- SSEストリーミング翻訳(3章)--------------------------------------
svr.Post("/translate/stream",
[&](const httplib::Request &req, httplib::Response &res) {
auto input = json::parse(req.body, nullptr, false);
if (input.is_discarded()) {
res.status = 400;
res.set_content(json{{"error", "Invalid JSON"}}.dump(),
"application/json");
return;
}
if (!input.contains("text") || !input["text"].is_string() ||
input["text"].get().empty()) {
res.status = 400;
res.set_content(json{{"error", "'text' is required"}}.dump(),
"application/json");
return;
}
auto text = input["text"].get();
auto target_lang = input.value("target_lang", "ja");
auto prompt = "Translate the following text to " + target_lang +
". Output only the translation, nothing else.\n\n" + text;
res.set_chunked_content_provider(
"text/event-stream",
[&, prompt](size_t, httplib::DataSink &sink) {
try {
llm.chat(prompt, [&](std::string_view token) {
sink.os << "data: "
<< json(std::string(token)).dump(
-1, ' ', false, json::error_handler_t::replace)
<< "\n\n";
return sink.os.good(); // 切断されたら推論を中断
});
sink.os << "data: [DONE]\n\n";
} catch (const std::exception &e) {
sink.os << "data: " << json({{"error", e.what()}}).dump() << "\n\n";
}
sink.done();
return true;
});
});
// --- モデル一覧(4章) -------------------------------------------------
svr.Get("/models",
[&](const httplib::Request &, httplib::Response &res) {
auto models_dir = get_models_dir();
auto arr = json::array();
for (const auto &m : MODELS) {
auto path = models_dir / m.filename;
arr.push_back({
{"name", m.name},
{"params", m.params},
{"size", m.size},
{"downloaded", std::filesystem::exists(path)},
{"selected", m.filename == selected_model},
});
}
res.set_content(json{{"models", arr}}.dump(), "application/json");
});
// --- モデル選択(4章) -------------------------------------------------
svr.Post("/models/select",
[&](const httplib::Request &req, httplib::Response &res) {
auto input = json::parse(req.body, nullptr, false);
if (input.is_discarded() || !input.contains("model")) {
res.status = 400;
res.set_content(json{{"error", "'model' is required"}}.dump(),
"application/json");
return;
}
auto name = input["model"].get();
auto it = std::find_if(MODELS.begin(), MODELS.end(),
[&](const ModelInfo &m) { return m.name == name; });
if (it == MODELS.end()) {
res.status = 404;
res.set_content(json{{"error", "Unknown model"}}.dump(),
"application/json");
return;
}
const auto &model = *it;
// 常にSSEで応答する(DL済みでも未DLでも同じ形式)
res.set_chunked_content_provider(
"text/event-stream",
[&, model](size_t, httplib::DataSink &sink) {
// SSEイベント送信ヘルパー
auto send = [&](const json &event) {
sink.os << "data: " << event.dump() << "\n\n";
};
// 未ダウンロードならダウンロード(進捗をSSEで通知)
auto path = get_models_dir() / model.filename;
if (!std::filesystem::exists(path)) {
bool ok = download_model(model, [&](int pct) {
send({{"status", "downloading"}, {"progress", pct}});
return sink.os.good(); // クライアント切断時にダウンロードを中断
});
if (!ok) {
send({{"status", "error"}, {"message", "Download failed"}});
sink.done();
return true;
}
}
// モデルをロードして切り替え
send({{"status", "loading"}});
llm = llamalib::Llama{path};
selected_model = model.filename;
send({{"status", "ready"}});
sink.done();
return true;
});
});
// --- 静的ファイル配信(5章) -------------------------------------------
svr.set_mount_point("/", "./public");
// `Ctrl+C` (`SIGINT`)や`kill` (`SIGTERM`)でサーバーを停止できるようにする
signal(SIGINT, signal_handler);
signal(SIGTERM, signal_handler);
std::cout << "Listening on http://127.0.0.1:8080" << std::endl;
svr.listen("127.0.0.1", 8080);
}
```
5.7 動作確認
ビルドし直してサーバーを起動します。
cmake --build build -j
./build/translate-server
ブラウザでhttp://127.0.0.1:8080を開きます。
- テキストを入力する → 300ms後にトークンが逐次表示される
- 入力を変更する → 前の翻訳が中断され、新しい翻訳が始まる
- 言語のドロップダウンを変更する → 自動で再翻訳される
- モデルのドロップダウンを変更する → ダウンロード済みならすぐ切り替わる
- 未ダウンロードのモデルを選ぶ → 進捗バーが表示され、Cancelで中断できる
curlで操作していた4章と同じことが、ブラウザからできるようになりました。
次の章へ
サーバーとWeb UIが揃いました。次の章ではこのアプリをwebview/webviewで包んで、ブラウザなしで動くデスクトップアプリにします。静的ファイルをバイナリに埋め込んで、配布物をバイナリ1つにまとめます。
Next: WebViewでデスクトップアプリ化する