HILS と画像検査の組み合せ

 

dSPACE - MAN - ADAS: dSPACE tool chain used by MAN

youtu.be

 

ETAS Camera-based Test System
 Vision Based LABCAR Testing: Camera Based Testing of HMI
 f:id:sato-7411:20200723145227p:plain

  

自動車開発において HILS (Hardware-In-the-Loop-Simulation) が使われています。自動車には, エンジンやメーターも含め, 様々な ECU (Electronic Control Unit) が使われています。

各メーカーで ECU を開発する際, ECU コネクタに接続されている車のセンサーやアクチュエータ, 通信の電気信号を, Digital/Analog の I/O ボードですべて模擬できれば, ECU からは実車で走行しているのと同じに見えて, 様々な条件下でのテストが行えます。

ECU から見て車をシミュレーションするハードウェア・ソフトウェアが HILS です。車全体をシミュレーションすることから, エンジン ECU, 車両制御 ECU, オートマチックトランスミッション ECU, ブレーキ ECU, メーター ECU, といった複数の ECU を接続して, テストすることが可能です。

HILS のコンピュータは, 車の走行状態や周囲環境をシミュレーションするために, モデル (プラントモデル) を使って計算しています。プラントモデルは, 各ベンダーが提供する, リアルタイムPC上で動作する自動車モデルや, MATLAB/Simulink で独自に作られた計算モデルで構成されています。

例えば, 詳細なエンジンモデルでは, インマニでの吸気, インジェクターの燃料噴射, イグニッションの点火時期, シリンダー・ピストンでの混合気の圧縮をシミュレーションし, 出力トルクを計算します。車両モデルでは, 設定された, 道路の勾配, 路面の摩擦係数, 車体の前面積, 車重, 気温, 気圧, などから走行抵抗を計算し, 車速を計算します。

HILS では, アクセルやブレーキの操作を時系列に記録したデータを読み込んで, パターン走行する, 自動テストが行われています。自動パターン走行時に, メーター表示を画像検査して, 車の走行を監視することができます。

 

がん治療(オプジーボ、一般名:ニボルマブ、免疫チェックポイント阻害薬)

次の資料を FAISS に登録して、質問してみました。

小野薬品工業株式会社「【オプジーボ】 切除可能非小細胞肺がんでの術前化学療法にニボルマブを追加する:CheckMate 816 試験」
小野薬品工業株式会社「【オプジーボ】 肺がんに対するニボルマブと化学療法の術前併用療法における全生存率」
小野薬品工業株式会社「【オプジーボ単剤】進行性扁平上皮非小細胞肺癌におけるニボルマブとドセタキセルの比較」

   

Q. 切除可能非小細胞肺がんの臨床試験で、術前オプジーボと化学療法は、抗腫瘍T細胞や免疫系にどのように作用し、抗腫瘍免疫を増強しますか?

A.
CheckMate 816 試験において、術前にオプジーボ(ニボルマブ)と化学療法を併用することで、抗腫瘍免疫が増強されるメカニズムは以下の通りです:

1. ニボルマブの作用: ニボルマブは、完全ヒト抗プログラム細胞死1(PD-1)抗体であり、既存の抗腫瘍T細胞の機能を回復させます。PD-1はT細胞の表面に存在する受容体で、腫瘍細胞がこの受容体に結合することでT細胞の活動を抑制します。ニボルマブはこの結合を阻害することで、T細胞の抗腫瘍活性を回復させます。

2. 化学療法の作用: 化学療法は、直接的または間接的に免疫系を活性化します。具体的には、腫瘍細胞を破壊することで腫瘍抗原を放出し、これが免疫系を刺激して抗腫瘍免疫を強化します。

3. 併用療法の効果: ニボルマブと化学療法の併用により、微小転移性疾患を早期に治療する機会が提供され、治療中に腫瘍塊や腫瘍抗原がまだ存在する場合に免疫応答が増強されます。これにより、病理学的完全奏効や生存転帰の改善が期待されます。

このように、ニボルマブと化学療法の併用は、免疫系の活性化を通じて抗腫瘍免疫を増強し、非小細胞肺がんの治療効果を高めることが示されています。

   

Q. 切除可能非小細胞肺がんの臨床試験では、どのような副作用(有害事象)が確認されましたか?

A.

CheckMate 816 試験では、切除可能非小細胞肺がんの術前化学療法にニボルマブ(オプジーボ)を追加した際の有害事象が報告されています。以下は主な有害事象の概要です:

1. 治療関連有害事象(ニボルマブ+化学療法群 vs. 化学療法群):
・全体の有害事象発生率は、ニボルマブ+化学療法群で82.4%、化学療法群で88.6%でした。
・Grade 3または4の有害事象は、ニボルマブ+化学療法群で33.5%、化学療法群で36.9%でした。
・主な有害事象には、悪心、貧血、便秘、食欲減退、好中球減少症などが含まれます。

2. 手術関連有害事象:
・手術関連の有害事象は、ニボルマブ+化学療法群で41.6%、化学療法群で46.7%でした。
・主な有害事象には、貧血、疼痛、創傷合併症、処置時疼痛、肺炎などが含まれます。

3. 手術の延期および中止につながった有害事象:
・手術延期につながった有害事象は、ニボルマブ+化学療法群で3.4%、化学療法群で5.1%でした。
・手術中止につながった有害事象は、ニボルマブ+化学療法群で1.1%、化学療法群で0.6%でした。

これらのデータは、ニボルマブを追加することによる有害事象の発生率や重症度を示しており、治療のリスクとベネフィットを評価する際に重要な情報となります。

   

Q. 切除可能非小細胞肺がんの臨床試験で、術前オプジーボ+化学療法群と化学療法単独群で、それぞれ病理学的完全奏効が認められた患者は何名でしたか?

A.
CheckMate 816試験において、術前オプジーボ(ニボルマブ)+化学療法群では43名(30.5%)、化学療法単独群では4名(3.2%)の患者が病理学的完全奏効(pCR)を認めました。

   

Q. 切除可能非小細胞肺がんの臨床試験で、無イベント生存期間(EFS)のイベントとして何が定義されていますか?

A.
CheckMate 816試験における無イベント生存期間(EFS)のイベントは、以下のように定義されています:

1. 手術不能となる疾患の進行
2. 手術後の疾患の進行または再発(RECIST 1.1に基づく盲検下独立中央判定による)
3. あらゆる原因による死亡

これらのいずれかのイベントが発生するまでの期間がEFSとして評価されています。後続の治療による打ち切り(センサリング)は行われません。

   

Q. 肺がん患者を対象に、術前オプジーボ+化学療法と化学療法単独を比較した臨床試験では、対象となった患者は何名で、全生存期間および5年全生存率についてどのような結果が得られましたか?また、病理学的完全奏効を示した患者の5年全生存率はどのくらいでしたか?

A.
この臨床試験では、合計358名の非小細胞肺癌患者が対象となり、ニボルマブ(オプジーボ)と化学療法を併用する群(179名)と化学療法単独群(179名)に無作為に割り付けられました。

全生存期間の最終解析では、ニボルマブと化学療法の併用が化学療法単独よりも有意に有利であり、死亡のハザード比は0.72(95%信頼区間0.523~0.998、P=0.048)でした。追跡期間中央値68.4ヵ月において、5年全生存率はニボルマブと化学療法の併用群で65.4%、化学療法単独群で55.0%でした。

病理学的完全奏効を示した患者の5年全生存率は、ニボルマブ+化学療法群において95.3%(95%信頼区間、82.7~98.8)でした。

   

Q. 進行性扁平上皮非小細胞肺がんの臨床試験では、安全性について、治療関連有害事象の発生頻度や重症度にどのような違いが認められましたか?全グレード、グレード3または4の有害事象と、主な有害事象について教えてください。

A.
進行性扁平上皮非小細胞肺がんにおけるオプジーボ(ニボルマブ)とドセタキセルを比較した臨床試験の安全性に関する結果は以下の通りです。

1. 全グレードの治療関連有害事象:
・ニボルマブ群では、治療関連の重篤な有害事象の発生頻度が低く、患者の7%にグレードの重篤な事象がみられました。
・ドセタキセル群では、患者の24%にグレードの重篤な事象がみられました。

2. グレード3または4の治療関連有害事象:
・ニボルマブ群では、2%にグレード3または4の重篤な事象がみられましたが、グレード5の重篤な事象はみられませんでした。
・ドセタキセル群では、19%にグレード3または4の重篤な事象がみられ、2%にグレード5の重篤な事象がみられました。これらの高い発生率は主に血液毒性事象と感染症によるものでした。

3. 主な有害事象:
・ニボルマブ群で最も頻繁に報告された有害事象は、甲状腺機能低下症(4%)、下痢(8%)、肺炎(5%)、血中クレアチニン値の上昇(3%)、および発疹(4%)でした。
・ドセタキセル群では、下痢(20%)が多く報告されました。
・治療関連の有害事象により治療を中止した患者の割合は、ニボルマブ群が3%、ドセタキセル群が10%でした。ニボルマブ群での治療中止の主な理由は肺炎(2%)であり、ドセタキセル群では末梢神経障害および疲労(それぞれ3%および2%)でした。

これらの結果から、ニボルマブはドセタキセルに比べて全体的に安全性が高いとされています。

   

Q. 進行性扁平上皮非小細胞肺がんにおける有効性を確認した臨床試験では、全生存期間についてどのような結果が得られましたか?オプジーボ群とドセタキセル群の全生存期間の中央値、1年全生存率、死亡リスク、死亡のハザード比を中心に、その他の有効性についても教えてください。

A.
進行性扁平上皮非小細胞肺がんにおけるオプジーボ(ニボルマブ)とドセタキセルの有効性を比較した臨床試験の結果は以下の通りです:

全生存期間

□ 全生存期間の中央値:
・オプジーボ群:9.2ヵ月(95%信頼区間[CI]、7.3~13.3ヵ月)
・ドセタキセル群:6.0ヵ月(95% CI、5.1~7.3ヵ月)

□ 1年全生存率:
・オプジーボ群:42%(95% CI、34~50%)
・ドセタキセル群:24%(95% CI、17~31%)

□ 死亡リスク:
・オプジーボ群の方がドセタキセル群よりも41%低かった。

□ 死亡のハザード比:
・ハザード比は0.59(95% CI、0.44~0.79、P<0.001)

その他の有効性

□ 奏効率:
・オプジーボ群:20%
・ドセタキセル群:9%(P=0.008)

□ 無増悪生存期間の中央値:
・オプジーボ群:3.5ヵ月(95% CI、2.1~4.9)
・ドセタキセル群:2.8ヵ月(95% CI、2.1~3.5)
・無増悪生存期間のハザード比:0.62(95% CI、0.47~0.81、P<0.001)

□ 1年後の無増悪生存率:
・オプジーボ群:21%(95% CI、14~28)
・ドセタキセル群:6%(95% CI、3~12)

これらの結果から、オプジーボはドセタキセルに比べて全生存期間および無増悪生存期間において有意に優れていることが示されました。

   

Q. 進行性扁平上皮非小細胞肺がんの臨床試験で、間質性肺炎によりオプジーボ投与を中止した4例について、安全性に関する経過を説明してください。

A.
進行性扁平上皮非小細胞肺がん患者を対象としたオプジーボ(ニボルマブ)とドセタキセルの比較臨床試験において、間質性肺炎によりオプジーボ投与を中止した4例の安全性に関する経過は以下の通りです:

1. 症例1:
・72歳男性、ステージIVの扁平上皮非小細胞肺癌。
・ニボルマブ3回目投与後、間質性肺炎が発生し、プレドニゾンで治療。
・症状は一時的に悪化し、最終的に肺炎は消失したが、原疾患の進行により死亡。

2. 症例2:
・53歳男性、ステージIIIBの扁平上皮非小細胞肺癌。
・ニボルマブ16回目投与後、呼吸困難と低酸素血症が発生し、肺炎と診断。
・プレドニゾンなどで治療し、肺炎は消失したが、再度呼吸困難が発生し、最終的に投与中止。

3. 症例3:
・60歳男性、ステージIVの扁平上皮非小細胞肺癌。
・ニボルマブ13回目投与後、肺炎が発生し、プレドニゾロンで治療。
・一時的に改善したが、再度肺炎が悪化し、最終的に投与中止。

4. 症例4:
・61歳男性、ステージIIIBの扁平上皮非小細胞肺癌。
・ニボルマブ11回目投与後、肺炎が発生し、メチルプレドニゾロンで治療。
・肺炎は消失したが、病勢進行により死亡。

これらの症例では、間質性肺炎が発生し、ステロイド治療が行われましたが、最終的にオプジーボの投与は中止されました。各症例での経過は異なりますが、肺炎の発生とその後の治療が共通しています。

   


用語

 

オプジーボ、ヤーボイ
免疫チェックポイント阻害薬(Immune Checkpoint Inhibitor)。患者自身の免疫力を回復させてがんを攻撃する。通常、T細胞の PD-1 とがん細胞の PD-L1 が結合すると、T細胞はがん細胞への攻撃を停止する。オプジーボは PD-1 をブロックすることで、T細胞ががん細胞を攻撃し続けられるようにする
Opdivo:T細胞表面の PD-1 を阻害する
Yervoy:T細胞表面の CTLA-4 を阻害する

 

CD1d
CD1d(Cluster of Differentiation 1d)は、細胞表面に存在し、脂質抗原を免疫細胞に提示する分子。大まかな流れは、がん細胞・抗原提示細胞のCD1d → 脂質抗原を提示 → NKT細胞のT細胞受容体(TCR)が認識 → NKT細胞が活性化 → 抗腫瘍免疫、となる

CD1d
  ↓
NKT活性化
  ↓
がん攻撃

├─ がん消失
└─ がんが免疫逃避
       ↓
   PD-L1発現
       ↓
   NKT抑制

 

プラチナ製剤(化学療法)
白金(プラチナ:platinum)を含む抗がん剤の総称。代表的な薬剤には、シスプラチン(cisplatin)、カルボプラチン(carboplatin)、オキサリプラチン(oxaliplatin)がある。プラチナ製剤は、がん細胞のDNAに結合してDNAの構造を損傷させる

 

RECIST v1.1
がんの臨床試験などで、治療によって腫瘍がどの程度小さくなったか、増大したかを客観的に評価するための国際的な基準。主な判定は次の4種類です:CR(Complete Response、完全奏効)、PR(Partial Response、部分奏効)、SD(Stable Disease、安定)、PD(Progressive Disease、進行)

 

病理学的完全奏効(Pathological Complete Response、Complete Response:CR)
RECIST v1.1 のCR(完全奏効)は、主にCTなどの画像による評価です。病理学的完全奏効(pCR)は、手術で切除した組織などを顕微鏡で調べて、生存しているがん細胞が認められないことを評価します。

 

追跡期間中央値68.4ヵ月において、5年全生存率は...
例えば、追跡期間を短い順に並べたとき、
20ヵ月、40ヵ月、68.4ヵ月、72ヵ月、80ヵ月
のようになっていれば、中央に位置する「68.4ヵ月」が中央値です。
患者を中央値で約5年8ヵ月追跡した結果、治療開始から5年後にどのくらいの患者が生存していたか

 

悪心(Nausea)
嘔吐、吐き気

 

死亡のハザード比 0.72(95% 信頼区間 0.523~0.998、P=0.048)
臨床試験で2つの治療群の死亡の起こりやすさを時間経過も考慮して比較した結果。
ニボルマブ+化学療法群を化学療法単独群と比較した場合、
・ハザード比 0.72:ニボルマブ+化学療法群の死亡ハザード(ある時点まで生存している人が、その直後に死亡する起こりやすさ)は、化学療法単独群の0.72倍でした。言い換えると、観察期間を通じた死亡のハザードが約28%低かったことを示します(1 − 0.72 = 0.28)。
・95%信頼区間 0.523~0.998:推定されたハザード比には不確実性があり、その範囲を示しています。重要なのは、死亡について「差がない」ことを表すハザード比 1.0を、この95%信頼区間がわずかに下回っていることです。
・P=0.048:通常用いられる有意水準0.05をわずかに下回っています。そのため、一般的な基準では統計学的に有意な差が認められたと判断されます。ただし0.048は0.05にかなり近く、95%信頼区間の上限も0.998と1.0に非常に近いため、統計学的な証拠は境界的です。P値だけでなく信頼区間や効果量も合わせて見ることが重要です。

 

ドセタキセル
イチイ科の植物成分を原料として半合成された化合物。細胞が分裂する際に必要な細胞構成成分の一つである微小管を安定化および過剰発現させることにより、がん細胞の増殖を阻害する。この薬の投与の副作用として、手足のむくみや胸やお腹への水分貯留が報告されている

 


 

生成AIチャットのリンク(自分用)
がん治療の生成AIチャット

   

生成AIの埋め込み(Embedding)について、text-embedding-3-large(OpenAI、ChatGPT)と、bge-m3、qwen3-embedding を比較

本文章の作成には ChatGPT を使用しているため、OpenAI にひいき目な内容になっているかもしれません。

Embedding を行って、質問と、(上位の)代表的なチャンクの類似度を計算し、順位付けを行いました。

質問:
HS-005(カテーテル投与)の治験状況について教えてください。

コンテンツ:
Heartseed株式会社「事業計画及び成長可能性に関する事項 2026年3月時点」(各章でチャンクに分割)
Heartseed株式会社「心筋再生医療 HS-005(カテーテル投与)の第1/2相治験企業治験における第1例目実施に関するお知らせ」

質問文は
「HS-005のカテーテル投与に関する治験の進捗状況」
です。
ところが p11-p12 には
# HS-005
# カテーテル
# EMERALD試験
# Phase1/2
# 治験届完了
# 2026年投与開始へ向け準備中
が非常に密集しています。

一方、第1例目実施のお知らせには
# HS-005
# EMERALD試験
# 第1例目投与
# 治験継続承認
がありますが、文書全体がかなり長いです。

人間が見ると
「HS-005の治験の進捗」
なら
「第1例目投与成功」
が最も直接的です。

OpenAI は
進捗
↓
治験開始
↓
第1例目投与
↓
継続承認
という意味理解が効いています。

bge-m3 は長文になるほど
# HS-005
# カテーテル
# EMERALD
# Phase1/2
のようなキーワード密度に引っ張られる傾向があります。

OpenAI の Embedding はこのような意味関係をベクトル上の近さとして表現しやすいです。

OpenAI の Embedding は、テキスト同士の関連性を測るためのベクトルを作り、検索ではクエリに関連する文書を順位付けできます。

今回の質問文は、
「HS-005のカテーテル投与に関する治験の進捗状況」
です。
この中で重要なのは単語そのものだけでなく、
「治験の進捗状況」
という意味です。
この表現は、単なる「HS-005の説明」よりも、
# 第1例目が実施された
# 投与後4週間のデータ評価
# 治験継続を承認
# 国内第1/2相EMERALD試験が進展
のような文章と意味的に近くなります。

一方、p11-p12 の資料には、
# HS-005
# カテーテル
# EMERALD試験
# Phase1/2
# 治験届完了
# 2026年投与開始へ向け準備中
が含まれます。これは関連性は高いですが、内容としては「計画・パイプライン表」です。

つまり OpenAI の結果では、
質問: 治験の進捗状況を知りたい
↓
より近い文書: 実際に第1例目が実施された最新進捗
↓
次に近い文書: HS-005の概要・EMERALD試験デザイン
↓
その次: パイプライン上の予定・準備状況
という並びになったと考えられます。

ポイントは、text-embedding-3-large が
「進捗状況」
を、単なるキーワード一致ではなく、
# 実施された
# 成功した
# 承認された
# 進展した
# 継続を承認
のような「進捗を表す表現」と近く配置している可能性が高いです。

   

質問:
パーキンソン病患者へのドパミン神経前駆細胞移植は、どのように行われるのですか?

コンテンツ:
住友ファーマ株式会社「日本における非自己iPS細胞由来ドパミン神経前駆細胞「アムシェプリ」の製造販売承認取得に関するお知らせ」(各章・節でチャンクに分割)   

質問は
「パーキンソン病に対するドパミン神経前駆細胞の移植手順」
です。
ここには2通りの解釈があります。

解釈A
「移植手順 = 実際にどうやって脳に移植するのか」
つまり
# 手術方法
# 注入方法
# 軌道設計
を知りたい。

解釈B
「移植手順 = 細胞作製から移植までの流れ」
つまり
iPS細胞
↓
DA前駆細胞誘導
↓
品質確認
↓
移植
を知りたい。

OpenAI と bge-m3 は解釈Aを選んでいます。
1位の
「11_07_方法」
には
# iPlan
# Leksell G
# 3軌道
# 4~8回注射
# 被殻へ移植
が書かれています。つまり、
移植手順
↓
手術手順
と解釈し、「11_07_方法」が1位になりました。

一方 qwen3 は解釈Bを選んだように見えます。
1位の
「06_DA前駆細胞の誘導と移植」
には
CORIN+選別
↓
凝集球形成
↓
品質確認
↓
被殻へ移植
が書かれています。つまり、
移植手順
↓
細胞準備から移植までの全工程
と考えた可能性があります。

一般的には
OpenAI
→ 意味重視
bge-m3
→ 検索重視
と考えられています。
しかし今回の質問では「移植手順」という単語が非常に強く、タイトルや本文に「方法、移植、起動、注射、手術」が多く含まれるため、
# 意味的にも近い
# キーワード的にも近い
という状態になっています。

qwen3 は、(上図には示していませんが)類似度がほぼ同じなので、
「3文書とも非常に似ている」
と考えています。

# OpenAI: 検索意図を解釈して順位付け
# bge-m3: 検索キーワードを重視して順位付け
# qwen3: 3文書とも近いと判断して差が付きにくい
という傾向が見えます。

特に qwen3 は今回、0.748109、0.748070、0.746996 とほぼ横並びなので、文書数が100件、1000件に増えると順位が不安定になる可能性があります。

   

iPS細胞(糖尿病、膵島細胞)

次の資料を FAISS に登録して、質問してみました。

京都大学医学部附属病院「『iPS細胞由来細胞由来膵島細胞シート移植に関する医師主導治験』における第一症例目の移植実施について」

オリヅルセラピューティクス株式会社「iPS細胞由来細胞治療における生体内分布評価の考慮点:膵島細胞を用いた事例研究」

      

用語
膵島(すいとう):膵臓の中に点在している小さな島状の組織。血糖値を調節するインスリン、グルカゴンなどを分泌する

   

Q. 生体内分布評価とは何ですか?iPS細胞由来膵島細胞の生体内分布評価の結果について教えてください。

A.
生体内分布評価とは、特定の治療製品や細胞が体内でどのように分布し、移動するかを調査する研究です。この評価は、治療の安全性や有効性を確認するために重要であり、特に再生医療や細胞療法においては、細胞が意図した部位に留まるか、他の臓器に移動してしまうかを確認することが求められます。

オリヅルセラピューティクス株式会社によるiPS細胞由来膵島細胞の生体内分布評価の結果は以下の通りです:

    研究デザイン: iPS細胞由来膵島細胞(iPIC)は、NOGマウスに皮下移植されました。研究は52週間にわたって行われ、移植後の細胞の運命と腫瘍形成リスクを評価しました。

    結果: iPICは移植部位に1年間留まり、他の臓器への移動は観察されませんでした。これは、長期生存と腫瘍形成リスクの最小化を示唆しています。

    細胞数と濃度の推移: 移植部位における細胞数と細胞濃度の時間的推移は、時間とともに増加し、定常状態に達しました。細胞は血液や他の主要臓器(肝臓、腎臓、脾臓、心臓、肺、脳など)では観察されませんでした。

この研究は、iPS細胞由来の細胞治療製品の生体内分布プロトコルの標準化に貴重な知見を提供し、臨床応用における安全性を示唆しています。

   

Q. iPS細胞由来の膵島細胞を移植した治験結果について教えてください。

A.

京都大学医学部附属病院で行われた「iPS細胞由来膵島細胞シート移植に関する医師主導治験」の結果についてお伝えします。

    治験の目的と概要:
        この治験は、1型糖尿病患者を対象に、iPS細胞由来の膵島細胞シート(OZTx-410)の安全性を評価することを目的としています。
        第一症例目の患者に対して、2025年2月に移植が実施されました。

    手術と経過:
        手術は京都大学医学部附属病院で行われ、術後の経過は良好で、患者は既に退院しています。
        術後1ヶ月までの経過観察では、安全性に大きな問題は認められませんでした。

    今後の予定:
        第一症例目の術後1ヶ月までの安全性が確認されたため、第二症例目の移植に向けた準備が進められています。
        移植を受けた患者の経過観察は最大5年間継続される予定です。

この治験は、iPS細胞由来の膵島細胞を用いた治療の安全性を確認するための初期段階であり、今後の症例や長期的な観察結果が重要となります。

   

Q. iPS細胞由来膵島細胞は、肝内移植ではなく皮下移植でも生着し、機能するのですか?

A.
オリヅルセラピューティクス株式会社の研究によれば、iPS細胞由来膵島細胞(iPICs)は皮下移植後も生着し、機能する可能性が示されています。具体的には、生分解性足場を用いた皮下移植により、糖尿病マウスおよびブタモデルにおいて糖尿病を改善することが示されています。このことから、皮下移植されたiPICsが血糖値に基づいてインスリンを分泌し、治療効果を発揮する可能性があると考えられます。

また、皮下移植の利点として、異常な移動や腫瘍形成を起こさずに移植部位に留まることができれば、予期せぬ副作用が発生した場合でも移植部位から除去することが可能である点が挙げられています。したがって、皮下移植はiPICsの有効性と安全性を確保する上で有望なアプローチとされています。

※ 膵島細胞は血糖値を感知してインスリンを分泌するため、十分な血流と酸素が供給されれば、肝臓内でなくても、皮下や腹腔内などでも機能する

   

生成AIチャットのリンク(自分用)
iPS細胞・再生医療の生成AIチャット

   

iPS細胞製造

次の資料を FAISS に登録して、質問してみました。

住友ファーマ株式会社「日本における非自己iPS細胞由来ドパミン神経前駆細胞「アムシェプリ」の製造販売承認取得に関するお知らせ」(Phase I/II trial of iPS-cell-derived dopaminergic cells for Parkinson’s disease | Nature 641, 971–977 (2025))

京都大学iPS細胞研究財団「iPS細胞ストックのご案内」

株式会社IDファーマ「iPS 細胞作製用センダイウイルスベクターキット CytoTune-iPS-2.0L」    

   

Q. パーキンソン病治療用のiPS細胞は、どうやって作製するのですか?

A. 
パーキンソン病治療用のiPS細胞の作製方法について、住友ファーマ株式会社の臨床試験に基づく情報を整理します。

    ヒトiPS細胞の生成:
        臨床グレードのヒトiPS細胞株(QHJI01s04)は、日本人集団で最も頻繁に見られるハプロタイプのホモ接合体である健康な個人の末梢血から樹立されました。このハプロタイプは日本人集団の17%に相当します。

    ドーパミン作動性前駆細胞の誘導:
        ヒトiPS細胞をラミニン511-E8断片でコーティングした培養プレートに播種し、11~13日間培養します。
        DA前駆細胞を濃縮し、非標的細胞を除去するために、蛍光活性化セルソーターを用いてCORIN+(床板マーカー)細胞を分離します。
        選別されたCORIN+細胞は、96ウェルプレートで神経分化培地とともに培養され、30日目まで凝集球を形成します。

    最終製品の準備:
        DA前駆細胞は各患者ごとに個別に誘導され、手術当日に新鮮な細胞として準備されます。
        最終製品は品質管理基準を満たしており、細胞は免疫染色およびシングルセルRT-qPCRによっても分析されます。

このプロセスにより、パーキンソン病治療に用いるためのiPS細胞由来ドーパミン作動性細胞が作製されます。

   

Q. iPS細胞からドパミン神経前駆細胞を誘導する方法と材料を教えてください。

A. 
住友ファーマ株式会社のパーキンソン病に対するiPS細胞由来ドーパミン作動性細胞の第I/II相臨床試験におけるドーパミン神経前駆細胞の誘導方法と材料は以下の通りです。
方法

    ヒトiPS細胞の準備:
        臨床グレードのヒトiPS細胞株を使用。
        iMatrixコート(Matrixome)でコーティングされた6ウェル培養皿上で、StemFit AK03N培地(味の素)を用いて維持。

    ドーパミン作動性前駆細胞の誘導:
        ヒトiPS細胞をラミニン511-E8断片でコーティングした培養プレートに播種。
        播種密度は5.3×10^5個/cm^2。
        11~13日間培養。

    細胞の選別と培養:
        11~13日目に蛍光活性化セルソーターを用いてCORIN^+(床板マーカー)細胞を選別。
        選別されたCORIN^+細胞を神経分化培地で培養し、凝集球を形成。

材料

    培養基: StemFit AK03N培地(味の素)
    コーティング材: iMatrixコート(Matrixome)、ラミニン511-E8断片
    細胞選別装置: 蛍光活性化セルソーター(BD Influx、MACSQuant Tyto、GigaSort)

この方法により、ドーパミン神経前駆細胞が誘導され、最終的に品質管理基準を満たす細胞が得られます。

   

Q. パーキンソン病患者へのドパミン神経前駆細胞移植は、どのように行われるのですか?

A.

パーキンソン病患者へのドーパミン神経前駆細胞の移植は、以下のような手順で行われます。

    細胞の誘導と準備:
        ヒトiPS細胞からドーパミン作動性前駆細胞(DA前駆細胞)を誘導します。これは、特定の培養条件下で行われ、11~13日目にCORIN+(床板マーカー)細胞を選別して濃縮します。
        選別された細胞は神経分化培地で培養され、凝集球を形成します。最終的な製品は、約60%のDA前駆細胞と40%のDAニューロンから構成されます。

    品質管理:
        DA前駆細胞は品質管理基準を満たす必要があります。これには、免疫染色やシングルセルRT-qPCRによる分析が含まれます。

    細胞移植:
        iPlan神経外科ナビゲーションシステムを用いて、細胞を両側被殻に移植します。手術はLeksell Gフレームシステムとカスタム注射針を用いて行われます。
        各半球につき3つの軌道を用い、各軌道につき4~8回の注射を行い、被殻あたり2.1~5.5×10⁶個の細胞を移植します。
        注射部位は、術中にコーンビームCTで確認されます。

このプロセスは、細胞の生存、増殖、分化能を評価し、腫瘍様の過剰増殖がないことを確認するために、動物モデルでの事前評価も行われています。 

   

Q. ドパミン神経前駆細胞は、どのような働きをするのですか?

A.
ドーパミン神経前駆細胞は、特にパーキンソン病の治療において重要な役割を果たします。これらの細胞は、ドーパミンを産生するニューロンに分化する能力を持っています。パーキンソン病は、脳内の黒質という部分でドーパミンを産生するニューロンが減少することによって引き起こされるため、ドーパミン神経前駆細胞を移植することで、失われたドーパミンニューロンを補充し、運動症状を改善することが期待されます。

具体的には、ドーパミン神経前駆細胞は以下のような働きをします:

    ドーパミン産生: ドーパミン神経前駆細胞は、ドーパミンニューロンに分化し、ドーパミンを産生します。これにより、パーキンソン病患者の脳内で不足しているドーパミンを補うことができます。

    運動症状の改善: ドーパミンの補充により、パーキンソン病の特徴的な運動症状(運動緩慢、筋強剛、安静時振戦など)が改善される可能性があります。

    細胞療法の一環としての役割: ドーパミン神経前駆細胞は、細胞療法の一環として、失われたニューロンを補充するために使用されます。これにより、従来の薬物療法では対処しきれない症状や合併症を軽減することが目指されています。

このように、ドーパミン神経前駆細胞は、パーキンソン病の治療において重要な役割を果たす可能性があります。

   

Q. iPS細胞ストックに関して、臨床用株と研究用株の違いは何ですか?値段は同じですか?

A.
iPS細胞ストックにおける臨床用株と研究用株の違いは以下の通りです:

    製造環境:
        臨床用株はCPC(管理区域内)で製造されています。
        研究用株は一般区域の研究室で製造されています。

    用途:
        臨床用株は、臨床研究や治療に使用されることを目的としています。
        研究用株は、基礎研究や創薬研究など、臨床以外の研究目的で使用されます。

    提供条件:
        臨床用株は、原則として研究用株での分化誘導実績のある機関にのみ提供されます。ただし、センダイウイルスiPS細胞ストックやHLAゲノム編集iPS細胞ストックについては、初回から臨床用株を使用することが可能です。

値段:

    非営利機関(大学、研究機関等)に対しては、臨床用株も研究用株も無料で提供されます。
    営利機関(製薬企業、スタートアップ等)に対しては、研究用株は50,000円/1バイアル、臨床用株は100,000円/1バイアルで提供されます。

このように、臨床用株と研究用株は製造環境や用途に違いがあり、営利機関に対する提供料も異なります。

   

Q. センダイウイルスを用いて、ヒト末梢血からiPS細胞を作製する手順を教えてください。

A. 
センダイウイルスを用いてヒト末梢血からiPS細胞を作製する手順は以下の通りです。
使用する試薬と培地

    サイトカイン: SCF, FLT-3, TPO, IL6(Thermo Fisher Scientific製)
    PBMC用培地: StemPro-34 SFMにGlutaMAX™ Supplementを添加
    iPSC培地: StemFit AK02N(味の素ヘルシーサプライ株式会社製)
    細胞培養基質: iMatrix-511(ニッピ製)

手順
Day -4: 細胞準備

    凍結されたPBMCを37℃のウォーターバスで解凍。
    細胞をPBMC CM培地に懸濁し、遠心分離(200 x g, 25℃, 3分)。
    上清を除去し、細胞をPBMC CM培地に再懸濁して細胞数を調整。
    細胞を6-ウェルプレートに播種し、37℃、5%CO₂のインキュベーターで培養。

Day 0: SeV感染

    細胞を新しいチューブに回収し、PBMC CM培地を追加して遠心分離。
    上清を除去し、CytoTune®-iPS 2.0LをMOI=5で添加。
    細胞を6-ウェルプレートに播種し、インキュベーターで培養。

Day 1: Cytokine除去

    細胞を新しいチューブに回収し、PBMC培地を追加して遠心分離。
    上清を除去し、iMatrix-511を添加して混合。
    細胞をT25フラスコに播種し、インキュベーターで培養。

Day 3, 4~: 培地交換

    フラスコの培地を除去し、iPSC培地を添加。
    インキュベーターで培養し、以降は毎日培地交換。

この手順により、ヒト末梢血からiPS細胞を誘導することができます。センダイウイルスベクターを使用することで、染色体に組み込まれることなく、効率的に遺伝子を導入できます。

   

Q. センダイウイルスを用いて、ヒト末梢血からiPS細胞を作製する際に使用する材料は何ですか?

A. 
センダイウイルスを用いてヒト末梢血からiPS細胞を作製する際に使用する材料は以下の通りです:

    サイトカイン(PBMC用の試薬):
        Human SCF Recombinant Protein (SCF)
        FLT-3 Ligand Recombinant Human Protein (FLT-3)
        TPO (Thrombopoietin), Recombinant Human Protein (TPO)
        IL6 Recombinant Human Protein (IL6)

    PBMC用培地:
        StemPro-34 SFM
        GlutaMAX™ Supplement

    PBMC用完全培地(PBMC CM培地):
        PBMC培地
        SCF
        FLT-3
        TPO
        IL6

    iPSC培地:
        StemFit AK02N

    細胞培養基質:
        iMatrix-511

これらの材料を用いて、末梢血単核球(PBMC)からiPS細胞を誘導するプロセスが行われます。

   

生成AIチャットのリンク(自分用)
iPS細胞・再生医療の生成AIチャット

   

faiss_service 補足(コンテンツエディター、使い方)

 

コンテンツエディター

 

以下の記事で、生成AIのための RAG(faiss_service.py)を作成しました。

sato-7411.hatenadiary.jp

faiss_service 補足

 

今回は、登録済みのコンテンツ(テキスト)を確認・編集するためのウェブページを作成しました。[保存して更新]ボタンを押下すると、faiss_service の delete と add を呼び出します(Faiss を使用しているため、登録済みの内容は変更ではなく、再登録します)。
 


 

 

pip install flask psycopg2 requests

mkdir content_editor
cd content_editor
nano app.py

from flask import Flask, render_template, request, redirect, url_for, flash
import psycopg2
import psycopg2.extras
import uuid
import requests
import os

app = Flask(__name__)
app.secret_key = "change-this-secret"

DB_CONFIG = {
    "host": "localhost",
    "port": 5432,
    "dbname": "webmaster",
    "user": "webmaster",
    "password": os.environ.get("PSQL_DB_PASSWORD"),
}

FAISS_BASE_URL = "http://127.0.0.1:8000"


def get_conn():
    return psycopg2.connect(**DB_CONFIG)


def normalize_content(content: str) -> str:
    if content is None:
        return ""
    return (
        content.replace("\r\n", "\n")
               .replace("\\", "\\\\")
               .replace("\"", "\\\"")
               .replace("\t", "    ")
    )


def faiss_delete_document(doc_id: str):
    url = f"{FAISS_BASE_URL}/delete"
    resp = requests.post(url, json={"id": doc_id}, timeout=60)
    return resp.ok, resp.text


def faiss_add_document(knowledge_id, title, content, metadata):
    url = f"{FAISS_BASE_URL}/add"
    payload = {
        "knowledge_id": knowledge_id,
        "content": content,
        "title": title,
        "metadata": metadata or {}
    }
    resp = requests.post(url, json=payload, timeout=120)
    return resp.ok, resp.json() if resp.ok else resp.text


@app.route("/")
def index():
    selected_knowledge_id = request.args.get("knowledge_id", "").strip()

    with get_conn() as conn:
        with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
            cur.execute("""
                SELECT DISTINCT knowledge_id
                FROM knowledge_documents
                ORDER BY knowledge_id
            """)
            knowledge_ids = cur.fetchall()

            if selected_knowledge_id:
                cur.execute("""
                    SELECT id, knowledge_id, title, created_at
                    FROM knowledge_documents
                    WHERE knowledge_id = %s
                    ORDER BY created_at DESC
                """, (selected_knowledge_id,))
            else:
                cur.execute("""
                    SELECT id, knowledge_id, title, created_at
                    FROM knowledge_documents
                    ORDER BY created_at DESC
                """)
            docs = cur.fetchall()

    return render_template(
        "index.html",
        docs=docs,
        knowledge_ids=knowledge_ids,
        selected_knowledge_id=selected_knowledge_id
    )


@app.route("/edit/<doc_id>", methods=["GET", "POST"])
def edit_document(doc_id):
    selected_knowledge_id = request.args.get("knowledge_id", "").strip()

    try:
        doc_uuid = uuid.UUID(doc_id)
    except ValueError:
        flash("ID の形式が不正です。", "danger")
        return redirect(url_for("index", knowledge_id=selected_knowledge_id))

    if request.method == "POST":
        title = request.form.get("title", "").strip()
        content = normalize_content(request.form.get("content", ""))

        with get_conn() as conn:
            with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
                cur.execute("""
                    SELECT id, knowledge_id, title, content, metadata
                    FROM knowledge_documents
                    WHERE id = %s
                """, (str(doc_uuid),))
                doc = cur.fetchone()

        if not doc:
            flash("対象データが見つかりません。", "danger")
            return redirect(url_for("index", knowledge_id=selected_knowledge_id))

        knowledge_id = doc["knowledge_id"]
        metadata = doc["metadata"]

        ok, msg = faiss_delete_document(str(doc_uuid))
        if not ok:
            flash("delete に失敗しました。", "danger")
            return redirect(url_for(
                "edit_document",
                doc_id=doc_id,
                knowledge_id=selected_knowledge_id
            ))

        ok, res = faiss_add_document(knowledge_id, title, content, metadata)
        if not ok:
            flash("add に失敗しました。", "danger")
            return redirect(url_for("index", knowledge_id=selected_knowledge_id))

        new_id = res["id"]

        flash("更新しました。", "success")
        return redirect(url_for(
            "edit_document",
            doc_id=new_id,
            knowledge_id=selected_knowledge_id
        ))

    with get_conn() as conn:
        with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
            cur.execute("""
                SELECT id, knowledge_id, title, content, metadata, created_at
                FROM knowledge_documents
                WHERE id = %s
            """, (str(doc_uuid),))
            doc = cur.fetchone()

    if not doc:
        flash("対象データが見つかりません。", "danger")
        return redirect(url_for("index", knowledge_id=selected_knowledge_id))

    return render_template(
        "edit.html",
        doc=doc,
        selected_knowledge_id=selected_knowledge_id
    )


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000, debug=True)

 
mkdir templates
nano templates/index.html

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <title>knowledge_documents 一覧</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    body {
      font-family: sans-serif;
      max-width: 1100px;
      margin: 20px auto;
      padding: 0 12px;
    }
    table {
      width: 100%;
      border-collapse: collapse;
    }
    th, td {
      border: 1px solid #ccc;
      padding: 8px;
      text-align: left;
      vertical-align: top;
    }
    a.button {
      display: inline-block;
      padding: 6px 10px;
      border: 1px solid #333;
      text-decoration: none;
      color: #000;
    }
    .flash {
      padding: 10px;
      margin-bottom: 10px;
      border: 1px solid #ccc;
      background: #f8f8f8;
    }
    .filter-box {
      margin-bottom: 12px;
    }
    select {
      padding: 4px 6px;
    }
  </style>
</head>
<body>
  <h1>knowledge_documents 一覧</h1>

  {% with messages = get_flashed_messages(with_categories=true) %}
    {% if messages %}
      {% for category, message in messages %}
        <div class="flash">{{ message }}</div>
      {% endfor %}
    {% endif %}
  {% endwith %}

  <form method="get" action="{{ url_for('index') }}" class="filter-box">
    <b>knowledge_id:</b>

    <select name="knowledge_id">
      <option value="">すべて</option>
      {% for row in knowledge_ids %}
        <option value="{{ row.knowledge_id }}"
          {% if selected_knowledge_id == row.knowledge_id %}selected{% endif %}>
          {{ row.knowledge_id }}
        </option>
      {% endfor %}
    </select>

    <button type="submit">絞り込み</button>

    <a class="button" href="{{ url_for('index') }}">解除</a>

    <span style="float:right;">
      {{ docs|length }} 件&nbsp;&nbsp;
    </span>
  </form>

  <table>
    <thead>
      <tr>
        <th>ID</th><th>knowledge_id</th><th>title</th><th>created_at</th><th>操作</th>
      </tr>
    </thead>
    <tbody>
      {% for doc in docs %}
      <tr>
        <td>{{ doc.id }}</td>
        <td>{{ doc.knowledge_id }}</td>
        <td>{{ doc.title }}</td>
        <td>{{ doc.created_at }}</td>
        <td>
          <a class="button"
             href="{{ url_for('edit_document', doc_id=doc.id, knowledge_id=selected_knowledge_id) }}">編集</a>
        </td>
      </tr>
      {% endfor %}
    </tbody>
  </table>
</body>
</html>

 
nano templates/edit.html

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <title>ドキュメント編集</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    body {
      font-family: sans-serif;
      max-width: 1100px;
      margin: 20px auto;
      padding: 0 12px;
    }
    label {
      display: block;
      font-weight: bold;
      margin-top: 14px;
      margin-bottom: 6px;
    }
    input[type="text"], textarea {
      width: 100%;
      box-sizing: border-box;
      padding: 8px;
      font-family: monospace;
    }
    textarea {
      min-height: 420px;
      line-height: 1.4;
      white-space: pre-wrap;
    }
    .row {
      margin-bottom: 10px;
    }
    .meta {
      background: #f7f7f7;
      border: 1px solid #ccc;
      padding: 10px;
      white-space: pre-wrap;
      font-family: monospace;
    }
    .actions {
      margin-top: 16px;
    }
    button, a.button {
      display: inline-block;
      padding: 8px 14px;
      border: 1px solid #333;
      background: #fff;
      text-decoration: none;
      color: #000;
      cursor: pointer;
    }
    .flash {
      padding: 10px;
      margin-bottom: 10px;
      border: 1px solid #ccc;
      background: #f8f8f8;
    }
  </style>
</head>
<body>
  <h1>ドキュメント編集</h1>

  {% with messages = get_flashed_messages(with_categories=true) %}
    {% if messages %}
      {% for category, message in messages %}
        <div class="flash">{{ message }}</div>
      {% endfor %}
    {% endif %}
  {% endwith %}

  <div class="row"><b>ID:</b> {{ doc.id }}</div>
  <div class="row"><b>knowledge_id:</b> {{ doc.knowledge_id }}</div>
  <div class="row"><b>created_at:</b> {{ doc.created_at }}</div>

  <form method="post">
    <label for="title">title</label>
    <input type="text" id="title" name="title" value="{{ doc.title }}">

    <label for="content">content</label>
    <textarea id="content" name="content">{{ doc.content }}</textarea>

    <label>metadata</label>
    <div class="meta">{{ doc.metadata | tojson(indent=2) }}</div>

    <div class="actions">
      <button type="submit">保存して更新</button>
      <a class="button" href="{{ url_for('index', knowledge_id=selected_knowledge_id) }}">戻る</a>
    </div>
  </form>
</body>
</html>

 

[ 実行 ]
export PSQL_DB_PASSWORD="******"
python app.py
 

[ 表示 ]
http://localhost:5000/
または、
http://192.168.1.13:5000/
(他のPCからIPアドレスを指定)

 

今の FAISS の使い方

今は文書の埋め込み(Embedding)に OpenAI API を使用しているため、1回あたりの最大文字数は 8192 文字です。

元の文書をチャンクに分割するわけですが、意味の通る文書にするため、手作業にはなってしまうものの、章・節・ページなど、区切りの良いところで分割しています。また、分割後の各チャンクには、文書名や著者名を追加しています。

実際には1チャンクあたり2000~3000文字程度を目安にしています。短い場合は500文字程度(小さく分割すると効率が悪い)、最初の分割で6000~7000文字程度になった場合は、適切な区切りでさらに2つ以上に分割しています。

分割した文書は、それぞれ OpenAI API により3072次元の数値ベクトルへ変換されます(text-embedding-3-large を使用しているため)。

知識検索では、適度な得点しきい値(今は0.25)を設定し、検索件数は3件にしています。

ユーザーが入力した質問文に対して類似検索を行い、関連する文書が3件見つかった場合は、それらの文書を参照情報として LLM に(ユーザーの質問文と共に)渡し、回答を生成します。(実際は、質問文に対して補完や拡張を行う。こちらを参照。)

そのため、知識検索で取得された文書が、意味の通らない文書だと、LLM が正しく回答できなくなる可能性があるため、このような方法を採用しています。

 

AIチャットから担当者につなぐ(Laravel、Dify)

はじめに

生成AIのチャット(Dify)で何度かやり取りした後に、担当者につないで、人と直接やり取りできるようにするためのインターフェースを作成します。

既存の Laravel に、生成AI向けのチャットUIを追加します。DB は MariaDB(MySQL)を使用しています。

3回対話すると、[担当者に繋ぐ]ボタンが表示されます。オペレーターが操作している画面に会話内容が表示され、チャット中のユーザーとやり取りできるようになります。  
 

 

 

 

 

 

 

前提: Dify 側の handoff 合図

Dify(チャットアプリ or チャットフロー)の「担当者へ切替を提案する返答」には、末尾などに固定マーカーを付けてください:

・[[HANDOFF]]

 
Laravel 側がこれを検知して、顧客UIに「担当者に繋ぐ」ボタンを出します。

 

1) Laravel 作成 & DB 設定

nano .env

# Dify
DIFY_API_BASE=https://bunkyo-ai.homelinux.com/v1
DIFY_API_KEY=YOUR_DIFY_API_KEY
DIFY_RESPONSE_MODE=streaming
DIFY_HANDOFF_MARKER=[[HANDOFF]]

 
Dify のベース URL と API キー(下図の右上)は、[APIアクセス]ページから取得します。 ベース URL の「/v1」は除きます。 http は https にします。
 

 

2) マイグレーション

2.1 conversations

php artisan make:migration create_conversations_table --create=conversations
nano 2026_03_27_xxxxxx_create_conversations_table.php

<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void {
        Schema::create('conversations', function (Blueprint $table) {
            $table->id();
            $table->string('app_session_id', 64)->unique(); // ブラウザ側セッションID
            $table->enum('mode', ['bot','human'])->default('bot');
            $table->string('dify_conversation_id', 128)->nullable()->index();
            $table->timestamp('last_user_at')->nullable();
            $table->timestamp('last_operator_at')->nullable();
            $table->timestamps();
        });
    }
    public function down(): void {
        Schema::dropIfExists('conversations');
    }
};

 

2.2 messages

php artisan make:migration create_messages_table --create=messages
nano 2026_03_27_xxxxxx_create_messages_table.php

<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void {
        Schema::create('messages', function (Blueprint $table) {
            $table->id();
            $table->foreignId('conversation_id')->constrained()->cascadeOnDelete();
            $table->enum('role', ['user','bot','operator','system'])->index();
            $table->longText('content');
            $table->timestamps();

            $table->index(['conversation_id','id']);
        });
    }
    public function down(): void {
        Schema::dropIfExists('messages');
    }
};

 
マイグレーション実行
php artisan migrate

 

3) モデル

php artisan make:model Conversation
nano app/Models/Conversation.php

<?php
namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Conversation extends Model
{
    use HasFactory;
    
    protected $fillable = [
        'app_session_id','mode','dify_conversation_id','last_user_at','last_operator_at'
    ];

    public function messages() {
        return $this->hasMany(Message::class);
    }
}

 
php artisan make:model Message
nano app/Models/Message.php

<?php
namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Message extends Model
{
    use HasFactory;
    
    protected $fillable = ['conversation_id','role','content'];

    public function conversation() {
        return $this->belongsTo(Conversation::class);
    }
}

 

4) Dify 設定(config/services.php)

config/services.php に追加:

'dify' => [
    'base' => env('DIFY_API_BASE', 'https://bunkyo-ai.homelinux.com/v1'),
    'key'  => env('DIFY_API_KEY'),
    'response_mode' => env('DIFY_RESPONSE_MODE', 'streaming'),
    'handoff_marker' => env('DIFY_HANDOFF_MARKER', '[[HANDOFF]]'),
],

 

5) ルート

nano routes/web.php

<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\ChatPageController;
use App\Http\Controllers\OperatorPageController;

// 顧客チャット画面
Route::get('/chat', [ChatPageController::class, 'index'])->name('chat.index');

Route::middleware(['auth', 'can:operator'])->group(function () {
    // 担当者トップ(一覧へリダイレクトでもOK)
    Route::get('/chat/operator', function () {
        return redirect()->route('chat.operator.inbox');
    })->name('chat.operator.home');
        
    // 対応待ち一覧
    Route::get('/chat/operator/inbox', [OperatorPageController::class, 'inbox'])
        ->name('chat.operator.inbox');

    // 会話詳細
    Route::get('/chat/operator/conversations/{conversation}', [OperatorPageController::class, 'show'])
        ->name('chat.operator.conversations.show');
});

 
nano routes/api.php

<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\ChatApiController;
use App\Http\Controllers\OperatorApiController;

Route::post('/chat/send', [ChatApiController::class, 'send']);
Route::get('/chat/poll', [ChatApiController::class, 'poll']);
Route::post('/chat/handoff', [ChatApiController::class, 'handoff']);
Route::post('/chat/back-to-bot', [ChatApiController::class, 'backToBot']);

Route::middleware(['web', 'auth', 'can:operator'])->group(function () {
    Route::get('/operator/queue', [OperatorApiController::class, 'queue']);
    Route::get('/operator/messages', [OperatorApiController::class, 'messages']);
    Route::post('/operator/reply', [OperatorApiController::class, 'reply']);
});

 
Laravel 12 の場合、api.php を読み込むために、次を変更します。(Laravel 10 では不要)

nano bootstrap/app.php

->withRouting(
    web: __DIR__.'/../routes/web.php',
    api: __DIR__.'/../routes/api.php',
)

 
 

6) コントローラー(ページ)

php artisan make:controller ChatPageController
nano app/Http/Controllers/ChatPageController.php

<?php
namespace App\Http\Controllers;

use Illuminate\Http\Request;

class ChatPageController extends Controller
{
    public function index()
    {
        return view('chat.index');
    }
}

 
php artisan make:controller OperatorPageController
nano app/Http/Controllers/OperatorPageController.php

<?php
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Models\Conversation;

class OperatorPageController extends Controller
{
    // 対応待ち一覧
    public function inbox()
    {
        $conversations = Conversation::where('mode', 'human')
            ->orderByDesc('updated_at')
            ->get();

        return view('chat.operator.inbox', compact('conversations'));
    }

    // 会話詳細
    public function show(Conversation $conversation)
    {
        $messages = $conversation->messages()
            ->orderBy('id', 'asc')
            ->get();

        return view('chat.operator.show', compact('conversation', 'messages'));
    }
}

 

7) コントローラー(API:中継+handoff)

7.1 ChatApiController(Dify の blocking と streaming で共通)

最初に表示されるメッセージ(オープナー)をここに記述しています。

php artisan make:controller ChatApiController
nano app/Http/Controllers/ChatApiController.php

<?php
namespace App\Http\Controllers;

use App\Models\Conversation;
use App\Models\Message;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;

class ChatApiController extends Controller
{
    private function getOrCreateConversation(Request $request): Conversation
    {
        $sid = $request->header('X-App-Session-Id');
        if (!$sid) abort(400, 'Missing X-App-Session-Id');

        $conv = Conversation::firstOrCreate(
            ['app_session_id' => $sid],
            ['mode' => 'bot']
        );

        // 初回作成時のみオープナーを入れる
        if ($conv->wasRecentlyCreated) {
            Message::create([
                'conversation_id' => $conv->id,
                'role' => 'system',   // または 'bot'
                'content' => 'こんにちは。ご相談内容を入力してください。担当者に繋ぐこともできます。',
            ]);
        }

        return $conv;
    }

    public function send(Request $request)
    {
        $request->validate([
            'text' => 'required|string|max:8000',
        ]);

        $conv = $this->getOrCreateConversation($request);

        Message::create([
            'conversation_id' => $conv->id,
            'role' => 'user',
            'content' => $request->text,
        ]);
        $conv->update(['last_user_at' => now()]);

        // humanモードならDifyは呼ばない(担当者返信待ち)
        if ($conv->mode === 'human') {
            return response()->json([
                'mode' => 'human',
                'reply' => null,
                'handoff_available' => false,
            ]);
        }

        $base = rtrim(config('services.dify.base'), '/');
        $payload = [
            'query' => $request->text,
            'inputs' => (object)[],
            'user' => $conv->app_session_id,
            'response_mode' => config('services.dify.response_mode', 'blocking'),
        ];
        if ($conv->dify_conversation_id) {
            $payload['conversation_id'] = $conv->dify_conversation_id;
        }

        // --- Dify request ---
        try {
            $res = Http::withToken(config('services.dify.key'))
                ->acceptJson()
                ->connectTimeout(10)
                ->timeout(180) // workflowだと長めが必要
                ->post($base . '/chat-messages', $payload);
        } catch (\Throwable $e) {
            Message::create([
                'conversation_id' => $conv->id,
                'role' => 'system',
                'content' => 'Dify request exception: ' . $e->getMessage(),
            ]);
            return response()->json(['error' => 'Dify request failed'], 502);
        }

        if (!$res->ok()) {
            Message::create([
                'conversation_id' => $conv->id,
                'role' => 'system',
                'content' => 'Dify error: ' . $res->status() . ' ' . $res->body(),
            ]);
            return response()->json(['error' => 'Dify request failed'], 502);
        }

        $contentType = strtolower((string) $res->header('Content-Type'));
        $answer = '';
        $difyConversationId = null;

        if (str_contains($contentType, 'text/event-stream')) {
            $raw = (string) $res->body();

            $buffer = '';           // message断片を連結
            $finalAnswer = null;    // workflow_finished の answer

            foreach (preg_split("/\r\n|\n|\r/", $raw) as $line) {
                $line = trim($line);
                if (!str_starts_with($line, 'data:')) continue;

                $jsonLine = trim(substr($line, 5));
                if ($jsonLine === '') continue;

                $obj = json_decode($jsonLine, true);
                if (!is_array($obj)) continue;

                if (!empty($obj['conversation_id'])) {
                    $difyConversationId = $obj['conversation_id'];
                }

                $ev = (string)($obj['event'] ?? '');

                // 途中断片(複数回届く)
                if ($ev === 'message') {
                    $piece = (string)($obj['answer'] ?? '');
                    if ($piece !== '') {
                        $buffer .= $piece;
                    }
                }

                // 最終結果(Workflowの最終回答がここに入る)
                if ($ev === 'workflow_finished') {
                    $maybe = $obj['data']['outputs']['answer'] ?? null;
                    if (is_string($maybe) && $maybe !== '') {
                        $finalAnswer = $maybe;
                    }
                }
            }

            // 最終回答を優先。なければ断片連結を使う
            $answer = $finalAnswer ?? $buffer;
        }

        // conversation_id 保存
        if ($difyConversationId) {
            $conv->dify_conversation_id = $difyConversationId;
            $conv->save();
        }

        // handoff marker 処理
        $answer = trim($answer);
        $marker = config('services.dify.handoff_marker', '[[HANDOFF]]');
        $handoff = Str::contains($answer, $marker);
        if ($handoff) $answer = trim(str_replace($marker, '', $answer));

        Message::create([
            'conversation_id' => $conv->id,
            'role' => 'bot',
            'content' => $answer,
        ]);

        return response()->json([
            'mode' => 'bot',
            'reply' => $answer,
            'handoff_available' => $handoff,
        ]);
    }

    public function handoff(Request $request)
    {
        $conv = $this->getOrCreateConversation($request);

        if ($conv->mode !== 'human') {
            $conv->mode = 'human';
            $conv->save();

            Message::create([
                'conversation_id' => $conv->id,
                'role' => 'system',
                'content' => 'Switched to human operator.',
            ]);
        }

        return response()->json(['ok' => true, 'mode' => 'human']);
    }

    public function backToBot(Request $request)
    {
        $conv = $this->getOrCreateConversation($request);

        if ($conv->mode !== 'bot') {
            $conv->mode = 'bot';
            $conv->save();

            Message::create([
                'conversation_id' => $conv->id,
                'role' => 'system',
                'content' => 'Switched back to AI.',
            ]);
        }

        return response()->json(['ok' => true, 'mode' => 'bot']);
    }

    public function poll(Request $request)
    {
        $conv = $this->getOrCreateConversation($request);

        $messages = Message::where('conversation_id', $conv->id)
            ->orderBy('id', 'asc')
            ->get(['id','role','content','created_at']);

        return response()->json([
            'mode' => $conv->mode,
            'messages' => $messages,
        ]);
    }
}

 

7.2 OperatorApiController

php artisan make:controller OperatorApiController
nano app/Http/Controllers/OperatorApiController.php

<?php
namespace App\Http\Controllers;

use App\Models\Conversation;
use App\Models\Message;
use Illuminate\Http\Request;

class OperatorApiController extends Controller
{
    public function queue()
    {
        $list = Conversation::where('mode', 'human')
            ->orderByDesc('updated_at')
            ->get(['id','app_session_id','last_user_at','last_operator_at','updated_at']);

        return response()->json(['conversations' => $list]);
    }

    public function messages(Request $request)
    {
        $request->validate([
            'conversation_id' => 'required|integer',
        ]);

        $cid = (int)$request->conversation_id;

        $messages = Message::where('conversation_id', $cid)
            ->orderBy('id', 'asc')
            ->get(['id','role','content','created_at']);

        return response()->json(['messages' => $messages]);
    }

    public function reply(Request $request)
    {
        $request->validate([
            'conversation_id' => 'required|integer|exists:conversations,id',
            'text' => 'required|string|max:8000',
        ]);

        $conv = Conversation::findOrFail((int)$request->conversation_id);

        // humanモードでなければhumanに(運用上便利)
        if ($conv->mode !== 'human') {
            $conv->mode = 'human';
            $conv->save();
        }

        Message::create([
            'conversation_id' => $conv->id,
            'role' => 'operator',
            'content' => $request->text,
        ]);
        $conv->update(['last_operator_at' => now()]);

        return response()->json(['ok' => true]);
    }
}

 

8) ビュー(Blade+JS)

8.1 顧客チャット画面

mkdir resources/views/chat
nano resources/views/chat/index.blade.php

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8"/>
  <meta name="viewport" content="width=device-width,initial-scale=1"/>
  <title>Chat</title>
  <style>
    body{font-family:system-ui,sans-serif;max-width:900px;margin:20px auto;padding:0 12px;}
    #log{border:1px solid #ddd;padding:12px;height:170px;overflow:auto;background:#fafafa;}
    .m{margin:8px 0;}
    .role{font-weight:700;margin-right:6px;}
    #row{display:flex;gap:8px;margin-top:10px;}
    input{flex:1;padding:10px;}
    button{padding:10px 14px;}
    #actions{display:flex;gap:8px;margin-top:10px;}
    #handoffBtn{display:none;}
    #backBtn{display:none;}

    @media (min-height: 500px){
      #log{ height:300px; }
    }  
  </style>
</head>
<body>
<h1>チャット</h1>

<div id="log"></div>

<div id="actions">
  <button id="handoffBtn">担当者に繋ぐ</button>
  <button id="backBtn">AIに戻す</button>
  <button id="clearBtn">チャットをクリア</button>
</div>

<div id="row">
  <input id="text" placeholder="メッセージ…" />
  <button id="send">送信</button>
</div>

<script>
  const sidKey = 'app_session_id';
  let sid = localStorage.getItem(sidKey);
  if (!sid) {
    sid = crypto.randomUUID();
    localStorage.setItem(sidKey, sid);
  }

  const log = document.getElementById('log');
  const text = document.getElementById('text');
  const sendBtn = document.getElementById('send');
  const handoffBtn = document.getElementById('handoffBtn');
  const backBtn = document.getElementById('backBtn');
  const clearBtn = document.getElementById('clearBtn');

  function escapeHtml(s){
    return (s ?? '').replace(/[&<>"']/g, c => ({
      '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
    }[c]));
  }

  function append(role, content){
    const div = document.createElement('div');
    div.className = 'm';
    div.innerHTML = `<span class="role">${role}:</span><span>${escapeHtml(content)}</span>`;
    log.appendChild(div);
    log.scrollTop = log.scrollHeight;
  }

  async function poll(){
    const r = await fetch('/api/chat/poll', {
      headers: {'X-App-Session-Id': sid}
    });
    const j = await r.json();

    log.innerHTML = '';
    j.messages.forEach(m => append(m.role, m.content));

    if (j.mode === 'human') {
      handoffBtn.style.display = 'none';
      backBtn.style.display = 'inline-block';
    } else {
      backBtn.style.display = 'none';
    }
  }

  async function send(){
    const v = text.value.trim();
    if (!v) return;
    text.value = '';

    const r = await fetch('/api/chat/send', {
      method:'POST',
      headers:{
        'Content-Type':'application/json',
        'X-App-Session-Id': sid
      },
      body: JSON.stringify({text: v})
    });

    const j = await r.json();

    if (j.handoff_available)
      handoffBtn.style.display = 'inline-block';

    await poll();
  }

  async function handoff(){
    await fetch('/api/chat/handoff', {
      method:'POST',
      headers:{'X-App-Session-Id': sid}
    });

    handoffBtn.style.display = 'none';
    await poll();
    append('system', '担当者に切り替えました。担当者の返信をお待ちください。');
  }

  async function backToBot(){
    await fetch('/api/chat/back-to-bot', {
      method:'POST',
      headers:{'X-App-Session-Id': sid}
    });

    await poll();
    append('system', 'AIに戻しました。');
  }

  // チャットクリア処理
  function clearChat(){
    if (!confirm('チャット履歴をリセットします。よろしいですか?')) return;

    localStorage.removeItem(sidKey);
    location.reload();
  }

  sendBtn.onclick = send;
  text.addEventListener('keydown', e => {
    if (e.key === 'Enter') send();
  });
  handoffBtn.onclick = handoff;
  backBtn.onclick = backToBot;
  clearBtn.onclick = clearChat;

  poll();
  setInterval(poll, 2000);
</script>
</body>
</html>

 

8.2 担当者操作画面

mkdir resources/views/chat/operator
nano resources/views/chat/operator/inbox.blade.php

@extends('layouts.main')

@section('title', 'Operator')

@section('content')

<meta name="csrf-token" content="{{ csrf_token() }}">

<style>
  .op-wrap{max-width:1200px;margin:20px auto;padding:0 12px;display:flex;gap:14px;}
  .op-left{width:360px;}
  .op-right{flex:1;}
  #queue{border:1px solid #ddd;padding:10px;height:75vh;overflow:auto;}
  #log{border:1px solid #ddd;padding:10px;height:60vh;overflow:auto;background:#fafafa;}
  .item{padding:8px;border-bottom:1px solid #eee;cursor:pointer;}
  .item:hover{background:#f3f3f3;}
  .sel{background:#e9f2ff;}
  #row{display:flex;gap:8px;margin-top:10px;}
  #row input{flex:1;padding:10px;}
  #row button{padding:10px 14px;}
</style>

<div class="op-wrap">
  <div class="op-left">
    <h2>対応中(human)</h2>
    <div id="queue"></div>
  </div>

  <div class="op-right">
    <h2>会話</h2>
    <div id="log"></div>

    <div id="row">
      <input id="text" placeholder="担当者返信…" />
      <button id="send">送信</button>
    </div>
  </div>
</div>

<script>
  let currentId = null;
  const queue = document.getElementById('queue');
  const log = document.getElementById('log');
  const text = document.getElementById('text');
  const sendBtn = document.getElementById('send');

  function escapeHtml(s){
    return (s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
  }
  function append(role, content){
    const div = document.createElement('div');
    div.style.margin = '8px 0';
    div.innerHTML = `<b>${escapeHtml(role)}:</b> ${escapeHtml(content)}`;
    log.appendChild(div);
    log.scrollTop = log.scrollHeight;
  }

  async function loadQueue(){
    const r = await fetch('/api/operator/queue', {credentials: 'same-origin'});
    if (!r.ok) {
      // 401/403 などの場合に気づけるように
      queue.innerHTML = `<div style="padding:8px;color:#b00;">queue取得失敗(${r.status})</div>`;
      return;
    }
    const j = await r.json();
    queue.innerHTML = '';

    (j.conversations ?? []).forEach(c => {
      const d = document.createElement('div');
      d.className = 'item' + (c.id === currentId ? ' sel' : '');
      const shortSid = (c.app_session_id || '').slice(0, 8);
      d.textContent = `#${c.id}  ${shortSid}…`;
      d.onclick = async () => {
        currentId = c.id;
        await loadMessages();
        await loadQueue();
      };
      queue.appendChild(d);
    });

    if ((j.conversations ?? []).length === 0) {
      queue.innerHTML = `<div style="padding:8px;color:#666;">対応中の会話がありません</div>`;
    }
  }

  async function loadMessages(){
    if (!currentId) { log.innerHTML=''; return; }

    const r = await fetch('/api/operator/messages?conversation_id=' + encodeURIComponent(currentId), {
      credentials: 'same-origin'
    });
    if (!r.ok) {
      log.innerHTML = `<div style="padding:8px;color:#b00;">messages取得失敗(${r.status})</div>`;
      return;
    }

    const j = await r.json();
    log.innerHTML = '';
    (j.messages ?? []).forEach(m => append(m.role, m.content));
  }

  async function reply(){
    if (!currentId) { alert('会話を選んでください'); return; }

    const v = text.value.trim();
    if (!v) return;
    text.value = '';

    const csrf = document.querySelector('meta[name="csrf-token"]').getAttribute('content');

    const r = await fetch('/api/operator/reply', {
      method:'POST',
      credentials: 'same-origin',
      headers:{
        'Content-Type':'application/json',
        'X-CSRF-TOKEN': csrf
      },
      body: JSON.stringify({ conversation_id: currentId, text: v })
    });

    if (!r.ok) {
      alert('送信に失敗しました(' + r.status + ')');
      return;
    }

    await loadMessages();
  }

  sendBtn.onclick = reply;
  text.addEventListener('keydown', e => { if (e.key === 'Enter') reply(); });

  loadQueue();
  setInterval(async () => {
    await loadQueue();
    await loadMessages();
  }, 2000);
</script>

@endsection

 

8.3 会話詳細画面

nano resources/views/chat/operator/show.blade.php

@extends('layouts.main')

@section('title', '会話詳細')

@section('content')

<h3>会話詳細(ID: {{ $conversation->id }})</h3>

<div class="border rounded p-3 mb-4 bg-light">
    @forelse ($messages as $message)
        <div class="mb-2">
            <strong>{{ $message->role }}:</strong>
            <span>{{ $message->content }}</span>
        </div>
    @empty
        <div class="text-muted">メッセージはまだありません。</div>
    @endforelse
</div>

<div class="mt-3">
    <a href="{{ route('chat.operator.inbox') }}" class="btn btn-secondary">
        一覧へ戻る
    </a>
</div>

@endsection

 

9) Laravel の Gate(認可ルール)

オペレーターの操作は、特定のメールアドレスでログインしたユーザーのみに許可します。

boot() メソッドに追加
nano app/Providers/AppServiceProvider.php
(Laravel 10 の場合は、app/Providers/AuthServiceProvider.php)

use Illuminate\Support\Facades\Gate;

public function boot(): void
{
    Gate::define('operator', function ($user) {
        return in_array($user->email, [
            'admin@example.com',
            'support@example.com',
        ]);
    });
}

 
(真ん中の 'verified' はメール認証が完了しているかどうかを示します。オペレーター権限は社内用のため、指定は不要です。)

web.php の場合

->middleware(['auth', 'verified', 'can:operator'])
または、
->middleware(['auth', 'can:operator'])

 
api.php の場合

->middleware(['web', 'auth', 'can:operator'])

 
Laravel は abort(403) や can ミドルウェアで拒否された場合、errors/403.blade.php ファイルがあれば自動で表示します。

mkdir resources/views/errors
nano resources/views/errors/403.blade.php

@extends('layouts.main')

@section('title', '権限がありません')

@section('content')
<div class="container text-center mt-5">
    <h1>403</h1>
    <p>このページにアクセスする権限がありません。</p>
    <a href="{{ route('product') }}" class="btn btn-primary mt-3">
        製品へ戻る
    </a><br>
    <br>
</div>
@endsection

 

10) 起動

php artisan serve

 

11) Dify で [[HANDOFF]] を返す

今回は、AIとのやり取りが3回以上行われた場合に、[担当者へ繋ぐ]ボタンを表示するようにします。

最終の[回答]の直前に[コード実行]を追加します。入力1に sys.dialogue_count、入力2に LLM の出力を設定します。sys.dialogue_count > 2 の場合は、LLM の結果に『担当者にお繋ぎします。 [[HANDOFF]]』を追加して出力します。それ以外の場合は、LLM の結果をそのまま出力します。
また、[回答]は[コード実行]の結果を出力するように変更します。

 

 

 

12) 動作確認

顧客UI(オペレーターとしてログインしている必要はありません)
http://127.0.0.1:8000/chat

担当者UI(もう1台のPCなどから、オペレーターとしてログインします)
http://192.168.1.7:8000/chat/operator

会話詳細(ID: 5)
http://192.168.1.7:8000/chat/operator/conversations/5

 
Dify との接続を確認するコマンド(Laravel のサーバー上で実行)。YOUR_DIFY_API_KEY は自分の API キーに置き換えます

blocking モード

curl -i -X POST "https://bunkyo-ai.homelinux.com/v1/chat-messages" -H "Authorization: Bearer YOUR_DIFY_API_KEY" -H "Content-Type: application/json" -d '{ "query":"ping", "inputs":{}, "user":"debug-user", "response_mode":"blocking" }'

 
streaming モードで接続を確認するコマンド。現状の Dify だと streaming モードしか動作しませんでした

streaming モード

curl -N -i -X POST "https://bunkyo-ai.homelinux.com/v1/chat-messages" -H "Authorization: Bearer YOUR_DIFY_API_KEY" -H "Content-Type: application/json" -d '{"query":"ping","inputs":{},"user":"debug-user","response_mode":"streaming"}'

 
チャットで担当者につなぐと、conversations テーブルの mode が bot から human に変化します

「mode = human の会話」だけをJSONで表示します
http://192.168.1.7:8000/api/operator/queue

 

埋め込みチャットを作成する

チャットを表示するウェブページに、下記のコードを追加します。
チャット機能のアドレスを /chat と想定しています。
 
表示位置の調整(#chat-widget-button、#chat-widget-window)
上にずらす → bottom を増やす
右にずらす → right を増やす
 
表示サイズの調整(#chat-widget-window)
width:350px;
height:420px;
 
会話部分サイズの調整(resources/views/chat/index.blade.php)
#log{ height:170px; }
 
表示アイコンの変更
💬
🤖
🎧

 

<!-- チャットボタン -->
<div id="chat-widget-button">🤖</div>

<!-- チャットウィンドウ -->
<div id="chat-widget-window">
  <iframe src="/chat"></iframe>
</div>

<style>
#chat-widget-button{
  position:fixed;
  right:80px;
  bottom:80px;
  width:50px;
  height:50px;
  background:#007bff;
  color:white;
  border-radius:50%;
  display:flex;
  align-items:center;
  justify-content:center;
  font-size:26px;
  cursor:pointer;
  z-index:9999;
}

#chat-widget-window{
  position:fixed;
  right:20px;
  bottom:150px;
  width:350px;
  height:420px;
  background:white;
  border:1px solid #ccc;
  border-radius:10px;
  display:none;
  overflow:hidden;
  z-index:9999;
}

#chat-widget-window iframe{
  width:100%;
  height:100%;
  border:none;
}

@media (min-width: 992px){
  #chat-widget-window{ height:550px; }
}
</style>

<script>
const btn = document.getElementById('chat-widget-button');
const win = document.getElementById('chat-widget-window');

btn.onclick = () => {
  if(win.style.display === 'none' || win.style.display === ''){
    win.style.display = 'block';
  } else {
    win.style.display = 'none';
  }
};
</script>

 

 

以上、

 

フロントエンド&バックエンド

 

はじめに

ホームページを作成するとき、ログインやユーザー管理、ユーザーごとのページ表示、問い合わせ、連絡メールの送信、製品の購入や決済などの機能があると便利です。これらをすべて一人で用意するのは大変ですが、ここでは最初の、環境を構築する部分について記述します。

主な機能
・Laravel
・composer
・Node.js
・Nginx
・php-fpm
・MariaDB (MySQL)

環境が準備できたら、次のような機能を追加することが考えられます。

日本語化
初期ホームページ(home.blade.php)の作成
メール認証、または OTP(ワンタイムパスワード)認証
jQuery、Bootstrap、Tailwind CSS(ホームページの見栄えを良くする)
データ / ユーザー管理
バリデーション(ユーザーが入力した内容のチェック)
ECサイト機能
チャットボット
REST API

 

前提

Nginx および PHP をインストールし、HTTP や HTTPS のデフォルトのウェブページが表示できる
HTTPS を利用するための SSL 証明書を取得し、導入している
MariaDB (MySQL) データベースをインストール済み
Node.js および composer をインストール済み

動作確認は、ノートPCに直接インストールした Ubuntu 24.04.4 で行いました。
管理者または操作するユーザー名は、習慣で webmaster を使用しています。

 

Docker Desktop のインストール

Laravel を使用するにあたり、Docker Desktop があった方が良いので、インストールします。リンク先の手順に従います。

https://docs.docker.com/engine/install/ubuntu/
1. Docker の APT レポジトリの設定
# Add Docker's official GPG key
# Add the repository to Apt sources
2. Docker パッケージのインストール
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
3. hello-world イメージの実行
sudo docker run hello-world
 
https://docs.docker.com/desktop/setup/install/linux/ubuntu/
sudo apt install gnome-terminal
docker-desktop-amd64.deb をダウンロード
sudo apt-get update
sudo apt-get install ./docker-desktop-amd64.deb
再起動
メニューから Development > Docker Desktop で起動。または systemctl --user start docker-desktop で起動
ユーザー登録はせず、skip、skip で Docker Containers の画面が表示される

 

Laravel 用のデータベース設定

sudo apt install mysql-server
sudo mysql

ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'password';  ※ Laravel 用のパスワード
FLUSH PRIVILEGES;
exit

sudo mysql -u root -p

CREATE DATABASE laravel DEFAULT CHARACTER SET = 'utf8mb4';
CREATE DATABASE testdb DEFAULT CHARACTER SET = 'utf8mb4';

 

Laravel インストール

Docker Desktop を起動する
最初はコンテナが何も無い状態。必要ならば Troubleshoot、Reset to factory defaults を行う

ターミナルを起動する

cd ~
mkdir www
cd www

curl -s https://laravel.build/laravel?php=83 | bash  ※ Ubuntu 24.04.4 は PHP 8.3 のため、明示的に指定する
cd laravel
composer require laravel/breeze --dev
php artisan breeze:install blade

PHPデバッグの準備
nano .env
SAIL_XDEBUG_MODE=develop,debug,coverage

./vendor/bin/sail up -d
※ 以降、Decker は sail up したまま
※ ポート 80 と 3306 は Nginx と MySQL で使用しているため未実行になる

ユーザー管理用データベースの準備
nano .env
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=password (←デフォルトのパスワード)

php artisan migrate

npm install
npm run dev

もう1つターミナルを起動する
cd www/laravel
php artisan serve
http://127.0.0.1:8000 で Laravel が表示される

Ctrl+C で一旦終了

/home/webmaster/www/laravel に構築した Laravel を /var/www/laravel に移動する
※ /var/www/laravel では Sail を使用して構築できないため、home ディレクトリで作成した
cd /var
sudo chown -R webmaster www
sudo chgrp -R webmaster www
sudo mv /home/webmaster/www/laravel /var/www/

cd www/laravel
Nginx から書き込み可能にする
sudo chown -R www-data:www-data storage bootstrap/cache
sudo chmod -R 775 storage bootstrap/cache
webmaster も www-data グループに追加する
sudo usermod -aG www-data webmaster

 
Nginx 設定
sudo nano /etc/nginx/sites-available/default

server {
    listen 80;
    root /var/www/html; -> root /var/www/laravel/public;
    index index.html … -> index index.php; ※または行をコピーして最後に追加

    server_name example.com; # あなたのドメイン名に書き換えてください

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

  fastcgi_pass unix:/run/php/php7.4-fpm.sock; -> fastcgi_pass unix:/run/php/php-fpm.sock;

 
次のページを参考。http (80) と https (433) の2か所で行う

www.hiramine.com

 

Nginx を再起動
sudo systemctl restart nginx

 
Git 設定
cd /var/www/laravel
git config --global user.name "xyz"
git config --global user.email "abc@def.com"
git init
.gitignore を作成  ※ 既にある
git add .
git commit -m "Initial commit."

cd /home/webmaster/www
git clone /var/www/laravel  ※ laravel フォルダが作成される
cd laravel
git pull origin master
git checkout -b staging
.env ファイルは手動でコピー
composer install
npm install
npm run build
xx sudo chown -R webmaster storage bootstrap/cach
xx sudo chmod -R 775 storage bootstrap/cache
※ php artisan serve で実行するために所有者を webmaster にする。git pull で作成しているので webmaster になっている

 
~/www/laravel の環境では、テストDBを使用するように設定する
cd ~/www/laravel
nano .env
  DB_DATABASE=testdb
php artisan migrate

必要ならば develop/laravel も作成する
ブランチ作成は git checkout -b develop
一般的に、staging は本番DBのコピーを参照し、develop はテストDBを参照する

 
Laravel 本番側で Vite を使用する場合
php artisan serve --host 192.168.11.15
nanopackage.json
"dev": "vite --host",

 
表示を「ダッシュボード」から「ログイン中...」に変える
resources/views/layouts/navigation.blade.php
'Dashboard' → 'Logging in ...'
lang/ja.json
(追加)"Logging in ...": "ログイン中..."

 
Laravel のロゴを非表示にする
resources/views/components/application-logo.blade.php
内容を以下のみ
{{-- logo hidden --}}

 

初期ホームページ home.blade.php

nano routes/web.php

use App\Http\Controllers\HomeController;

Route::get('/', [HomeController::class, 'index'])->name('home');

 
php artisan make:controller HomeController
nano app/Http/Controllers/HomeController.php

<?php

namespace App\Http\Controllers;

class HomeController extends Controller
{
    public function index()
    {
        return view('home');
    }
}

 
nano resources/views/home.blade.php

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Your Home Page</title>
  <!-- CSSのリンク -->
  <link href="{{ asset('css/app.css') }}" rel="stylesheet">
</head>
<body>
  <div class="container">
    <h1>Welcome to Our Homepage</h1>
    <!-- ホームページのコンテンツ -->
  </div>
  <!-- JavaScriptのリンク -->
  <script src="{{ asset('js/app.js') }}">
  </script>
</body>
</html> 

 

Bootstrap インストール

home.blade.php を一旦バックアップ

https://getbootstrap.com/
参考:https://www.itsolutionstuff.com/post/how-to-use-bootstrap-icons-in-laravel-viteexample.html

composer require laravel/ui --dev
php artisan ui bootstrap (--auth を付けると、いくつかの既存ファイルが上書きされ、アカウント情報が表示できなくなる)
npm install bootstrap-icons --save-dev
npm install
npm run build

php artisan serve
npm run dev

home.blade.php を戻す

home.blade.php に次の2行を追加

@vite(['resources/sass/app.scss', 'resources/js/app.js'])  ※ <head> の中  
<button type="button" class="btn btn-outline-primary">Primary</button>  ※ div container の中。ダブルクオーテーションに注意

 

jQuery インストール

https://jquery.com/download/
jquery-3.7.1.min.js をダウンロード。jquery.min.js に名前を変更。public/js に保存
HTML の head に以下を記述    ※ body のほうではなく

<script src="{{ asset(‘/js/jquery.min.js’) }}"></script>

表示テスト

– HTML –
<input type="checkbox" name="allcheck" id="all"> ALL<br>
<input type="checkbox" name="check[]" class="check" value="1"> 1<br>
<input type="checkbox" name="check[]" class="check" value="2"> 2<br>
<input type="checkbox" name="check[]" class="check" value="3"> 3<br>
<script>
$(function(){
  $(‘input[name="allcheck"]‘).on(‘click’, function(){
    if (this.checked) {
      $(‘input[name="check[]"]’).prop(‘checked’, true);
    } else {
      $(‘input[name="check[]"]’).prop(‘checked’, false);
    }
  });
});
</script>

 

テンプレート化

layouts/header.blade.php  ※ ここでは 'resources/css/app.css' を書かない。login、register のページでは使用している

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{ $title }}</title>
    <script src="{{ asset('/js/jquery.min.js') }}"></script>
    <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
    @vite(['resources/sass/app.scss', 'resources/js/app.js'])
</head>
<body>

@auth
    @include('layouts.navigation')
    <br>
@else
    <div class="d-flex justify-content-end gap-2 mb-3">
        <a href="{{ route('login') }}" class="btn btn-outline-dark px-4 rounded-1" style="min-width:110px;">
        {{ __('Log In') }}
        </a>

        <a href="{{ route('register') }}" class="btn btn-outline-dark px-4 rounded-1" style="min-width:110px;">
        {{ __('Register') }}
        </a>
    </div>
@endauth

layouts/footer.blade.php

<!-- Footer -->
<footer class="bg-dark text-white py-4">
    <div class="container text-center">
        &copy; 2026 Your Company. All Rights Reserved.
    </div>
</footer>
</body>
</html>

layouts/navigation.blade.php の色の変更

    <!-- Primary Navigation Menu -->
    <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 bg-gray-500">
                        <div class="text-white">
                        {{ __('Logging in ...') }}
                        </div>

(再) home.blade.php

@include('layouts.header', ['title' => 'Your Company のサイトへようこそ'])

<div class="container">
<h1>Welcome to Our Homepage</h1>
<button type="button" class="btn btn-outline-primary">Primary</button><br>
<br>
<h1 class="display-4 text-danger fw-bold">
    Bootstrap Test
</h1>
<div class="bg-primary bg-opacity-25 p-4 mt-4">
    Bootstrap が読み込まれています
</div>
<br>
<input type="checkbox" name="allcheck" id="all"> ALL<br>
<input type="checkbox" name="check[]" class="check" value="1"> 1<br>
<input type="checkbox" name="check[]" class="check" value="2"> 2<br>
<input type="checkbox" name="check[]" class="check" value="3"> 3<br>
<script>
$(function(){
  $('input[name="allcheck"]').on('click', function(){
    if (this.checked) {
      $('input[name="check[]"]').prop('checked', true);
    } else {
      $('input[name="check[]"]').prop('checked', false);
    }
  });
});
</script>
<br>
</div>

@include('layouts.footer')

※ vite.config.js が、'resources/sass/app.scss' に置き換わっているので、次の行に 'resources/css/app.css' も追加
その後、npm install、npm run build

 

日本語化(install blade の後)

https://laravel.com/docs/12.x/localization#main-content

php artisan lang:publish
lang/en ディレクトリに auth.php、pagination.php、passwords.php、validation.php ができる

nano .env
APP_LOCALE=ja
APP_FALLBACK_LOCALE=ja
APP_FAKER_LOCALE=ja_JP

nano config/app.php
‘timezone’ => ‘Asia/Tokyo’,

composer require askdkc/breezejp --dev
php artisan breezejp
lang/ja ディレクトリに〃
Github にスターを登録するために、GUI 環境で実施する必要がある。協力する:yes
いくつか警告メッセージが出るが、lang/ja にファイルが出来ていれば良い

nano lang/ja.json  (修正分)
"Log In": "ログイン",
"Logging in ...": "ログイン中 ...",

 

jQuery と Bootstrap を使用し、レスポンシブ対応の会社ホームページのサンプルを作成

 

その他

・ 管理ツール
Laravel-admin または Filament

・ メール認証
https://laravel.com/docs/12.x/verification
Docker を立ち上げていれば、mailpit を使用してメール送信テストができる
http://localhost:8025/

・ メールによるワンタイムパスワード(OTP)認証

・ お問合せフォーム
https://laravel.com/docs/12.x/mail
https://mailtrap.io/blog/send-email-in-laravel/

・ユーザーごとの閲覧制限(Laravel Gate)
https://laravel.com/docs/12.x/authorization

・ 決済(Laravel Casher、Stripe)
https://laravel.com/docs/12.x/billing

 

開発環境

SmartGit
https://www.syntevo.com/ より smartgit-23_1_1.deb をダウンロード
sudo apt install ./smartgit-23_1_1.deb

Meld Merge Tool
https://meldmerge.org/
sudo apt install meld

VSCode
https://code.visualstudio.com/download
code_1.110.1-1772839366_amd64.deb をダウンロード
sudo apt install ./code_1.110.1-1772839366_amd64.deb

GIMP
sudo apt update
sudo apt install gimp

 

便利コマンド

□ システム
pwd
ls -alF
ip addr show
nano .env (矢印キーでカーソル移動、保存Ctrl+S、終了Ctrl+X)
Ctrl+Alt+[F6] でGUIを抜けてコンソール画面に行く
sudo shutdown -h now
sudo reboot
ssh webmaster@192.168.1.13
systemctl status x11vnc
diff -r /home/webmaster/www/laravel /var/www/html/laravel
apt list --installed | grep ssh

□ Git
cd /home/webmaster/laravel
git status で、変更されたファイルと、新しく追加したファイルを確認
git add --all でそれらをステージングに移動
git commit -m "コメント"
git checkout master
git merge staging
git checkout staging

cd /var/www/laravel
git pull

 
以上、