ハコブくんの表情をチャットUIのステートとして実装した日。Dify × Studio × JavaScriptでHRI表情設計
LOG111HRI2026/3/23

ハコブくんの表情をチャットUIのステートとして実装した日。Dify × Studio × JavaScriptでHRI表情設計

GA4を眺めていたら予想外のデータが出てきて、そこからハコブくんの話になって、気づいたらチャットUIの表情切り替えを実装していた。実機展開そっちのけで、かなりいい仕込みができた日だった。

発信がトラフィックを動かし始めた

GA4を開いたら、Direct流入がOrganic Searchを逆転していた。直近1週間(3/17〜3/23)だけのデータで、Directが47.68%、Organic Searchが47.45%。ほぼ拮抗してDirectがわずかに上回っている。

これはSEOが落ちたわけじゃない。期間を1/1からの全期間に広げると、Organic Searchが85%で圧倒的1位に戻る。サイト自体の集客力は正常だ。

何が起きたかというと、3/18以降に投稿頻度を上げたXへの流入が、UTMパラメーターなしでDirectに計上されていた。XアプリやDM経由のリンクは参照元情報が失われやすく、Directに振り分けられるケースが多い。

解決は単純で、投稿リンクにUTMをつけるだけ。

https://adaptdesign.jp/pixelstomotors/record/day111?utm_source=twitter&utm_medium=social&utm_campaign=r2r2r

ただ、これを「問題」として見るより、「X投稿が効いてきた証拠」として見る方が正確だと思っている。Organic Social(計測できたX流入)が直近1週間で59人いて、3/17以前の3ヶ月で86人しかいなかった。1週間で3ヶ月分に迫っている。

ハコブくんをWebに住まわせる

話はそこからハコブくんに移っていった。

ハコブくんは、以前から設計していた薬剤搬送ロボットのキャラクター。白いボディに黒いスクリーン、青い目と笑顔が表示されるディスプレイ顔を持つ配送ロボットだ。R2R2Rの概念解説層として再登場させる予定だったが、シミュレーション完成まで休止させていた。

シミュレーションが完成して、実機展開フェーズに入った今、ハコブくんを再起動するタイミングが来た。

ただ、そのまま漫画連載を再開するだけじゃなくて、もう一歩踏み込む実装ができると気づいた。ハコブくんをチャットUIのアバターとして使う、という発想だ。

イメージしたのは、攻殻機動隊のフチコマ(タチコマ)。身体(リアルなロボット)を持ちながら、意識はネットに潜れる。リアルとデジタルを幽体離脱みたいに行き来する存在。

ハコブくんの世界観として整理するとこうなる。

  • リアル側 — 病院や施設を走り回る配送ロボット。物を運ぶ身体

  • Web側 — ai-agentとして常駐。LOG1からのログを全部読んでいて、訪問者と話せる

R2R2R自体が「デジタルツイン(仮想)→ 実機(現実)」という往復構造を持っているプロジェクトだ。そのコアコンセプトとキャラクターの設定がぴったり重なる。ハコブくんが「僕、シミュレーション空間で何千回も練習したんだよ」と言える世界観。

表情をステートとして設計する

ハコブくんの表情デザインはすでに13種類用意されていた。黒いスクリーンに青い目と口が表示される、あのデザインだ。

これをチャットUIのステートにマッピングする。UIデザイナーとして、ここは直感的に整理できた。

チャットのインタラクションを状態遷移として分解すると、5つのステートが見えてくる。

ステート

意味

表情

idle

待機中・入力受付

標準顔(穏やかな楕円目・笑顔)

thinking

API呼び出し中

困った顔(斜め線の目)

responding

ストリーミング返答中

真剣な表情(縦バー目)

happy

返答完了

キラキラした目(完了の喜び)

error

エラー発生

焦り顔(×目)

実装はシンプルだった。<img> タグのsrc属性を差し替えるだけ。アニメーションでもSVGでもなく、画像を入れ替える。

function ptmSetLatestIcon(expr) {
  const icons = document.querySelectorAll('.ptm-row-ai .ptm-icon');
  if (icons.length > 0) {
    icons[icons.length - 1].src = PTM_FACES[expr] || PTM_FACES.idle;
  }
}

送信ボタンを押した瞬間に thinking(困った顔)になり、ストリーミングが始まると responding(真剣)に切り替わり、返答が完了すると happy(キラキラ)になって2.5秒後に idle に戻る。

ヘッダーのステータスドットも連動する。待機中は黄緑、通信中は水色、エラーは赤。

最初に動いた瞬間

最初にStudioで確認したとき、表情が切り替わるのを見て「おおおおおーーーー!顔が変わった!」と思わず声が出た。

技術的には単純な実装だ。imgのsrcを変えているだけ。でも画面の中でハコブくんが考えて、答えて、喜ぶ動きをする——それを見た瞬間に、「ロボットの動きをデザインする」という自分のテーマがそのままUIに降りてきた感覚があった。

ロボットの表情もチャットの表情も、設計の問いは同じだ。「この瞬間に、何を伝えるか」。

UIとしての表情設計

ここで少し立ち止まって考えてみると、表情をUIとして設計するというのは、ロボット工学でいう「Human-Robot Interaction(HRI)」の問いと全く同じ構造を持っている。

物理ロボットの表情設計で問われることは:

  • 処理中であることをどう伝えるか

  • 成功・失敗をどう表現するか

  • 人に「今コミュニケーションできる状態か」をどう示すか

チャットUIの表情設計でも同じ問いが出てくる。ローディングスピナーを回すのか、顔を変えるのか。エラーをテキストで出すのか、表情で先に示すのか。

「動きをデザインする」という観点から見ると、画面の中のキャラクターも、物理空間のロボットも、設計の原則は連続している。

今の構成

ai-agentページのチャットUIに、ハコブくんの表情が5ステートで切り替わる形で実装できた。

残り8種類の表情(ウィンク・テレ顔・めまい・スリープなど)はまだ使っていない。実機展開フェーズが進んで「初めて物理ロボットが動いた瞬間」「失敗した瞬間」「デバッグ中」みたいなシチュエーションに合わせて追加していく予定だ。

フチコマはネットと現実を行き来した。ハコブくんはWebとロボットを行き来する。その設定はR2R2Rというプロジェクト自体の構造と重なっている——これが今日一番の発見だった。

追加記事:翻訳機能の実装を試みて、Difyの壁に当たった

今日の後半、GA4でアクセスデータを見ていて「海外からの訪問者もいる」ことに気づいた。直近1週間でEnglishブラウザユーザーが一定数いる。ならハコブくんのチャットに言語切り替えを入れたい——そう思って実装を試みたが、結果的に今日中の解決はできなかった。

記録として残しておく。

やろうとしたこと

ヘッダー右上に JP | EN のトグルを置いて、押すと挨拶文・サジェスト・AIの返答言語がすべて切り替わる設計。

● ハコブくん          PIXELS TO MOTORS AI   JP | EN

アクティブな方が #e8ff00(黄色)で光る。両方常時表示なので、英語圏のユーザーでも「ENが選べる」と一目でわかる。

Dify側でやったこと

スタートノードに language 変数(Select型)を追加。

項目

設定値

変数名

language

タイプ

Select

オプション

日本語 / English

デフォルト

日本語

LLMノードのSYSTEM末尾に追記:

## 応答言語
必ず {{#sys.inputs.language#}} で回答すること。
"日本語" の場合は日本語で、"English" の場合は英語で回答する。
応答言語に関わらず、マーカーは必ず [次の質問] を使ってください。

Studio側でやったこと

inputs に language を渡し、JP/ENトグルUIを実装した:

let ptmLanguage = '日本語';

// リクエスト時に言語を渡す
body: JSON.stringify({
  inputs: { language: ptmLanguage },
  query: input,
  user: 'user-ptm',
  response_mode: 'streaming',
  conversation_id: ptmConvId
})
// JP | EN トグル
function ptmSetLang(lang) {
  ptmLanguage = lang;
  document.getElementById('ptm-lang-jp').classList.toggle('ptm-lang-active', lang === '日本語');
  document.getElementById('ptm-lang-en').classList.toggle('ptm-lang-active', lang === 'English');
  ptmConvId = ''; // 言語切り替えで会話リセット
  // メッセージエリアをクリアして挨拶を再表示
  document.getElementById('ptm-messages').innerHTML = '';
  if (lang === 'English') {
    ptmAppendMsg('ai', "I'm Hakobu! I know everything about this robot development project.\nFeel free to ask me anything!");
    ptmAppendSuggestions(['What is this project about?', 'What was the hardest part?', 'Why start with an iPhone?']);
  } else {
    ptmAppendMsg('ai', 'ハコブくんにまかせて!\nロボット開発の記録、全部頭に入ってるよ。\nなんでも聞いてね!');
  }
}

当たった壁:Dify Chatflowのイベント構造

デバッグログを仕込んで調べた結果、Dify ChatflowはSSEで message や text_chunk を返さないことが判明した。

返ってくるイベントはすべて node_finished と workflow_finished のみ:

[1] event=workflow_started
[2] event=node_started   // 知識検索ノード開始
[3] event=node_finished  // 知識検索ノード完了
[4] event=node_started   // LLMノード開始
[5] event=node_finished  // LLMノード完了
...
Stream done. Total events: 8 / fullText length: 0

node_finished の outputs.text を取ろうとしたが、複数ノードが順番に node_finished を発火するため(知識検索→LLM→回答)、最初のノードで空振りして取り損ねていた。

blocking モードに切り替えて data.answer を直接取る方法も試みたが、Chatflowは blocking モード非対応(Status: 400)だった。

現状

翻訳機能は未実装のまま元に戻している。UIトグル(JP | EN)のデザインと設計は完成済みで、あとはDify側のイベント取得問題だけが残っている。

次回の方針候補:

  • Dify公式ドキュメントで ChunkAdvancedChatEvent の全イベント仕様を確認する

  • Chatflowの「回答ノード」を経由したときの text_chunk の正確な構造を検証する

  • DifyをAgentタイプで作り直す(Agentは message イベントが来ることが確認されている)

教訓:インフラ(Dify)の仕様を把握する前に実装を進めると、試行錯誤が多くなる。
先に curl で生のSSEレスポンスを確認してから実装に入るべきだった。

# 次回試す:生のSSEレスポンスを直接確認する
curl -N -X POST https://api.dify.ai/v1/chat-messages \
  -H 'Authorization: Bearer APP_KEY' \
  -H 'Content-Type: application/json' \
  -d '{"inputs":{"language":"日本語"},"query":"テスト","user":"test","response_mode":"streaming","conversation_id":""}'