9. ブラウザ UI
プレーンな HTML、JS、CSS — ビルドステップもフレームワークもなし。
src/ui/ は 3 つのファイルです。
index.html— ページの骨格app.js— クライアントの状態とイベントハンドラstyles.css— 見た目のレイヤー
フレームワークなし、バンドラーなし、トランスパイルなし。ファイル
ピッカーがフォルダの中身を渡してくれて、fetch がサーバーと通信し、
DOM は手作業で更新します。
ページ
index.html には次が定義されています。
- brain 一覧を表示するサイドバー
- アクティブな brain を表示するトップバー
- メッセージスレッドと入力フォームを持つチャットパネル
- 名前入力、Choose folder ボタン、Create and index 送信ボタンを持つ brain 作成パネル
/assets/… から styles.css と app.js を読み込みます。
状態モデル
app.js は単一の状態オブジェクトを持ちます。
const state = {
brains: [],
activeBrainId: undefined,
messages: [],
selectedFolder: undefined,
};レンダ関数は state から読み取って DOM を更新します。イベント
ハンドラは state を変更し、該当するレンダ関数を呼びます。仮想 DOM は
ありません。この規模の UI では、得られる恩恵よりコストのほうが大きい
からです。
フォルダの読み込み
elements.browseFolder.addEventListener("click", () => {
elements.folderPicker.click();
});
elements.folderPicker.addEventListener("change", async () => {
const files = elements.folderPicker.files;
state.selectedFolder = await readSelectedFolder(files);
/* … render … */
});<input type="file" webkitdirectory> は webkitRelativePath が設定された
File オブジェクトのフラットなリストを返します。readSelectedFolder は
サーバーと同じ対応拡張子でフィルタし、大きいファイルを落とし、各 File を
BrowserDocumentInput に変換します。
function shouldReadBrowserFile(relativePath, size) {
if (size > MAX_BROWSER_FILE_BYTES) return false;
const segments = relativePath.split("/");
if (segments.some((s) => s.startsWith(".") || IGNORED.has(s))) return false;
return SUPPORTED.has(extname(relativePath).toLowerCase());
}これらの定数は src/files.ts と一致させる必要があります。一致しないと、
UI が受け入れたものをサーバーが弾きます。
brain の作成
elements.brainForm.addEventListener("submit", async (event) => {
event.preventDefault();
const body = {
name: elements.brainName.value,
folderName: state.selectedFolder.rootName,
documents: state.selectedFolder.documents,
};
const { brain } = await api("/api/brains/from-files", {
method: "POST",
body: JSON.stringify(body),
});
await refreshBrains();
state.activeBrainId = brain.id;
state.messages = [];
renderActiveBrain();
});api() は fetch の薄いラッパーで、2xx 以外で throw します。サーバーの
AppError メッセージをトーストとしてユーザーに表示します。
質問する
elements.chatForm.addEventListener("submit", async (event) => {
event.preventDefault();
const question = elements.chatInput.value.trim();
if (!question || !state.activeBrainId) return;
state.messages.push({ role: "user", content: question });
renderChat();
const { answer, citations } = await api(
`/api/brains/${state.activeBrainId}/chat`,
{ method: "POST", body: JSON.stringify({ question }) },
);
state.messages.push({ role: "assistant", content: answer, citations });
renderChat();
});チャットスレッドは、ファイル内に置かれた小さなレンダラ
(renderMarkdown、formatInlineMarkdown、escapeHtml)を経由して
Markdown として描画されます。対応するのは bold、italic、code、コード
ブロック、ブラケット引用だけ。モデルが生成するものを表現するのには
これで十分で、それ以上は持ちません。
フレームワークを使わない理由
この規模の UI ではトレードオフが明確です。
- ビルドステップなし。
app.jsを編集してブラウザをリロード。それが 開発ループです。 - 端から端まで読みやすい。 クライアント全体が 1 ファイルです。
- バージョン更新に振り回されない。 我々がいなくても動き続けます。
UI が複数画面に成長したら、フレームワークが採算に乗ってきます。今は そうなっていません。
ここに機能を足す
UI を拡張するには、小さなレンダ関数を追加し、state の一部を持たせ、
既存のイベントハンドラから呼んでください。ボタンを 1 つ追加するために
フレームワークを引き入れないでください。
これでウォークスルーは終わりです。ここからはコード構造の セクションで境界がなぜそこに置かれているかを説明し、拡張セクションで 新しい機能を組み込む場所を示します。