Code Reference

LLM Local Chat ― コード解説(やさしい版)

作成日: 2026-08-17 / 全文再確認: 2026-09-05

対象: LLM Local Chat v1.8.2 の現行コード

この文書は、現在のコードから確認できる動作を、最近のAI周辺技術から離れていた方にも追いやすい形で説明する資料です。

記述にあたっては、次の順序で根拠を扱います。

  1. Pythonコード、設定テンプレート、起動バッチ
  2. 回帰テスト
  3. README / CHANGELOG などの公開文書

コードと文書の記載が食い違う場合は、現行コードを優先します。行番号・総行数・テスト件数など、更新で変わりやすい値は原則として本文へ固定しません。

重要

リポジトリをクローンしただけでは実行準備は完了しません。GGUFモデル、Python仮想環境、chat_settings.json は各自で用意します。音声入力を起動時から有効にする場合は、Whisperモデルの取得が発生することがあります。


目次

  1. このアプリは何をするものか
  2. 実行前に別途必要なもの
  3. 全体構成
  4. 起動から返答までの流れ
  5. 主要ファイルの役割
  6. LLMの読み込み・履歴・排他制御
  7. VRAM監視とWhisper
  8. 音声入力・読み上げ・アバター
  9. 会話履歴の保存と暗号化
  10. 持ち出し用暗号化エクスポート
  11. 家計簿・健康記録の連携
  12. 設定ファイル
  13. テストと未確認事項
  14. 用語集

1. このアプリは何をするものか

LLM Local Chat は、GGUF形式のLLMをPC上で読み込み、日本語で対話するWindows向けデスクトップアプリです。GUIはTkinter、LLM実行は llama-cpp-python を使用します。

主な機能は次のとおりです。

機能 実装上の概要
テキストチャット ローカルのGGUFモデルへ入力を渡し、応答をストリーミング表示
会話履歴 chat_logs/ にセッション単位で保存
要約メモリ 一定ターンごとに短い要約を作り、以後のプロンプトへ追加
音声入力 PyAudio + Whisper + RMSベースVAD
読み上げ Windows SAPI5
アバター 瞬きとTTS連動の口パク
家計簿API連携 取引候補を生成・確認後、別プロセスのkakeibo-bridge APIへPOST
健康記録連携 ユーザー確認後、localhost上のBiolog APIへPOST
履歴の持ち出し scrypt + AES-256-GCM を使うパスフレーズ方式

処理が行われる場所

LLM推論、Whisperによる音声認識、SAPI5による音声合成、通常の会話処理はローカルで実行されます。本体コードには、会話本文をインターネット上の生成AIサービスへ送る処理は確認できません。

一方、次の点は区別が必要です。

会話履歴の暗号化について

通常の保存済み会話はWindows DPAPIで保護されます。ただし、同じWindowsユーザーの権限で動くプログラムからの復号を防ぐ仕組みではありません。DPAPIは「そのユーザー環境で扱うデータの保護」に使われており、アプリ独自のパスフレーズを毎回要求する方式ではありません。

別PCへ履歴を移す用途には、後述するパスフレーズ付き暗号化エクスポートを使います。


2. 実行前に別途必要なもの

リポジトリに含まれないもの

対象 説明
GGUFモデル本体 .gitignore で models/ と *.gguf が除外されているため、各自で取得して配置
.venv/ Python仮想環境。各自で作成
chat_settings.json chat_settings.json.example を基に作成
Whisperモデル 音声入力用。必要時にWhisperライブラリが読み込む
家計簿側サービス 家計簿アプリ、DB、Web UI、API/kakeibo-bridgeサーバーおよびDocker/Compose定義は本リポジトリに非同梱
Biolog 健康記録用の別プロジェクト

リポジトリ内の models/ここにモデルを追加してください.txt は配置場所を示す空ファイルで、GGUFモデル本体ではありません。

chat_settings.json.example の意味

テンプレートでは model_path が C:\path\to\your\model.gguf になっています。これは利用者が実際のモデルパスへ書き換えるためのプレースホルダーです。

アプリ本体には別に内部既定値 models\gemma-3-4b-it-q4_k_m.gguf が定義されています。テンプレート上の値と、設定ファイルが無い場合の内部既定値は別物です。


3. 全体構成

処理の中心は LLM_Local_Chat.py の ChatApp ですが、生成処理・プロンプト構築・VRAM判定・保存処理などは別モジュールへ分離されています。

LLM_Local_Chat.py
  ChatApp / SettingsDialog / AvatarWindow / 各種UI
          |
          v
controller.py
  入力・モード・生成・停止・要約の流れを調整
          |
          +--> prompt_builder.py
          |      LLMへ渡すmessagesと各モード用プロンプトを構築
          |
          +--> prompt_inputs.py
          |      外部promptと現在チャットの添付を検証・整形
          |
          +--> resource_manager.py
          |      推論前のmax_tokens判定をresource_monitorへ委譲
          |
          +--> llm_service.py
          |      LLM生成・要約・健康データ再抽出を実行
          |
          +--> json_extractors.py
                 LLM応答から構造化JSON候補を抽出

保存系
  session_store.py
      +--> history_crypto.py   DPAPI
      +--> attachment_store.py 添付sidecar / SHA-256 / 保留処理
      +--> atomic_io.py        一時ファイル経由の置換
  portable_history.py         パスフレーズ付き持ち出し形式

音声・リソース系
  audio_workers.py            TTS / マイク / VAD / Whisper呼び出し
  resource_monitor.py         VRAM観測 / LLM・Whisper配置判断

外部連携
  integrations.py             localhost API制限・Biolog検証・POST
  kakeibo_split.py            1入力を複数取引候補へ分割・検証
  kakeibo_amount.py           ユーザー原文から金額抽出
  kakeibo_date.py             ユーザー原文から日付抽出
  kakeibo_confirmation.py     家計簿候補と送信直前検証

controller.py 自身の冒頭コメントは、Controllerを「判断のみ行い、実行は各サービスに委譲する薄いオーケストレーター」と位置付けています。


4. 起動から返答までの流れ

4-1. Windowsの起動バッチ

同梱の LLM_Local_chat_run.bat は、次の処理を行います。

1. コンソールをUTF-8コードページへ変更
2. バッチファイル自身のフォルダへ移動
3. .venv\Scripts\activate.bat を呼ぶ
4. python LLM_Local_Chat.py を実行
5. 終了後に pause

仮想環境名を変えた場合は、バッチ内のパスも変更する必要があります。

4-2. Python側の起動

main() は概ね次の順序です。

  1. tk.Tk() を作り、最初はウィンドウを非表示にする
  2. create_app_deps() で ResourceMonitor、WhisperPool、SessionStore を作る
  3. ChatApp を初期化する
  4. 会話履歴の安全確認と、必要なら旧平文履歴の移行を行う
  5. アバター、TTSワーカー、UI、Controllerを作る
  6. _reload_llm() からLLM読み込みをバックグラウンドスレッドで開始する
  7. ChatApp 初期化後にウィンドウを表示し、mainloop() に入る

履歴のスキャンで破損・未対応形式が検出された場合や、必要な移行が完了しなかった場合は、通常起動を中止する経路があります。

4-3. Whisperの起動条件

Whisperの初回ロードは、LLMのロード処理が終わった後に開始されます。

さらに should_load_whisper() は、設定の mic_enabled が明示的に true の場合だけWhisperを必要と判定します。したがって、既定値 false のまま起動した場合はWhisperをロードしません。

起動時にWhisperを読み込まなかった状態から音声入力を使う場合、UIは再起動が必要である旨を案内する実装になっています。

4-4. メッセージ送信

Controller.handle_text() が通常の入口です。

入力
  ↓
処理中か確認
  ↓
LLMが準備済みか確認
  ↓
通常 / 家計簿 / 健康記録 のモードを決定
  ↓
PromptBuilderでmessagesを構築
  ↓
添付があれば本文と区別して追加し、画像用contextを予約
  ↓
ResourceManagerで今回のmax_tokensを決定
  ↓
LLMService.generate()を開始
  ↓
文字列断片が届くたびUIへ反映
  ↓
完了後に履歴・連携候補・要約を処理

LLMから届く choice["delta"]["content"] は文字列断片であり、1回のコールバックが1文字とは限りません。

モードごとの差

通常モードでは、応答をそのまま表示し、保存対象の会話履歴へ追加します。

通常モードのファイル添付は、選択時にサイズ・形式を検証し、検証済みのセッションIDと添付内部IDでchat_logs/attachments/へ添付時点のコピーを保存します。現在表示中チャットでは実パスを持たないAttachmentへ復元し、テキストを区切り付きでユーザー入力へ加え、画像はVision対応時だけローカルdata URIとして渡します。送信後、チャット切替、終了後もsidecarを保持し、保存済みチャットを再度開けば後続質問ごとに現在入力へ1回だけ加えます。

履歴JSONへはid/name/kind/mime_type/extension/size/sha256だけを保存し、実パス・画像bytes・base64/data URI・テキスト本文は埋め込みません。本文なし+添付ありでは中立な内部指示で推論しますが、その補完文はuser履歴へ保存しません。AIが添付内容を回答へ引用すれば、その回答は通常履歴として保存されます。

添付がある間は、通常の「古い履歴を予算内だけ選ぶ」処理を使わず、system prompt、要約、全履歴、添付、新規入力、応答予約、画像予約を合算します。n_ctxを超える場合は履歴を黙って落とさず、添付解除・新規チャット等を案内します。UIの「添付解除」は確認後に現在チャットの全添付メタデータとsidecarを削除します。設定では内部ID単位の個別削除と全チャット対象の一括削除、件数・容量を管理できます。

添付はコードとして自動実行しませんが、文書内の命令文がprompt injectionとして回答へ影響する可能性があります。sidecarはアプリ自身では暗号化せず、ユーザー単位の保存領域分離もありません。共有保存領域の一括削除は他利用者の添付にも影響し得ます。元ファイル変更は保存済みコピーへ自動反映されず、添付と.shiro-exportは添付バックアップ用途にはなりません。

健康記録モードでは、先頭に出る健康JSONを LeadingJsonFilter で表示対象から外し、後続の自然文を表示します。生成後は健康データを検証し、必要なら健康データ抽出専用の再生成を行います。

家計簿モードでは、LLM応答は家計簿候補の抽出に使いますが、通常のAI返答としてストリーミング表示・読み上げを行いません。また、この家計簿モードのやり取りは通常会話履歴へ追加しない実装です。


5. 主要ファイルの役割

ファイル 現行コードでの主な役割
LLM_Local_Chat.py GUI、設定、モデルロード、アバター、履歴UI、暗号化履歴UI
app_composition.py ResourceMonitor / WhisperPool / SessionStore をまとめて生成
controller.py 生成開始・モード・停止・要約・健康再抽出などの処理順を制御
llm_service.py LLM実行専用。生成・健康抽出・要約を単一実行状態で管理
prompt_builder.py system/user/historyをmessagesへまとめ、家計簿・健康用プロンプトも作成
prompt_inputs.py inline+external promptの結合・重複排除、添付形式/サイズ検証、区切り付きテキストと画像data URIの構築
attachment_store.py UUIDで分離した添付sidecar、サイズ/SHA-256検証、追加・削除の保留状態、path境界
resource_manager.py adjust_inference() を呼び、推論時の生成上限を決定
resource_monitor.py GPU/VRAM観測、LLMのGPU/CPU判断、Whisperモデル管理
audio_workers.py SAPI5 TTS、マイク入力、RMS-VAD、Whisper認識品質判定
session_store.py 保存・一覧・読込・削除・改名・移行・保存期限・インポート、添付メタデータとsidecarのtransaction統合
history_crypto.py DPAPIエンベロープの暗号化・復号
atomic_io.py 同一ディレクトリの一時ファイルを書き終えてから os.replace()
portable_history.py scrypt + AES-256-GCM の持ち出し用履歴形式
json_extractors.py LLM応答から家計簿JSON候補・健康JSON候補を抽出
kakeibo_split.py 1回の入力を複数取引候補へ分割し、source_text の実在・重複・順序・件数上限に加え、原文金額の網羅・断片の文脈・日付の一意性を検証
kakeibo_amount.py ユーザー原文から1件の正の整数金額を機械抽出
kakeibo_date.py ユーザー原文から対応形式の日付を抽出
kakeibo_confirmation.py 家計簿確認候補の構築とAPI送信直前の検証
integrations.py ローカルAPI送信先制限、Biolog検証、非同期POST
check_audio_devices.py 入力デバイス一覧と短時間RMS測定を行う独立ツール

json_extractors.py の注意点

健康JSONは _strict_json_loads() を使い、NaN / Infinity のような非標準定数を拒否します。

家計簿JSON候補の抽出は通常の json.loads() を使用します。ただし、家計簿の最終 amount と date はLLM出力から確定せず、後段でユーザー原文から機械抽出します。

kakeibo_date.py が対応している日付

現行コードで明示的に扱っているのは次の形式です。

年の無い月/日形式では実行年を補います。数字は全角も正規化します。実在しない日付、 未対応形式、日付表現が無い入力は実行日にフォールバックします。無効な年付き日本語日付の 内部を、年なしの月日として実行年で再解釈することはありません。

「おととい」「先週の金曜」などの自然言語相対日付を解析するコードはありません。

kakeibo_amount.py が扱う金額

ユーザー原文の数字を半角化した上で、円 / 千円 / 万円 を伴う正の整数金額を抽出します。

例:

一方、現行コードは小数、符号付き金額、指数表記、数字途中の空白、複合単位表記などを不正形式として扱います。1万5千円 のような複合単位表記も拒否対象です。

8/20 1603円 のように日付と正常金額の間へ空白を置いた入力は許可します。 これは日付末尾の 20 と金額の 1603円 を、1 603円 のような不正金額として 連結して扱わないためです。金額の数字列自体に空白がある表記は引き続き拒否します。

2170円8月18日 のように金額の直後へ日本語日付が続く場合は、直後から始まる M月D日 / YYYY年M月D日 が暦日として妥当なときだけ金額の右境界として許可します。 年なし月日は境界判定では構造だけを確認し、最終的な日付は従来どおり実行年で判定します。 100円50や100円2月30日のような表記は許可しません。

1つの取引断片の中に有効な金額候補が複数ある場合は multiple_amounts とし、その断片を1取引と断定せずに入力全体を拒否します。


6. LLMの読み込み・履歴・排他制御

6-1. モデル読み込みのフォールバック

init_llm() はまず adjust_llm() の結果を使って、Full GPU、約75%・50%・25%のpartial GPU offload、CPUのどれを使うか決めます。Fullの既存安全条件は緩めません。

GPUを選択した場合、既定構成では次の順で読み込みを試します。

  1. 性能設定を含む構成
  2. flash_attn=False にした構成
  3. 基本引数だけの構成

GPUメモリ不足と明示できる失敗ではモデルを解放し、一段低いoffload候補へ移ります。一般的なメモリ不足やモデル破損などの非VRAMエラーを、層数変更で隠してretryすることはありません。

GPUロード成功後も最低空き容量を再確認し、条件を満たさなければ安全に解放してから gc.collect() と、利用可能な場合の torch.cuda.empty_cache() を呼びます。

ロード後安全確認を通った結果はruntime stateとして保持されます。生成設定では、希望するLLM GPUオフロードを自動・Full GPU・約75%・約50%・約25%・CPUから選び、再起動せずhot reloadできます。実際にロード成功したLLMレイヤーの割合・層数・実行方式は別の読み取り専用欄へ表示します。設定ファイルへ保存するのは希望モードだけで、actual runtime stateは保存しません。

hot reloadでは、まず新規操作を止め、LLMServiceから旧モデルをdetachします。重いclose・GC・CUDA cleanupと新モデルのloadはworkerで行うためTkのmain threadを止めません。新しいモデルは旧モデルの解放後にだけロードし、古いreload世代や終了開始後の結果はattachせずworker側で解放します。

内部Whisperはreload pauseと転写lockを使い、新規転写を止めたうえで進行中転写の終了を有限時間待ちます。音声認識中のモデルを強制解放する処理ではありません。

この仕組みは「必ずロードできる」ことを保証するものではなく、すべて失敗した場合は最後の例外を上位へ返します。

6-2. 会話履歴をコンテキストへ入れる量

PromptBuilder.build() は、履歴用の予算を次の考え方で計算します。

残り予算 = n_ctx - max_tokens - system分 - 今回の入力分
履歴へ割り当てる予算 = 残り予算 × HISTORY_BUDGET_RATIO

現行の HISTORY_BUDGET_RATIO は 0.60、system側には追加で SYSTEM_BUF_TOKENS = 256 を見込みます。

履歴は新しい会話ペアから逆順に見て、予算へ収まる範囲を採用し、最終的なmessagesでは元の時系列順へ戻します。各会話ペアにはコード上の固定見積もり +12 が加算されます。

トークン数は原則としてモデルのtokenizerで数えます。LLM_Local_Chat.py の count_tokens() はtokenizer呼び出しが失敗した場合に文字数ベースのフォールバック値を返します。

6-3. 要約メモリ

通常会話と健康記録モードの会話は保存履歴へ追加され、履歴数が SUMMARY_THRESHOLD の条件を満たすと要約処理を開始します。現行値は4ターンです。

要約用LLM呼び出しは LLMService.summarize() が担当し、次の値を使います。

max_tokens = 80
temperature = 0.3
stop = ["\n"]

通常生成の既定temperatureより低い値を使い、出力のばらつきを抑えています。完成した要約はセッションの summary へ保存され、次回以降のsystemメッセージへ [要約]: ... として追加されます。

家計簿モードは通常会話履歴へ追加されないため、この履歴数にも入りません。

6-4. LLMはキューではなく同時開始を拒否する

LLMService は _state_lock と _running を持ちます。

with self._state_lock:
    if self._running:
        return False
    self._running = True

したがって、すでにLLM処理が走っているとき、新しいLLM処理を待ち行列へ積むのではなく開始要求を拒否します。

読み上げ側の TTSWorker は queue.Queue を使用するため、LLM側とは設計が異なります。

6-5. 世代番号で古いコールバックを無効化

Controllerは _operation_generation を増やし、ストリーミングや完了コールバックが現在の操作世代と一致するか確認します。

停止や新しい処理によって世代が変わった後に古いコールバックが届いた場合、その処理は現在の操作へ反映しません。

6-6. 停止ボタン

Controller.stop() は次の処理を行います。

  1. LLM中断フラグを立てる
  2. TTSキューと再生を停止方向へ切り替える
  3. アバターの発話状態を戻す
  4. マイク側のTTS排他状態を解除する
  5. LLMService.abort() を呼ぶ
  6. 別スレッドでLLM実行状態を監視する

3秒以内にLLMが停止状態へ移らない場合、UIへ警告を出し、「安全のため新しい推論は開始しない」と案内します。その後も監視は続き、実際にLLMが停止した時点で現在世代と一致すれば操作状態を解除します。

ここで行っているのは、停止未確認の状態で次の生成を開始しないための制御です。


7. VRAM監視とWhisper

7-1. ResourceMonitor

ResourceMonitor は生成時に作られ、デーモンスレッドでGPU・VRAM・CPUの診断値を更新します。ポーリング周期は 0.5 秒です。

VRAMの即時判定では snapshot() が同期取得を行います。

GPU情報の取得は次の順に試します。

  1. pynvml(requirements.txt 上のパッケージは nvidia-ml-py)
  2. nvidia-smi
  3. 取得できなければGPU情報なしとして扱う

7-2. LLMロード前

adjust_llm() は、GPUオフロード対応の有無、空きVRAM、GGUFファイルサイズ、metadataの総レイヤー数を使って n_gpu_layers を決めます。

起動時の予約量は次の大きい方です。

1024MB
総VRAMの12%

Fullの概算必要量は従来どおり モデルファイルサイズ + 起動予約量 です。Full条件に届かない場合だけ、総レイヤー数からhalf-upで約75%・50%・25%を作り、起動予約量 + モデルファイルサイズ × 候補層数 / 総層数(端数切り上げ)を満たす最大候補を選びます。metadataを安全に取得できずFull条件にも届かない場合や、partial候補にも届かない場合はCPUです。

この線形計算は事前の保守的な近似です。実際の安全境界は、明示的なGPUメモリ不足時の段階降格と、ロード後の最低予約量再確認でも守ります。

7-3. 推論直前

adjust_inference() はLLMがGPU推論中の場合に、空きVRAMを見て max_tokens を調整します。資源保護による縮小後も最終値を利用者設定の default_max 以下へクランプするため、設定値が256未満の場合でも自動調整が設定上限を超えることはありません。

条件 現行動作
空きが最低予約量より少ない 自動かつGPU配置中なら、現在より低いoffloadへhot reloadして同じ入力を1回だけ継続。安全に再配置できない場合は開始しない
空きが1024MB未満 max(256, default_max // 4)
空きが1536MB未満 max(256, default_max // 2)
それ以上 設定値を維持

最低予約量は 512MB と総VRAMの 6% の大きい方です。

CPU推論時、またはGPUメモリ情報を取得できない場合は、GPU残量を理由にこのブロックを行いません。

Auto downshiftの候補は現在位置より下だけです。例えばFullからは約75%→50%→25%→CPU、約50%からは約25%→CPUを検討します。推論ごとの自動upshiftは行わず、起動・通常のモデル再読み込み・設定画面の「GPU配置を再評価」で上位候補を再検討します。入力原文・セッション・モード・操作世代はrequest contextとして保持し、安全確認後にだけユーザー表示、履歴、TTS、家計簿確認/POSTを始めるため、reloadによる二重処理を避けます。

Whisper側から渡された直近GPU使用率変化が 10 ポイントを超える場合は、さらに生成上限を半分へ調整します。

7-4. Whisperのモデル配置

WhisperPool.load() はまずCPU版 small をロードします。その後、設定と空きVRAMに応じてGPUモデルのロードも試します。

auto の選択基準は次のとおりです。

空きVRAM GPU側の候補
4096MB以上 medium
2048MB以上 small
それ未満 GPUモデルを追加せずCPU small

手動でGPUモデルを指定していても、必要な空き量に満たない場合はCPU small 側へフォールバックします。GPUモデルのロードに失敗した場合もCPUモデルを残します。

7-5. WhisperのGPU/CPU切替

GPUモデルが利用可能な場合、WhisperController はGPU使用率に対してヒステリシスを持ちます。

88と70で境界を分けることで、単一のしきい値付近で切替が連続しにくい構造です。

VRAMGuard クラスも resource_monitor.py 内に定義されていますが、現行の他モジュールからこのクラスを生成・参照するコードは確認できません。実際の生成経路では ResourceMonitor、adjust_llm()、adjust_inference() が使用されています。


8. 音声入力・読み上げ・アバター

8-1. 音声入力の既定値

chat_settings.json.example と load_settings() の両方で、mic_enabled の既定値は false です。

起動時に mic_enabled=false の場合、Whisperのロードをスキップします。

起動時にWhisperをロードした場合、VoiceRecognizer はバックグラウンドスレッドを開始します。マイクON/OFFは _enabled イベントで制御され、OFF中は認識処理へ進まず待機します。OFFまたは停止時には認識世代も進め、OFF前に開始した録音・Whisper処理・送信待ち結果を古いものとして無効化します。

したがって、「マイクスレッドはONにするたび作成・破棄する」という構造ではありません。

8-2. VAD

音声入力は16kHz、モノラル、1024サンプル単位で読み取ります。

発話開始は、RMSが設定しきい値を連続6チャンク超えた場合に確定します。プリロールとして直近0.75秒相当のチャンクを保持します。

録音開始後は、連続30チャンクの静音、または最大6秒の到達で録音区間を終えます。

8-3. Whisper認識結果のフィルタ

認識後は、複数の条件で結果を破棄することがあります。

100文字という値は、一般的な発話長の上限を示すものではなく、この実装が誤認識抑制用に置いているヒューリスティックです。

compression_ratio も「繰り返し量そのもの」ではなく、Whisperが返す認識区間の指標として扱われています。コードは他の指標と組み合わせて判定します。

8-4. TTSとの相互干渉

TTS開始時はマイク側へTTS中であることを通知します。VoiceRecognizer はTTS中のマイクチャンクを発話判定へ蓄積しません。

録音中にTTSが始まった場合は、その録音データを破棄してWhisper処理へ進まない経路があります。またWhisper処理の前後でもTTS世代を確認します。

TTS終了後のマイク抑制解除には世代番号を使い、新しいTTSが開始済みなら古い解除処理を適用しません。

8-5. 読み上げ

TTSWorker は専用デーモンスレッドと queue.Queue を持ちます。

tts_enabled の既定値は false です。TTSワーカースレッド自体はUI初期化時に作られますが、enabled=false なら speak() は読み上げ要求をキューへ追加しません。

ストリーミング応答は StreamingTTSBuffer が 。!?!? と改行を境界として文単位へ分割します。コードフェンス内の内容は読み上げ対象から外します。

8-6. アバター

AvatarWindow は別のTkウィンドウとして作られます。

画像読み込みに失敗した場合、_load() は透明な100×100画像を代わりに作ります。瞬き用画像が個別に無い場合は既存画像を使うフォールバックもあります。


9. 会話履歴の保存と暗号化

9-1. 保存先

SessionStore はアプリ配置フォルダ配下の chat_logs/ を管理します。初期化時にこのディレクトリが無ければ作成します。

会話が空の場合は保存ファイルを作りません。保存ファイル名は概ね次の形式です。

chat_YYYYMMDD_HHMMSS_ffffff.json

9-2. DPAPIエンベロープ

history_crypto.py の DPAPIProtector はWindowsの現在ユーザースコープで CryptProtectData / CryptUnprotectData を呼びます。

保存ファイルは平文セッションそのものではなく、概ね次のエンベロープになります。

{
  "format": "llm-local-chat-dpapi",
  "version": 1,
  "ciphertext": "..."
}

保存時の _write_encrypted() は、

  1. セッション形式を検証
  2. JSON化
  3. DPAPIで暗号化
  4. その場で復号
  5. 復号結果が元バイト列と一致することを確認
  6. ファイルへ書き込む

という順序です。

一致しない場合は保存処理をエラーにします。DPAPI保存に失敗した場合、ChatApp._save_now() は平文へ自動フォールバックしません。また、新規チャット・別履歴への切替・通常モードからゲストモードへの切替前の保存に失敗した場合は、現在のセッションを置換せず未保存会話を維持します。

9-3. 一時ファイル経由の置換

atomic_io.atomic_write_bytes() は、対象と同じディレクトリに NamedTemporaryFile を作り、

write
  ↓
flush
  ↓
os.fsync(file descriptor)
  ↓
os.replace(temp, target)

の順で処理します。

これは既存ファイルを先頭から直接上書きするのではなく、一時ファイルを完成させてから対象パスへ置換する構造です。

このコードはファイルディスクリプタに対する os.fsync() は呼びますが、置換後のディレクトリ自体を fsync する処理はありません。このため、この文書では電源断時の永続性まで保証する表現は使用しません。

9-4. 旧平文履歴の移行

起動時に scan_legacy() で chat_logs/*.json を確認します。

という手順があります。

9-5. 履歴一覧キャッシュ

SessionStore._file_signature() は、次の4要素を署名として使います。

mtime_ns
size
ctime_ns
SHA-256(ファイル全内容)

SHA-256はファイルを1MBずつ読んで毎回計算します。その署名が前回と同じ場合、後段のDPAPI復号とJSONパースを省略します。

つまりこのキャッシュは「ファイルを読まなくするキャッシュ」ではありません。内容ハッシュの計算は行い、復号・パース結果の再取得を省略する仕組みです。

9-6. 保存期間

history_retention_days は 0 / 30 / 90 / 180 / 365 のみを受け付け、既定値0は無期限です。

有限値の場合、last_activity_at を基準に期限候補を判定します。現在開いているセッションや、読み取りに失敗するファイルを無条件に削除する処理にはなっていません。

9-7. ゲストモード

ゲストモードでは ChatApp._save_now() が保存処理へ進まず戻ります。したがってゲスト会話の会話履歴ファイルは保存しません。通常モードからゲストモードへ切り替えるときは現在の会話を先に保存し、保存失敗時はゲスト化も新規セッションへの置換も行いません。

ただし、SessionStore 初期化時の chat_logs/ 作成など、アプリ全体が一切ファイルを作らないという意味ではありません。

9-8. 平文テキスト保存

現行の LLM_Local_Chat.py の「ファイル」メニューには、

があり、旧版にあった平文の「テキストとして保存」は現行コードにはありません。CHANGELOGでもv1.5.0で暗号化エクスポートへ置き換えたことが記録されています。


10. 持ち出し用暗号化エクスポート

通常の保存履歴はDPAPIで保護されるため、別PCへの持ち出しには portable_history.py の形式を使います。

10-1. 暗号化方式

12文字以上のパスフレーズ
      ↓
scrypt
  N=32768
  r=8
  p=1
      ↓
32バイト鍵
      ↓
AES-256-GCM
      ↓
.shiro-export

saltは16バイト、nonceは12バイトです。

KDF・暗号方式・salt・nonceなどのヘッダーはcanonical JSONへ変換され、AES-GCMの関連データとして暗号化・復号の両方で使用されます。

10-2. インポート時の検証

import_archive() は、外側のキー集合、format/version、KDF設定、暗号設定、salt/nonce長などを検証します。

AES-GCM復号で InvalidTag が発生した場合は、

パスフレーズが違うか、暗号化ファイルが破損しています。

という共通エラーに変換します。InvalidTag だけから原因を個別に確定する処理はありません。

復号後もschema/version、日時形式、record UUID、セッション形式を確認します。

10-3. リソース上限

現行コードには次の上限があります。

対象 上限
暗号化ファイル 64MB
復号後データ 128MB
セッション数 10,000
全ターン数 100,000
1文字列 4,000,000文字
JSON入れ子 深さ32

この形式はZIP等の圧縮アーカイブではありません。これらは入力サイズ・件数・文字列長・入れ子深さを制限するための値です。

10-4. 重複判定

session_digest() はセッションをcanonical JSONへ変換し、SHA-256を計算します。インポート時の重複判定はこのダイジェストを使います。

ローカル専用のsession_idとattachmentsは、ダイジェスト計算と.shiro-export出力の前に除外します。そのため添付実体・添付メタデータはエクスポートされず、インポート後に復元されません。.shiro-exportは添付ファイルのバックアップではありません。


11. 家計簿・健康記録の連携

この2機能は、通常チャットとは別に、構造化データを別プロセスのlocalhost APIへ送る連携クライアント機能です。どちらも起動直後から自動送信を行うのではなく、ユーザーが該当モードを選択した入力を処理します。

11-1. 送信先URLの制限

is_allowed_local_api_url() は次を確認します。

scheme == http
URL内の username が無い
URL内の password が無い
host が localhost / 127.0.0.1 / ::1 のいずれか
port が LOCAL_API_PORTS に含まれる

LOCAL_API_PORTS は、

から構成されます。

HTTPSを許可していないこと自体が「外部送信防止」を意味するわけではありません。送信先の範囲を決めているのはhost・port等の条件です。

HTTP通信を開く _open_local_api() は、

を使用します。JSON応答は最大1MBまで読みます。

11-2. 家計簿モード

家計簿モードでは PromptBuilder.build_kakeibo_prompt() が、LLMへ取引ごとの原文断片を transactions 配列で返すよう指示します。1回の入力から最大10件の取引候補を生成でき(MAX_KAKEIBO_TRANSACTIONS_PER_INPUT)、各取引は1件ずつ確認し、1件ずつPOSTします。

候補生成・検証・確認UIとPOSTクライアントはLLM Local Chat側にあります。実際の台帳登録には別途用意したkakeibo-bridge APIが必要で、家計簿アプリ、DB、Web UI、API/bridgeサーバーおよびそのDocker/Compose定義は本リポジトリに含まれません。連携先がPOST後に行う処理は、このリポジトリのコードからは断定しません。

一括登録ではありません。 「1取引 = 1確認 = 1 POST」という境界は従来のままです。

1回の入力
  ↓
複数の取引候補へ分割(最大10件。11件以上は入力全体を拒否)
  ↓
取引1を確認 → 取引1だけPOST
  ↓
取引2を確認 → 取引2だけPOST
  ↓
…

LLMが出したJSONをそのまま登録しない点も従来どおりです。

分割と source_text の検証

kakeibo_split.py は、LLMが返した source_text を信用せず次を確認します。1つでも満たさない場合は、先頭だけ処理する部分成功を行わず入力全体を拒否します。

まず文字単位の完全一致を試します。完全一致しない場合でも、違いが半角スペース(U+0020)と 全角スペース(U+3000)だけで、空白を除いた一致位置が原文上で1か所に決まり、他の断片と 重ならないときは、対応する user_text[start:end] の実スライスへ戻します。 LLMが整形した文字列を候補へ採用するわけではありません。

句読点、金額カンマ、数字、日付、店名、改行などは正規化しません。空白を除くと同じ候補が 複数位置に現れる場合も推測せず拒否します。原文に存在しない文言をLLMが生成した場合は 候補になりません。11件を超える場合も、確認画面0件・POST 0件で全体を拒否します。

取りこぼし・曖昧な分割の拒否

断片が原文に実在するだけでは独立した取引として認めません。次も確認し、1つでも満たさない場合は入力全体を拒否します。

金額

最終 amount は kakeibo_amount.extract_amount_result() が各取引の原文断片から決めます。LLM JSON内のamountは最終値として使用しません(そもそも雛形に amount を含めていません)。

1つの断片に有効な金額候補が複数ある場合、その断片が1取引なのか内訳なのかを推測せず、入力全体を拒否します。不正形式の金額表現が検出された場合も確認画面へ進みません。

日付

最終 date もLLM値ではなく kakeibo_date が決めます。各断片から抽出し、断片自身に日付表現が無い場合は、原文全体の明示日付がちょうど1種類のときだけその日付へフォールバックします。

8/16 スーパーで2000円、コンビニで500円
  → 両方 8/16(原文の日付が1種類なので適用できる)

8/16 スーパーで2000円、8/17 コンビニで500円、薬局で1200円
  → 入力全体を拒否(薬局がどちらの日付か確定できない)

日付が1つも無ければ従来どおり実行日を使います。対応形式は前述の YYYY/MM/DD、YYYY-MM-DD、M/D、YYYY年M月D日、M月D日 です。 「昨日」「一昨日」「先週」などの自然言語相対日付は解析しません。

店舗・メモ

LLMが候補として出した store / memo は、空白除去後の文字列がその取引の原文断片へそのまま含まれる場合だけ候補へ残します。

種別・カテゴリ

type は 支出 / 収入 以外を未確定扱いにします。categoryはtypeごとの定義済み一覧に合わせます。

確認と最終検証

候補は KakeiboConfirmDialog で1件ずつ表示し、ユーザーが確認・編集します。このシーケンスが進行中の間は Controller.is_busy() が真になり、キーボード入力・音声入力とも新しい送信を開始できません。 完了・スキップ・中止・エラーのいずれでもフラグは解除され、予期しない例外が起きた場合も解除されます。この間は送信ボタンも無効のままで、ステータスは「🏠 家計簿確認・登録中…」を表示します。なお1件だけの入力では、確認画面の「認識・入力内容」に入力全文を表示します。複数候補のときはタイトルへ (2 / 3) のように現在位置を表示し、「この取引をスキップ」と「残りを中止」を分けています。ウィンドウを閉じる操作は「残りを中止」と同じ扱いです。

登録ボタンの判定とは別に、API送信直前にも validate_kakeibo_payload() が日付・金額・type・category等を検証します。

送信の直列化と失敗時

複数POSTを並列化しません。send_kakeibo() の完了通知を受けてから次の確認画面を開くため、送信は常に直列です。

途中のPOSTが失敗した場合は、成功したかどうかが不明な状態で次を送らないよう、残りの候補を処理せず停止します。このクライアントからPOST済みの取引は取り消しません。

11-3. 健康記録モード

健康記録モードでは、LLMへ健康JSONを先頭に出すよう指示します。

対象フィールドは次です。

date
weight
body_fat
muscle_mass
bmr
temperature
pulse
systolic_bp
diastolic_bp
meal_detail
activity_log
memo

11-4. ユーザー原文を優先する欄

integrations.extract_explicit_health_fields() は、ユーザー原文に明示された次のラベルを機械的に探します。

見つかった値は同じフィールドのLLM候補より優先してマージします。

11-5. Biolog値の検証

数値範囲は次のとおりです。

項目 範囲
体温 34.0〜42.0(両端を含む)
体重 0より大きく300未満
体脂肪率 0〜100(両端を含む)
筋肉量 0より大きく200未満
脈拍 30〜200(整数、両端を含む)
収縮期血圧 50〜250(整数、両端を含む)
拡張期血圧 30〜150(整数、両端を含む)
基礎代謝 0より大きく5000未満(整数)

文字列の meal_detail / activity_log / memo は各2000文字までです。

bool、非数値型、有限でない数値、範囲外の数値などは BiologValidationError になります。

測定値だけから成る activity_log や memo を、明示入力でない場合に除去する判定もあります。

11-6. Biologの確認

検証後、画面へ登録内容を表示し、messagebox.askyesno() で確認します。承認時だけPOSTします。

user_id はLLM出力から取得せず、送信時に固定文字列 "self" を付けます。

日付を省略した場合は、送信直前にJSTの当日を登録日として確定し、実際にPOSTするrecordのdateへ保持します。明示済みの有効日付は変更しません。

登録成功時の完了表示は、API応答に有効なdateがあればその値を優先し、なければ実際にPOSTした確定recordのdateを使用します。表示層で現在日付を作り直したり、日付取得のために再POSTしたりはしません。


12. 設定ファイル

chat_settings.json.example の公開テンプレートと、load_settings() の内部既定値を確認すると、主な設定は次のとおりです。

キー テンプレート値 意味
model_path C:\path\to\your\model.gguf 使用するGGUF。利用者が変更するプレースホルダー
n_ctx 8192 コンテキスト長
llm_gpu_offload_mode テンプレート未記載(コード既定auto) LLM GPUオフロードの希望モード。actual stateは保存しない
max_tokens 1024 通常生成の上限
temperature 0.7 通常生成へ渡すtemperature
history_retention_days 0 保存期間。0は無期限
n_threads_batch 12 llama.cpp性能設定
n_batch 1024 llama.cpp性能設定
n_ubatch 512 llama.cpp性能設定
flash_attn true llama.cpp性能設定
offload_kqv true llama.cpp性能設定
use_mmap true llama.cpp性能設定
tts_enabled false 起動時TTS
tts_rate 0 SAPI5読み上げ速度
mic_enabled false 起動時音声入力
whisper_mode auto Whisper配置モード
vad_threshold 150 RMS-VADしきい値
system_prompt 空 inline system prompt。未指定時は従来のコード既定値
user_personalization 空 persona文章
response_language 日本語指示 応答言語の指示
reasoning_visibility_instruction 内部思考を表示しない指示 表示方針の指示
external_prompt_files 各項目は空 UTF-8のTXT / MD。相対パスはアプリ配置フォルダ基準
vision_enabled false Vision入力を有効にするか
vision_handler gemma4 gemma4 / llava15
vision_projector_path 空 対応projector / mmproj GGUF

chat_settings.json が無い、またはJSON読み込みに失敗した場合、load_settings() は内部既定値を使って処理を続けます。

ただし、すべての設定値を同じ方法で厳密検証しているわけではありません。ロード時に明示的な正規化処理があるのは、性能系整数・bool、TTS速度、VAD値、Whisperモード、保存日数などです。設定画面側にも別の入力検証があります。

設定保存は atomic_write_json() を使います。

inline system、external system、inline persona、external persona、応答言語、思考表示方針、external profile/instructionsを順に併用し、同一ファイル・同一文章は重複挿入しません。読込エラーはUIへまとめて警告し、inlineを含む利用可能な設定で継続します。Vision有効時はprojectorの存在とhandler名をモデル読込前に検証します。


13. テストと未確認事項

13-1. テスト

tests/ は標準ライブラリの unittest を使っています。現在のテスト結果を確認する場合は、リポジトリルートで次を実行します。

python -m unittest discover -s tests -t . -p "test*.py"

公開CHANGELOGでは、v1.6.1で全回帰テストが通過し、v1.6.2 / v1.6.3 はコードロジックを変更していないため再実行していない、v1.7.0では家計簿の複数取引対応にあたって全回帰テストを実行し全件成功した、と記録されています。 v1.7.1では、日本語日付形式、日付直後・金額直後の日付隣接入力、複数取引の source_text における限定的な水平スペース差の復元に関する回帰テストを追加し、 全回帰を再実行しています。 v1.7.2では、Biologの日付省略時にJST登録日をPOST recordへ保持する処理、 API応答日付の優先、POST日付へのfallback、API失敗時の非表示、二重POST防止を 回帰テストで確認し、全回帰を再実行しています。 v1.7.3では、外部promptの優先・重複排除・読込失敗、対応添付形式と上限、 添付の一回限り利用と履歴非永続、Vision拒否、MTMD handlerの生成・解放、 画像用context予約を回帰テストで確認しています。 v1.8.0では、添付の再起動復元、元ファイル独立、後続ターン参照、同名別ID、 添付のみ送信、個別/一括/チャット削除、欠損・サイズ・SHA-256不一致、保留処理、 portable除外、inline+external prompt、全履歴込みcontext上限警告を回帰テストへ 追加しました。既存回帰を含む全640件を実行し、639件成功・1件skipです (symlink作成権限なし)。

テストには、少なくとも次の領域があります。

13-2. テストがあることと実機確認は別

自動テストではモックや疑似オブジェクトを使う領域があります。そのため、テスト通過だけから次を保証することはできません。

13-3. モデルファイルの完全性

本アプリは会話履歴キャッシュや持ち出し履歴の重複判定にSHA-256を使用しますが、GGUFモデル本体について期待SHA-256を照合するコードはありません。

モデル読み込み時に例外になる破損もありますが、あらゆる取り違え・改変・破損をアプリが検出する仕組みではありません。配布元がチェックサムを提示している場合は、利用者側での照合が別途可能です。

13-4. 標準出力ログ

アプリは各所で標準出力へ診断ログを出します。専用ログファイルへ自動保存する実装は確認できません。

test_audio_log_privacy.py はTTS本文とWhisper認識本文がそれぞれの診断ログへ直接含まれないことを確認しています。一方、これはアプリ内のあらゆるログ出力について機密情報非出力を保証するテストではありません。


14. 用語集

用語 この文書での意味
LLM このアプリでは、llama-cpp-python から読み込んで対話生成に使う言語モデル
GGUF llama.cpp系で読み込むモデルファイル形式
llama-cpp-python Pythonからllama.cppを利用するためのライブラリ
コンテキスト長 (n_ctx) 1回の生成でモデルへ渡せる文脈量の設定
トークン モデルのtokenizerが文章を分割して扱う単位
temperature LLM呼び出しへ渡す生成パラメータの1つ
ストリーミング 生成完了まで待たず、届いた文字列断片を順次処理する方式
VRAM GPUが利用するメモリ
OOM Out Of Memory。メモリ不足を表す状態
VAD Voice Activity Detection。ここではRMSを使った発話区間判定
RMS 音声振幅から計算する値。VADしきい値判定に使用
Whisper 音声認識モデル。現行コードでは whisper パッケージを使用
TTS Text To Speech。テキスト読み上げ
SAPI5 現行Windows TTSで利用している音声API
Tkinter PythonのGUIインターフェース
スレッド 同一プロセス内で別の処理を並行して進める実行単位
デーモンスレッド Pythonプロセス終了時の待機対象にならないよう daemon=True で作られたスレッド
キュー 順番待ち用のデータ構造。TTSで queue.Queue を使用
LRU 最近使われていない要素を先に外すキャッシュ方針。TokenCostCache で使用
ヒステリシス 切替開始と復帰に異なるしきい値を持たせる方式
DPAPI Windows Data Protection API。通常履歴の保護に使用
AES-GCM 持ち出し履歴の暗号化と認証に使用
scrypt パスフレーズから暗号鍵を導出するKDF
SHA-256 履歴キャッシュ署名や重複判定で使用するハッシュ関数
サニタイズ / 検証 APIへ送る前にキー、型、範囲などを制限する処理
localhost / loopback 同じPC自身を指すネットワーク宛先

最後に

LLM Local Chat v1.8.2 の現行実装は、単一のGUIファイルだけで処理を完結させず、LLM実行、プロンプト構築、VRAM判定、履歴保護、添付sidecar、音声処理、ローカルAPI連携を別モジュールへ分けています。

特にコード上で確認できる特徴は、次のとおりです。

これらは現在のコードに実装されている処理の説明であり、「あらゆる環境で障害が起きない」「セキュリティ上の問題が存在しない」といった保証を意味しません。実運用では、利用するGGUF、Windowsユーザー環境、Python依存関係、GPU/音声デバイス、連携先サービスの構成も合わせて確認する必要があります。


この版は、v1.8.2 の LLM_Local_Chat.py と関連モジュール、chat_settings.json.example、起動バッチ、回帰テスト、README / CHANGELOG を再確認して作成しました。パーソナライズ・永続添付・Vision設定も現行コードに基づいて記載しています。