Tool calls were invisible until the answer arrived: the only trace was the sources list, and only for web searches. Surface them as they happen instead. Add llm.stream_tool_calls(), an incremental version of resolve_tool_calls() that yields a start and a completion event per tool call and ends with a "resolved" event carrying the previous return value. resolve_tool_calls() is kept as a thin wrapper that drops the progress events. api/chat.py relays those events as a new SSE "tool" event, and wraps the search-summary/knowledge-save step in one of the same shape, since it is a noticeable pause of its own. On the front end, ToolActivityList.vue renders one row per tool above the assistant bubble (label, target, spinner while running, short result when done). The store merges events by id and marks anything still running as interrupted when the stream ends early. Display only - not persisted, so the rows disappear when the session is reloaded, same as sources. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
15 KiB
15 KiB
機能一覧
このリポジトリにあるアプリ(PlayChat)が「何ができるか」を機能単位でまとめたもの。 内部設計は architecture.md、エンドポイント仕様は api.md、 Discord 連携は discord-bot.md、ノートの操作方法は notes-guide.md、 デプロイ手順は deploy-lightsail.md を参照。
これは何か
個人用の LLM チャットアプリ。FastAPI(Python)+ Vue 3(TypeScript)で、会話・記憶・ノートを すべて手元の SQLite に持つ。LLM は LiteLLM 経由なので OpenAI / Anthropic / Ollama / Amazon Bedrock を 同じ画面のプルダウンで切り替えられる。単なるチャット UI ではなく、
- 会話が長くなったら勝手に要約して長期記憶に落とす
- 知らないことは自分で Web 検索して、その結果を次回以降も使える知識として溜める
- ユーザーと AI が同じノートを共同編集する
- Discord 側にも住んでいて、天気を知らせたり雑談に乗ったりする
といった「動き続ける」部分を持つのが特徴。
機能サマリ
| # | 機能 | 概要 | 主な実装 |
|---|---|---|---|
| 1 | チャット(SSE ストリーミング) | 逐次表示・Markdown 描画・セッション管理 | api/chat.py / ChatWindow.vue |
| 2 | セッションの書き出し / 取り込み | 会話を JSON でダウンロード・復元 | api/chat.py / SessionList.vue |
| 3 | 5 層コンテキスト管理 | 蓄積知識・ノート・ローカル KB・長期/短期メモリを毎回合成 | core/memory_kb/memory.py |
| 4 | 自動要約による長期記憶 | 一定件数を超えたら古い会話を要約して圧縮 | memory_kb/memory.py |
| 5 | Web 検索と知識の蓄積 | Tavily で検索 →要約 → DB とローカル KB に保存 | core/search/ / memory_kb/knowledge.py |
| 6 | ローカル Markdown ナレッジベース | 話題に近いファイルを埋め込み類似度で選んで全文注入 | memory_kb/local_knowledge.py |
| 7 | 共有ノート | タブ/セクション付きノートを人と AI の両方が編集 | core/notes.py / NotePanel.vue |
| 8 | 自動タグ付け | 発言・要約にタグを 3 つ付与(話題選びに使う) | memory_kb/tagging.py |
| 9 | ワードプール | 直近の名詞をインメモリに保持しタグクラウド表示 | memory_kb/word_memory.py / WordPool.vue |
| 10 | Discord Bot | メンションに返信。天気・雑談キーワードに反応 | core/discord/discord_bot.py |
| 11 | 定期通知 | 毎日の洗濯天気通知と、定期的な「どうでもいい豆知識」 | core/discord/scheduler.py |
| 12 | 天気予報(気象庁 API) | 明日の天気・降水確率から洗濯指数を判定 | core/weather/ / Clock.vue |
| 13 | モデル切替とプリセット管理 | 選択肢を DB で管理し画面から追加・編集・削除 | api/models.py / SettingsPanel.vue |
| 14 | GUI 設定画面 | API キー等を再起動なしで変更(許可リスト制・マスク表示) | core/settings_store.py |
| 15 | API ログビューア | LLM 呼び出しの成功/失敗・レイテンシ・リクエスト全文 | api/logs.py / LogViewer.vue |
| 16 | システムモニタ | CPU・メモリ・GPU 使用率を 10 秒ごとに表示 | api/system.py / MemoryMonitor.vue |
| 17 | サイト認証 | 共有パスワード 1 つでサイト全体を保護 | core/site_auth.py / LoginGate.vue |
| 18 | Docker 一式 | Caddy + nginx + FastAPI で HTTPS 公開 | docker-compose.yml / Caddyfile |
1. チャット
POST /chat/streamが SSE を返し、フロントは fetch + ReadableStream で受けて 1 文字ずつ描画する。 イベントはsession_id/chunk/tool/sources/notes_updated/done/errorの 7 種。- アシスタントの発言は
marked+DOMPurifyで描画する。ストリーミング途中で閉じていないコードフェンスは 自動で補完してから描画するので、生成中でもレイアウトが崩れない。 - セッションは左サイドバーの一覧から切り替え・削除できる。タイトルは最初の発言の先頭 40 文字。
- Web 検索が走った応答には出典(
sources)が、各メッセージには自動生成されたタグが添えて表示される。 - Web 検索・ノートの読み書き・蓄積知識の読み出しといったツール実行は、バブルの上に
「🔍 Web検索 / クエリ / ✓ 5件の結果」の形でリアルタイムに表示される(
ToolActivityList.vue)。 実行中はスピナーが回り、応答が途中で切れたツールは「⚠ 中断されました」になる。表示専用で永続化はしない。 - 失敗時は赤いバブルとトースト通知の両方で知らせる。
2. セッションの書き出し / 取り込み
GET /chat/sessions/{id}/exportがセッションのメタ情報と全メッセージ(タグ込み)を JSON で返す。POST /chat/sessions/importは同じ形式の JSON から新しいセッションを作成する(上書きではない)。 サイドバーの ⭱ ボタンからファイルを選ぶだけで取り込める。- 要約済み(アーカイブ済み)のメッセージも
archived_at付きでそのまま書き出され、取り込み時もアーカイブ状態を保つ。
3. コンテキスト管理(5 層)
毎リクエスト get_context_messages() が以下の順に system メッセージを積み、最後に会話履歴を連ねる。
| 層 | 中身 | 量の制御 |
|---|---|---|
| 1 蓄積知識 | 過去の Web 検索結果の見出し一覧のみ(本文は read_knowledge で読む) |
最新 KNOWLEDGE_CONTEXT_LIMIT(既定 15)件 |
| 2 共有ノート | ページのタイトル一覧+本文先頭 40 文字 | 全ページ(本文は入れない) |
| 3 ローカル KB | 選ばれた Markdown の全文 | 類似度上位 LOCAL_KB_TOP_K(既定 3)件かつ LOCAL_KB_MIN_SCORE(既定 0.75)以上 |
| 4 長期メモリ | 要約を全件連結 | 上限なし |
| 5 短期メモリ | セッション内の全メッセージ | 上限なし(要約で間接的に制御) |
重い情報(ノート本文・蓄積知識の本文・Web 検索)は目次だけを渡し、本体はツール(read_note / write_note / read_knowledge / web_search)で
取りに行かせる遅延読み込み方式。llm.resolve_tool_calls() が本番生成の前に最大 2 ラウンドでこれを解決する。
4. 自動要約による長期記憶
- メッセージ数が
MEMORY_SUMMARIZE_THRESHOLD(既定 20)を超えると、古い側の半分を LLM が要約してmemoriesに 1 行として保存する。 - 要約した元メッセージはアーカイブされる(物理削除はしない)。LLM に渡るコンテキストからは外れるが、チャット画面では薄く表示され、区切り線から先だけがモデルに渡ることが分かるようになっている。
- 保存した要約にもタグが付き、Discord Bot の話題選びの材料になる。
- 溜まった要約は
GET /memory/{session_id}で確認、DELETE /memory/{id}で個別削除できる(API のみ。専用の画面は無い)。
5. Web 検索と知識の蓄積
- LLM が必要と判断したときだけ
web_searchツールが走る(Tavily。TAVILY_API_KEY未設定なら空振り)。 - 結果は
SEARCH_SUMMARY_MODELで構造化要約にされ、2 か所に保存される。knowledge_entries/knowledge_sourcesテーブル → 次回以降、層 1 として全セッションに効くknowledge_base/web_search/*.md→ 層 3 の類似度検索の対象になる
POST /chat/knowledge/regenerateで、既存の知識エントリを出典 URL から取り直して要約し直せる。
6. ローカル Markdown ナレッジベース
backend/knowledge_base/**.mdを置いておくだけで、話題が近いときに自動で全文が注入される。- 直近のユーザー発言との埋め込みコサイン類似度で選ぶ。埋め込みは
local_knowledge_docsに 内容ハッシュ付きでキャッシュされ、ファイルが変わったときだけ取り直す。 - プロンプト上は「Web 検索結果より優先。これで答えられるなら検索するな」と明示している。
7. 共有ノート
- 1 ページ=1 タブ。作成・改名・削除・本文編集(入力が止まると自動保存)ができる。
- セクションでページをグループ化でき、同じセクションのタブは常に隣り合って表示される。
- タブはドラッグ&ドロップで並べ替えでき、他セクションのタブにドロップすればそのまま移動になる。
- AI 側はタイトル一覧だけを常に見ており、
read_noteで本文を読み、write_noteで追記/全置換/新規作成する。 AI が書き換えると SSE のnotes_updatedが飛び、開いているノートパネルが自動で最新化される。 - 自分が編集中(未保存)のページは AI の更新で上書きされない。
8-9. 自動タグ付けとワードプール
- 各メッセージと各要約に、バックグラウンドでタグが最大 3 つ付く(応答ストリームはブロックしない)。 タグはコンテキストには注入せず、Discord Bot が「近い話題」を選ぶために使う。
POST /api/system/tags/backfillで、過去の未タグ分をまとめて処理できる。- ワードプールは Janome で抽出した名詞を
deque(maxlen=200)にプロセス内保持するもの。 DB に永続化されず再起動で消えるが、起動時に直近 200 件のメッセージから再シードされる。 サイドバーに出現回数に応じた大きさのタグクラウドとして表示される。
10-11. Discord Bot と定期通知
- Bot はメンションされたときだけ反応する。本文に天気系キーワード(天気・洗濯・雨など)が含まれれば 気象庁データを添えて回答し、雑談系キーワード(どうでもいい・暇など)なら発言からタグを起こして 関連する話題の豆知識を即座に投げ返す。
- APScheduler による定期ジョブが 2 つ。
- 洗濯天気通知: 毎日
WEATHER_NOTIFY_HOUR時(既定 22 時)に、翌日の天気と洗濯指数を配信 - どうでもいい豆知識:
USELESS_TIPS_ENABLED=trueのときだけ登録され、USELESS_TIPS_INTERVAL_HOURS(既定 3 時間)ごとに実行。深夜 3 時台はスキップ
- 洗濯天気通知: 毎日
- どちらも
POST /api/system/weather-notify/test/POST /api/system/useless-tip/testで即時実行して確認できる。
12. 天気予報
- 気象庁の無料 JSON API から明日の予報を取り、天気文言と降水確率から洗濯指数を判定する (雨雪なら室内干し、降水確率 30% 以下なら外干し OK、それ以上は室内干し推奨)。
- 取得した最新データはメモリ上にキャッシュされ、画面右上の時計の横にも表示される(
GET /api/system/weather/latest)。 - 対象地域は
WEATHER_AREA_CODE(既定 120000=千葉県)とWEATHER_AREA_KEYWORD(既定「北西部」)で指定する。
13-14. モデル選択と設定画面
- モデルのプルダウンは
model_presetsテーブル由来で、初回起動時に既定の一覧がシードされる。 設定画面からラベル/モデル文字列を追加・編集・削除できる(重複は 400 で弾く)。 - 設定画面では API キー類を再起動なしで変更できる。
- 変更できるのは許可リストにあるキーだけ(
OPENAI_API_KEY/ANTHROPIC_API_KEY/ AWS 系 /OLLAMA_API_BASE/TAVILY_API_KEY/LOCAL_KB_EMBEDDING_MODEL) - 値は末尾 4 文字だけを見せるマスク表示。空欄で保存すると「変更なし」扱い
- 保存すると
app_settingsテーブルに入りos.environに反映される。削除すると.env由来の起動時の値に戻る
- 変更できるのは許可リストにあるキーだけ(
15-16. ログビューアとシステムモニタ
- LLM 呼び出しは成功・失敗どちらも
api_logsに記録される(モデル・リクエスト全文・応答・ レイテンシ・エラー)。ログパネルからセッション別/成功失敗別に絞り込み、個別削除ができる。 - サイドバーの監視パネルは CPU 使用率・メモリ使用量を 10 秒ごとに更新する。
pynvmlが使える環境なら GPU メモリも表示する(無ければその欄が出ないだけ)。
17. 認証
パスワードが 2 種類あり、役割が違う。
| 変数 | 守る対象 | 方式 |
|---|---|---|
SITE_PASSWORD |
チャット含むサイト全体 | ログイン画面 → HMAC 署名付きセッション Cookie(既定 30 日) |
SETTINGS_PASSWORD |
設定画面と モデルプリセットの更新系のみ | HTTP Basic(リクエストごとに送る) |
- どちらも未設定時の挙動が逆なので注意。
SITE_PASSWORD未設定=誰でも使える、SETTINGS_PASSWORD未設定=設定画面は常に 403(空パスワードでの突破を防ぐため)。 SESSION_SECRET_KEYを設定しないと起動ごとに鍵が作り直され、再起動のたびに全員ログインし直しになる。- ローカルの http 環境でログインが効かない場合のみ
SESSION_COOKIE_SECURE=falseにする。
18. デプロイ
docker-compose.ymlに backend(FastAPI)/ frontend(nginx で静的配信+API リバースプロキシ)/ caddy(Let's Encrypt 自動取得と HTTPS 終端)の 3 コンテナが定義済み。- SQLite と
knowledge_baseは Docker ボリュームに永続化される。 - フロントは API をすべて相対パスで呼ぶため、同一オリジン配信なら CORS 設定は実質不要。
制約・注意点
このアプリを触るときに引っかかりやすい点。
- 1 応答あたり LLM 呼び出しは最低 2 回。ツールを使わない会話でも
resolve_tool_calls()が フルコンテキストを 1 回投げてから本番生成に入る。 - トークン数ベースの制御が一切ない。すべて件数・文字数ベースなので、層 3(Markdown 全文 ×3)は 会話が短くてもコンテキストを大きく押し上げる(層 1 は見出しのみなので軽い)。
- 長期メモリに上限がない。長いセッションでは要約が積み上がり続ける(要約の再要約はしない)。
- 要約自体は不可逆。アーカイブされた元メッセージは残るが、要約をやり直したり元の状態に戻す手段は無い。
- ワードプールは揮発性。再起動でリセットされ、直近 200 件から作り直される。
- DB マイグレーションは簡易実装。Alembic は依存に入っているが使っておらず、起動時に
PRAGMA table_infoを見て不足カラムをALTER TABLEで足すだけ(SQLite 前提)。 - 自動テストは無い。
pytestは dev 依存に入っているがbackend/tests/は存在しない。