ハコブくんの表情をチャット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型)を追加。
項目 | 設定値 |
|---|---|
変数名 |
|
タイプ | Select |
オプション |
|
デフォルト |
|
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: 0node_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":""}'