技術 約11分で読めます

かなチャット v2のアーキテクチャ変更点

前回の記事で、正規CLIをラップしてAIエージェントを動かす「かなチャット」の基本設計を書いた。あれから約1ヶ月、実際に毎日使いながら改修を重ねた結果、構成がだいぶ変わった。変更点と、なぜそうしたのかを記録しておく。

全体像の変化

v1時点のシステム構成はこうだった。

flowchart TB
    A["iPhone ブラウザ"]
    B["FastAPI"]
    C1["Claude Code"]
    C2["Codex"]
    C3["Gemini CLI"]

    A --> B
    B --> C1
    B --> C2
    B --> C3

v2ではレイヤーが増えている。

v2のチャット画面。ヘッダーにかなちゃんアイコン、Chat/Plan/Jobs/Blogの4タブ

アイコンも変えた。v1ではIllustrious系LoRAで生成したイラストを使っていたが、v2ではドット絵に差し替えた。ヘッダーのアイコンだけでなく、ジョブのステータスごとに表情が変わるようにしている。

左からヘッダー、思考中(?)、実行中(⚙)、完了(✓)、失敗(✗)、待機中(Zzz)、一時停止(⏸)。ジョブカードやプッシュ通知のアイコンにも使っている。

flowchart TB
    A["iPhone ブラウザ<br/>PWA + Push通知<br/>画像・音声入力"]
    TS["Tailscale Serve<br/>HTTPS"]
    B["FastAPI<br/>WebSocket ストリーミング"]
    R["デュアルモデルルーター<br/>gpt-5.3-codex-spark + gpt-5.4-mini"]
    P["プランナーモード<br/>設計 → 実行の分離"]
    HB["Heartbeat<br/>メモリ抽出 + タスク提案"]
    W1["Claude Code ワーカー"]
    W2["Codex レビュアー"]
    WD["Job Watchdog<br/>ストール検知"]

    A --> TS
    TS --> B
    B --> R
    R --> |"small_chat"| B
    R --> |"plan"| P
    R --> |"job"| W1
    W1 --> W2
    WD --> W1
    B --> HB

主な変更は9つ。順に書く。

1. デュアルモデルルーター

v1では「Haikuでインテント分類」の一言で片付けていた部分。実際に運用すると、Haiku単体だとルーティングの精度が足りなかった。雑談をジョブに回したり、明らかにファイル操作が必要な依頼をチャットで処理しようとしたりする。

v2ではプライマリモデル(gpt-5.3-codex-spark)で分類し、confidence が 0.84 未満ならフォールバックモデル(gpt-5.4-mini)で再分類する2段構えにした。

flowchart LR
    MSG["ユーザーメッセージ"]
    P["プライマリ<br/>gpt-5.3-codex-spark"]
    F["フォールバック<br/>gpt-5.4-mini"]
    OUT["ルート決定"]

    MSG --> P
    P --> |"confidence ≥ 0.84"| OUT
    P --> |"confidence < 0.84"| F
    F --> OUT

ルートは4種類に整理した。

ルート用途
small_chat雑談、短文生成、軽い説明
background_searchWeb調査が必要な質問
plan要件整理、設計相談
jobファイル修正、コード変更、リポジトリ作業

v1にあった「チャットかステータス問い合わせか」の2分岐から、4分岐に増えたことで、「調べ物をジョブに回してClaude Codeのコンテキストを汚す」問題が消えた。background_searchは非同期でWeb検索を走らせ、結果を次のターンにランタイムノートとして差し込む。

ルーターの出力はJSON スキーマで固定している。

{
  "route": "small_chat",
  "confidence": 0.92,
  "ask_user": false,
  "short_reason": "日常会話",
  "search_query": ""
}

reasoning effortはlowに設定。ルーティングに深い推論は不要で、レイテンシのほうが重要。25秒でタイムアウトさせ、失敗時はsmall_chatにフォールバックする。

2. プランナーモード

v1にはなかった機能。「これ作りたいんだけど」という相談をそのままジョブに投げると、要件が曖昧なまま実装が始まって手戻りが発生する。plan ルートに分類されたメッセージは、専用のtmuxウィンドウでCLIを起動し、設計と要件整理だけを行う。

プランナーのCLIはread-onlyサンドボックスで起動する。コードを読んで設計案を出すことはできるが、ファイルの変更はできない。設計がまとまったら /planner/job エンドポイントでジョブに変換し、そこで初めて実装が始まる。

flowchart LR
    U["ユーザー: これ作りたい"]
    PL["プランナー<br/>read-only sandbox"]
    PLAN["plan.md<br/>設計案"]
    JOB["ジョブ実行<br/>実装開始"]

    U --> PL
    PL --> PLAN
    PLAN --> |"ユーザー承認"| JOB

設計と実装を分離したことで、「やっぱり違う」のときにプランナーの段階で止められる。ジョブとして走り始めてからの中断より圧倒的に安い。

Planタブ。トピックを入力して設計セッションを開始する

3. Heartbeatメモリシステム

v1のHeartbeatは「会話から”やりたそうなこと”を抽出してタスク候補として提案する」だけだった。v2では提案機能に加えて、ユーザーのプロファイル・日々の活動・タスクシグナルを構造化して蓄積するメモリ機能が追加された。

heartbeat-memory.json の構造はこうなっている。

{
  "updated_at": "2026-03-23T10:00:00Z",
  "profile": [
    "ブログを毎日書いている",
    "セキュリティ記事に詳しい"
  ],
  "today": {
    "2026-03-23": [
      "カレーを食べた",
      "散歩した"
    ]
  },
  "task_signals": [
    {
      "text": "OGP画像の自動生成を調べたい",
      "created_at": "2026-03-23T10:00:00Z",
      "date": "2026-03-23",
      "source": "heartbeat"
    }
  ]
}

Heartbeatループ(デフォルト1時間ごと)の中で、Haikuが直近60件のユーザー発言から3カテゴリの情報を抽出する。

カテゴリ内容上限
profile趣味嗜好・固定的な好み40件
today今日やったこと・行った場所・食べたもの日付別で14日分
task_signals作業化できそうな意図80件

これが効くのはジョブのコンテキスト構築時。v1では会話履歴をそのまま渡していたが、v2ではHeartbeatメモリから「この人はこういう好みがあって、今日はこういう活動をしていて、こういうことをやりたがっている」という構造化された情報をジョブのプロンプトに含める。

今日の活動が2件以上たまると、自動で日記ドラフトの作成を提案してくる。日記は基本的に自分でスマホからぶん投げてるんだけど、忘れた日に勝手に書いておいてくれるのが地味に助かる。ガチめに忘れてるとき「そういやそんなことあったな」と振り返れるので、雑にメモだけ残しておけば日記として成立する運用になった。iPhoneからカジュアルに書けるのが前提なので、入力のハードルは低いほうがいい。

4. Geminiバックエンドとホットスイッチ

v1ではGemini CLIはワーカーの一つとして触れただけだった。v2ではチャットのメインバックエンドとしてGeminiを使えるようにし、Codexとの間で /switch gemini /switch codex で即座に切り替えられる。

ここで問題になったのがサンドボックスの制約。Geminiサンドボックスはプロジェクトディレクトリ内のファイルしか読めない。会話のstate(recent.md、summary.md、memory.md)はプロジェクト直下の state/ にあるが、Gemini CLIからは見えない。

解決策として chat/state/ ディレクトリにstateファイルをミラーリングする仕組みを入れた。stateが更新されるたびに chat/state/ にもコピーする。Gemini CLIにはセッション復帰時に「state/recent.md を読んで文脈を把握して」とシステムメッセージを送る。

flowchart LR
    S["state/<br/>recent.md<br/>summary.md<br/>memory.md"]
    M["ミラー<br/>chat/state/"]
    G["Gemini CLI<br/>sandbox"]

    S --> |"更新時コピー"| M
    M --> |"読み取り"| G

バックエンドごとにTUIの出力形式が異なる(Codexは 、Geminiは でマーカーが違う等)ので、chat.py にバックエンドプロファイルを持たせてパース処理を切り替えている。

5. ブログ記事バリデーション

これはv1時点では完全に欠けていた部分。ジョブで日記記事を生成させると、テンプレートのルールに違反した記事が上がってくることがあった。frontmatterに draft: true が入っていない、「食事」セクションがない、画像の配置がおかしい、など。

v2ではジョブ完了後にバリデーションゲートを通す。

以下をチェックする。

チェック内容
frontmatterdraft: truecategory: diary の存在
必須セクション## 食事## 運動 の存在(運動は省略フラグあり)
セクション順序運動は食事より前に来ること
画像配置images_last フラグ時、全画像がドキュメント末尾にあること
ギャラリー食事画像が複数ある場合、<div class="gallery"> で囲まれていること

バリデーション結果は results/result-validation.md に書き出される。エラーがあればジョブのサマリーに含まれるので、通知で気づける。

記事ファイルのパス特定もそこそこ面倒で、ジョブの summary.md からバッククォート囲みのパスを探す → タイトルから日付を推定してパスを組み立てる → カテゴリディレクトリで最新の .md を探す、という3段フォールバックで見つける。

Jobsタブ。ジョブごとにステータスバッジ(完了・停止)とかなちゃんアイコンが表示される

6. PWAとプッシュ通知

v1は「終わったら通知が来る」と書いたが、実装はWebSocket経由のブラウザ通知だけだった。つまりブラウザを開いていないと通知が来ない。ファイア・アンド・フォーゲットを謳っているのにブラウザ開きっぱなしが必要では意味がない。

ネイティブアプリにすれば解決する話ではあるが、iOSアプリでプッシュ通知を実装するにはApple Developer Programの登録(年$99)が必要で、正直めんどくさかった。AndroidだけならFirebaseにぶん投げて終わりなんだけど、メイン端末がiPhoneなのでそうもいかない。そもそもこれは自分しか使わないツールで、ストアに申請するようなものでもない。さらに無料の開発者アカウントだと7日ごとに実機へデプロイし直す必要があって、いちいちそれをやるのもだるい。

結局PWAが一番楽だった。Web Push APIはiOS 16.4以降のSafariで対応しているので、Service Workerさえ入れればネイティブアプリと同じようにプッシュ通知が飛ばせる。ホーム画面に追加すればアプリっぽく使えるし、ストア申請も証明書管理も不要。

v2ではService Workerを入れてPWA化し、VAPIDベースのWeb Push通知に対応した。ジョブが完了・失敗・キャンセルされると、バックグラウンドでiPhoneにプッシュが飛ぶ。ステータスごとにアイコンも変えている。

{
  "title": "かなちゃん: ✅ Job完了",
  "body": "✅ 完了: ブログ記事を作成しました",
  "tag": "job-completed-job-20260323-192000",
  "kind": "job_complete",
  "icon": "/static/icons/push/completed.png"
}

VAPID鍵は環境変数で渡すか、初回起動時に自動生成して state/webpush-vapid.json に保存する。サブスクリプションはSQLiteで管理し、404/410を返すエンドポイントは自動で無効化する。

7. タグ駆動アクションとランタイムノート

チャットバックエンドの応答からアクションを発火させる仕組みを追加した。モデルの応答に特定のタグが含まれていると、サーバー側でパースしてアクションを実行する。

タグ動作
[[EXEC:SEARCH:クエリ]]非同期Web検索を実行、結果を次ターンに差し込み
[[EXEC:JOB:説明]]ジョブ確認ダイアログをフロントに送信

タグはユーザーに表示する前にストリップされる。[[EXEC:JOB:...]] でジョブが自動実行されることはなく、必ずフロントエンドで確認UIを挟む。

ランタイムノートは非同期処理の結果をチャットに戻す仕組み。background_searchの結果やジョブの完了通知など、次のユーザー発言時に最大6件(各900文字)を注入する。チャットバックエンドのCLIからすると、システムメッセージとして追加情報が入ってくる形になる。

8. 画像入力

v1のチャットはテキストオンリーだった。スクリーンショットを共有したいとき、ファイルパスを伝えて「これ見て」と言うしかなかった。iPhoneからだとそもそも画像を渡す手段がない。

v2ではチャットUIから画像を直接送信できる。iPhoneのカメラロールから選択するか、その場で撮影して送る。FastAPIが画像を受け取り、Base64エンコードしてチャットバックエンドのビジョンAPIに渡す。Claude、Geminiともにビジョン対応なので、画像の内容を理解した上で回答が返る。

flowchart LR
    U["iPhone<br/>画像選択・撮影"]
    API["FastAPI<br/>Base64エンコード"]
    LLM["チャットバックエンド<br/>ビジョンAPI"]

    U --> API
    API --> LLM

実際に多い使い方は「このエラー画面直して」と「この画面のここを変えて」。テキストでUIの位置関係を説明するより、スクリーンショットを1枚送るほうが早い。ジョブへの画像添付にも対応しているので、参考画像を見せながら「こういう感じで実装して」と投げられる。

9. 音声文字起こしとTailscale HTTPS化

チャットに音声入力の文字起こし機能を追加した。iPhoneのマイクから録音し、ブラウザのWeb Speech API(SpeechRecognition)でリアルタイムにテキスト化する。音声データをサーバーに送る必要はなく、ブラウザ側で文字起こしが完結する。長いメッセージをフリック入力で打つのが面倒なとき、音声で指示を飛ばせる。文字起こし結果はテキストメッセージとしてそのまま送信されるので、チャット側からはテキスト入力と区別がない。

flowchart LR
    MIC["iPhone マイク"]
    SR["ブラウザ<br/>SpeechRecognition API<br/>リアルタイム文字起こし"]
    MSG["テキストメッセージ<br/>として送信"]
    API["FastAPI"]

    MIC --> SR
    SR --> MSG
    MSG --> API

ただし、SpeechRecognition APIはセキュアコンテキスト(HTTPS)でしか動かない。v1ではTailscale VPN経由のHTTPで直接FastAPIに繋いでいたが、HTTPだとiOS Safariがマイクへのアクセスを許可しない。

解決策としてTailscale Serveを間に挟んだ。Tailscale Serveはローカルで動いているHTTPサーバーに対してTLS証明書を自動でプロビジョニングし、HTTPSで公開する機能。証明書の手動管理は不要で、MagicDNS名(<hostname>.<tailnet>.ts.net)でHTTPSアクセスできるようになる。

tailscale serve https:443 / http://localhost:8000

これでSpeechRecognition APIが使えるようになり、音声文字起こしが動いた。副次的にWebSocket接続もWSSに昇格して、通信全体がTLS化された。画像アップロードも含め、v1では平文で流れていたデータがすべて暗号化されている。

その他の細かい変更

変更内容
iPhoneテキストエリアの入力補助対策visualViewport APIでビューポートのリサイズを検知し、テキストエリアの位置をキーボードの高さ分だけ動的に調整。env(safe-area-inset-bottom) だけでは入力補助バーの高さを吸収しきれず、visualViewport.heightwindow.innerHeight の差分から算出する必要があった
ストリーミングチャットWebSocketで thinking → stream → done のイベントを段階的に送信。キャンセルも対応。v1はポーリングだった
Job Watchdogジョブのストール検知を外部プロセスに切り出した。Claude Codeワーカーは300秒、レビュアーは180秒でストール判定
ワーカーレーン並行ジョブ数を設定可能にした(デフォルト2レーン)。v1は逐次実行だった
ジョブタイムアウト3600秒(1時間)で強制終了。開始直後は120秒の猶予期間あり
Git安全設定AUTO_INIT_GIT_REPOAUTO_CREATE_GH_REPO をデフォルトfalseに。ジョブが勝手にリポジトリを作らないようにした