0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

IBM Bobと一緒にOpen Notebookのモバイル版UIを開発してみた

0
Posted at

bob.png

Summary

Designer.png

  • Google Notebook LM のオープンソース代替「Open Notebook」はモバイル向け UI を持たないため、REST API を叩く静的 HTML 単ファイルの Wrapper を自作した
  • 今回の実装は AI エージェントの IBM Bob との対話で進めた。「Open Notebook が port 5055 で REST API を公開している」という事実も Bob 自身が GitHub のソースコードをフェッチして発見したものであり、コンテナ構成も Bob が一式を作成している
  • embed=false・空の transformations・空の context など、Swagger だけでは気づけない落とし穴が複数存在した。それらを Bob がソースコードを読んで特定・修正した過程を本記事に記録する

やらないこと

  • Open Notebook 本体のセットアップ手順
  • React / Next.js など重厚なフロントエンドフレームワークを用いた実装
  • AI モデルの選定・プロバイダー設定
  • セキュリティ強化(ユーザー認証など)

本記事における課題

Open Notebook とは

Open Notebook は Google の Notebook LM に触発されたオープンソースの研究支援ツールである。PDF・Web ページ・YouTube 動画などを「ソース」として登録し、LLM による要約(Insight)生成・チャット・Podcast 生成などが行える。完全ローカルで動作し、OpenAI / Anthropic / Ollama など 18 以上の AI プロバイダーに対応している。

モバイルで使えない問題

普段、私はおうちKubernetesにデプロイしたOpen Notebookで市中のニュースを情報収集し要約するようにしている。ところが、本家 UI は Next.js 製のデスクトップ向けレイアウトで、スマートフォンブラウザから操作するには辛い。

そこで Bob に「Open Notebook のモバイル向け UI を作ってほしい」とだけ伝えたところ、Bob はまず GitHub 上の Open Notebook リポジトリを自律的にフェッチし、バックエンドが port 5055 で FastAPI アプリとして動いており、Full REST API と Swagger UI(http://localhost:5055/docs)を持つという事実を自ら調べて報告してきた。

「API が生えているなら、モバイルに特化した薄い Wrapper を自前で作れる」——これが本プロジェクトの出発点であり、その判断も Bob が自律的に下したものである。

要件

  • 静的 Web ページであること(ビルド不要・ホスティングが簡単)
  • コンテナとしてデプロイできること
  • Open Notebook の REST API を使うこと
  • API キー・ベース URL はブラウザの localStorage にのみ保存し、サーバー側では感知しないこと
  • ソース・ノートブック・メモ・Podcast の一覧・参照・編集・新規登録ができること

やったこと

技術選定

フレームワークは使わなかった。バニラ HTML + CSS + JavaScript の単一ファイルで実装している。ビルドパイプラインを持ち込むと「静的ファイルをそのまま配信する」というシンプルさが失われるためである。

最終的な成果物は以下の 4 ファイルのみ。コンテナ構成も含め Bob が一式を作成した。

index.html        ← アプリ本体(SPA)
nginx.conf        ← SPA fallback + gzip
Dockerfile
docker-compose.yml

Bob が fetch MCP で OSS ソースコードを読んだ

Open Notebook の API ドキュメントは Swagger があるものの、リクエストボディの細かい制約やデフォルト挙動については記述が薄い。今回の実装は Bob との対話形式で進めたが、Bob が fetch MCP を使って GitHub 上の生ソースコードをリアルタイムに取得・解析するというアプローチが終始有効だった。

具体的には、実装に着手する前に Bob が以下のファイル群を GitHub の raw URL から順次フェッチして内容を確認している。

frontend/src/lib/types/api.ts        ← 型定義(最重要)
frontend/src/lib/api/sources.ts      ← ソース操作の実装
frontend/src/lib/api/chat.ts         ← チャット操作の実装
frontend/src/components/sources/AddSourceDialog.tsx  ← UI コンポーネント実装
frontend/src/app/(dashboard)/notebooks/[id]/page.tsx ← ノートブック画面実装
frontend/src/lib/stores/auth-store.ts ← 認証ストア実装

Swagger を手で読むより、Bob が実装ファイルを丸ごと取得してコードを解釈する方が正確で速い。例えば types/api.ts のフェッチ 1 回で、ソース作成リクエストの全容が判明した。

export interface CreateSourceRequest {
  notebook_id?: string
  notebooks?: string[]
  type: 'link' | 'upload' | 'text'   // ← 'url' や 'youtube' ではない!
  url?: string
  transformations?: string[]
  embed?: boolean
  async_processing?: boolean
  // ...
}

レスポンス型の発見も同様である。

export interface NotebookResponse {
  id: string
  name: string      // ← 'title' ではなく 'name'!
  description: string
  source_count: number
  note_count: number
  // ...
}

export interface SourceListResponse {
  asset: {
    file_path?: string
    url?: string    // ← s.url ではなく s.asset.url
  } | null
  // ...
}

こうして Bob がソースコードを読みながら仕様を確定させ、人間はそれをレビューして要件を伝えるという分業が自然に成立した。公式ドキュメントが薄い OSS でも、型定義ファイルを読めばどんどんとAIが理解して進めてくれるというのが実感である。

対話しながら進めたデバッグの記録

最初の実装でいくつかの動作がおかしい箇所に気づいた。そのつど Bob にチャットで状況を伝えると、Bob が該当ファイルを再フェッチして根本原因を特定し、修正計画を提示してから実装を進める、という流れで解決していった。

その1 「ソースの type 値が違う」

最初は type=urltype=youtube でソースを登録しようとして、API から 400 エラーが返り続けた。Bob が types/api.ts をフェッチして確認すると、'link' | 'upload' | 'text' の 3 択しか受け付けていなかった。YouTube の URL も type=link で渡すのが正解である。

その2 「ノートブック詳細にソース・メモが含まれない」

GET /notebooks/:id のレスポンスには sourcesnotes もフィールドが存在しない。最初は nb.sources || [] と書いていたが、Bob が types/api.tsNotebookResponse 型を確認したところ、当該フィールドが定義されていなかった。ノートブック詳細を表示する際はソースとメモを別途取得する必要がある。

const [nb, sources, notes] = await Promise.all([
  API.get(`/notebooks/${id}`),
  API.get(`/sources?notebook_id=${id}`),
  API.get(`/notes?notebook_id=${id}`),
]);

その3 「embed=false で Embedding が走らない」

ソース追加時に embed=false を渡していたため、Embedding が一切実行されなかった。true にして初めてベクトル化が走る。ドキュメントには特に記載がなく、本家 UI の AddSourceDialog.tsx の実装を読んで判明した事実である。

その4 「transformations=[] で Insight が生成されない」

Embedding と似た話で、transformations フィールドに何も渡さないと Insight(要約・抽出)も生成されない。本家 UI は GET /transformationsapply_default=true のものを取得して自動付与している。Wrapper でも同じ処理を実装することで解決した。

const transformations = await API.get('/transformations');
const defaultIds = transformations
  .filter(t => t.apply_default)
  .map(t => t.id);
fd.append('transformations', JSON.stringify(defaultIds));
fd.append('embed', 'true');

その5 「チャットの context を空で渡すと AI が何も知らない」

ノートブックチャット(POST /chat/execute)の context フィールドに { sources: [], notes: [] } を渡すと、AI はコンテキストを持たない状態でしか回答できない。「ソースが見つかりません」「コンテキストがありません」という回答が返り続けた原因がこれだった。

本来のフローは 2ステップになっている。

  1. POST /chat/contextcontext_config(ソースを "insights" モード、メモを "full" モードで指定)を送り、API 側でコンテキストをビルドさせる
  2. その戻り値を POST /chat/executecontext フィールドに渡す
// Step 1: コンテキストビルド
const sourcesConfig = {};
nbSources.forEach(s => { sourcesConfig[s.id] = 'insights'; });
const notesConfig = {};
nbNotes.forEach(n => { notesConfig[n.id] = 'full'; });

const contextResp = await API.post('/chat/context', {
  notebook_id: notebookId,
  context_config: { sources: sourcesConfig, notes: notesConfig },
});

// Step 2: メッセージ送信
const result = await API.post('/chat/execute', {
  session_id: session.id,
  message: userMessage,
  context: contextResp.context,   // ← Step 1 の結果を渡す
});

このフローは ChatColumn.tsxuseNotebookChat フックを読んで判明した。Swagger のスキーマだけでは到底気づけない部分である。

その6 「API キーは存在しない」

「Open Notebook の API キーをどこで発行するのか」という疑問に最初かなりの時間を使った。Settings > API Keys を探したが、そこにあるのは OpenAI などの外部 AI プロバイダーの認証情報管理画面だった。

正解は、Open Notebook 自身のログインパスワードがそのまま Bearer トークンになる設計だった。auth-store.ts を Bob が読んで初めて判明した事実である。

// login() の実装を見ると一目瞭然
const response = await fetch(`${apiUrl}/api/notebooks`, {
  headers: { 'Authorization': `Bearer ${password}` }
})
if (response.ok) {
  set({ isAuthenticated: true, token: password })  // パスワード = トークン
}

パスワード保護を無効にしている場合(ローカル運用)はキーが不要で、空欄のまま使える。

実装のポイント

localStorage を唯一の設定ストアにする

要件の「サーバーでキーを感知しない」を満たすため、設定は全て localStorage に保存している。

function getBaseUrl() {
  return (loadConfig().baseUrl || 'http://localhost:5055').replace(/\/$/, '');
}
function getApiKey() {
  return loadConfig().apiKey || '';
}

async function api(method, path, body) {
  const url = `${getBaseUrl()}/api${path}`;
  const headers = {};
  const key = getApiKey();
  if (key) headers['Authorization'] = `Bearer ${key}`;
  // ...
}

静的ファイル配信サーバーは API キーに一切触れない。

ライトモード・ダークモードの自動切替

OS の設定に追従させるには @media (prefers-color-scheme: dark) だけで十分であり、JavaScript は不要。

:root {
  --bg: #f4f4f8;
  --surface: #ffffff;
  --text: #1a1a2e;
  /* ... */
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0f0f1a;
    --surface: #1a1a2e;
    --text: #e8e8f0;
    /* ... */
  }
}

自前 Markdown パーサー

Insight・メモの内容は Markdown 形式で返ってくる。marked.js などを読み込む選択肢もあったが、単一 HTML ファイルの方針を崩したくなかったため、80 行程度のシンプルなパーサーを自前実装した。

重要なのは コードブロックとインラインコードを先に退避させてから残りをエスケープする順序である。逆にやると <> が HTML エンティティになった後にコードハイライトが壊れる。

function md(text) {
  const blocks = [];
  // 1. コードブロックを退避
  let s = text.replace(/```([\w]*)\n?([\s\S]*?)```/g, (_, lang, code) => {
    const i = blocks.length;
    blocks.push(`<pre><code>${esc(code)}</code></pre>`);
    return `\x00BLOCK${i}\x00`;
  });
  // 2. 残りをエスケープ
  s = s.replace(/&/g,'&amp;').replace(/</g,'&lt;') /* ... */;
  // 3. 見出し・リスト・強調などを変換
  s = s.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>');
  // ...
  // 4. 退避したコードブロックを戻す
  blocks.forEach((v, i) => { s = s.replace(`\x00BLOCK${i}\x00`, v); });
  return s;
}

コンテナ構成

コンテナ構成の詳細は割愛するが、Bob が Dockerfile・docker-compose.yml・配信サーバー設定を一式作成しており、イメージサイズは数十 MB・ビルド時間は数秒に収まっている。

実際のスクリーンショット

ちょっと宣伝

この記事自体もIBM Bobに下書きしてもらいました!

生成AIによる開発プロセスの効率化に関心がある場合は、IBMのAIエージェント製品コミュニティであるBobサロンのような場もおすすめです!実際にBobを活用されているユーザー様の声にも触れてみてください!

私がBobを試した記事もご参考になれば幸いです。

まとめ・所感

Bob が強みを発揮した部分

本プロジェクトは Bob との対話形式で実装を進めた。特に効果が大きかったのは以下の場面である。

  • API の存在自体を自律的に発見する場面。「UI を作って」とだけ伝えたのに、Bob がリポジトリを自らフェッチし、バックエンドに REST API が生えていることを確認してきた。これがプロジェクトの起点となった
  • GitHub のソースコードを直接フェッチして仕様を調査する作業。TypeScript の型定義ファイルを読み込み、フィールド名の誤りや必須パラメータの欠落を即座に指摘した
  • 複数バグの根本原因を並行調査する場面。embed=false・空 transformations・空 context という 3 つの独立したバグを同時に発見・修正した
  • 自前 Markdown パーサーやコンテナ構成のような「方針は明確だが書くのが面倒なコード」の生成
  • 作業計画の透明性が高い。Bob は実装に入る前に必ず作業計画をチャットに提示する。これから何をしようとしているのかが手に取るようにわかるため、誤った方向に進もうとした場合はチャットで指示を与えれば再計画してくれる。この「計画→レビュー→実行」のサイクルが終始安心感につながった

まだ実装しきれていない部分・今後の改善点

機能面

  • Podcast 生成:エピソード一覧・再生はできるが、新規生成(Episode Profile / Speaker Profile の選択など)は未実装。本家 UI のウィザード UI が複雑で今回は見送った
  • Insight の手動生成:ソース詳細画面から Transformation を選択して Insight を手動生成する機能が未実装
  • 処理ステータスのポーリング:ソース追加は async_processing=true で投げっぱなしにしており、処理完了を検知して UI を更新する仕組みがない。GET /sources/:id/status のポーリングを実装すべきである
  • ノートブックチャット画面:メモ編集の AI アシスト機能はあるが、ノートブック全体を対象としたチャット専用画面がない
  • ソース詳細編集:タイトル編集のみ対応。URL や content の更新は未実装

技術面

  • PWA 対応manifest.json と Service Worker を追加すれば、ホーム画面へのインストールとオフラインキャッシュが実現できる
  • メモ編集画面の content 取得:初期実装では DOM から textContent を引っ張るという力技だったが、openNoteModal()async 関数に変更し編集開始時に GET /notes/:id で最新の内容を取得するよう修正済み
  • エラーハンドリングの粒度:現状は全エラーをトーストで表示するだけで、再試行やフォールバックがない

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?