LocalLens
ウォークスルー

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.cssapp.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 は サーバーと同じ対応拡張子でフィルタし、大きいファイルを落とし、各 FileBrowserDocumentInput に変換します。

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();
});

チャットスレッドは、ファイル内に置かれた小さなレンダラ (renderMarkdownformatInlineMarkdownescapeHtml)を経由して Markdown として描画されます。対応するのは bold、italic、code、コード ブロック、ブラケット引用だけ。モデルが生成するものを表現するのには これで十分で、それ以上は持ちません。

フレームワークを使わない理由

この規模の UI ではトレードオフが明確です。

  • ビルドステップなし。 app.js を編集してブラウザをリロード。それが 開発ループです。
  • 端から端まで読みやすい。 クライアント全体が 1 ファイルです。
  • バージョン更新に振り回されない。 我々がいなくても動き続けます。

UI が複数画面に成長したら、フレームワークが採算に乗ってきます。今は そうなっていません。

ここに機能を足す

UI を拡張するには、小さなレンダ関数を追加し、state の一部を持たせ、 既存のイベントハンドラから呼んでください。ボタンを 1 つ追加するために フレームワークを引き入れないでください。

これでウォークスルーは終わりです。ここからはコード構造の セクションで境界がなぜそこに置かれているかを説明し、拡張セクションで 新しい機能を組み込む場所を示します。

On this page