17 KiB
アーキテクチャ概要
全体構成
フロントエンド (Vue 3 + TypeScript + Vite, :5173)
↕ REST + SSE (Vite dev proxy 経由)
バックエンド (FastAPI + Python, :8000)
↕ SQLAlchemy
SQLite (playchat.db)
↕ LiteLLM
OpenAI / Anthropic / Ollama など任意の LLM
バックエンド
ディレクトリ構成
backend/
├── app/
│ ├── main.py # FastAPI インスタンス、CORS、ルーター登録、DB初期化、起動時シード
│ ├── api/
│ │ ├── chat.py # チャット・セッション関連エンドポイント(SSE / export / import)
│ │ ├── memory.py # 長期メモリ参照・削除
│ │ ├── notes.py # 共有ノート CRUD
│ │ ├── models.py # モデルプリセット CRUD
│ │ ├── settings.py # GUI 設定画面(APIキー等)
│ │ ├── system.py # タグ backfill・ワードプール参照などの運用系
│ │ ├── auth.py # サイトログイン
│ │ └── logs.py # API ログ参照・削除
│ ├── core/
│ │ ├── config.py # .env 読み込み・定数定義
│ │ ├── llm.py # LiteLLM ラッパー(ストリーミング・埋め込み・ツール解決・ログ記録)
│ │ ├── notes.py # 共有ノート(タイトル一覧の注入 / read_note・write_note ツール)
│ │ ├── memory_kb/ # コンテキスト管理まわり一式
│ │ │ ├── memory.py # コンテキスト合成・要約(旧 core/memory.py)
│ │ │ ├── knowledge.py # Web検索で得た知識の蓄積・要約・再生成
│ │ │ ├── local_knowledge.py # ローカル Markdown KB の埋め込み・類似検索
│ │ │ ├── tagging.py # メッセージ・要約への自動タグ付け
│ │ │ └── word_memory.py # 形態素解析による単語プール(Discord Bot 用)
│ │ ├── search/
│ │ │ └── websearch.py # Tavily による web_search ツール
│ │ ├── discord/ # Discord Bot・定期通知
│ │ ├── settings_store.py # GUI 設定を os.environ へ反映
│ │ └── auth.py / site_auth.py # 設定画面パスワード・サイトログイン
│ └── db/
│ ├── database.py # SQLite 接続・セッションファクトリ・テーブル自動作成
│ └── models.py # SQLAlchemy モデル定義
├── knowledge_base/ # ローカルナレッジベース(Markdown。web_search/ 配下は自動生成)
└── pyproject.toml
DB テーブル
| テーブル | 主なカラム | 用途 |
|---|---|---|
sessions |
id (UUID), title, model, created_at, updated_at | チャットセッション |
messages |
id, session_id, role, content, created_at | 会話メッセージ(短期メモリ) |
message_tags |
id, message_id, tag, created_at | メッセージへの自動タグ |
memories |
id, session_id, summary, embedding_json, created_at | 要約による長期メモリ |
memory_tags |
id, memory_id, tag, created_at | 要約への自動タグ |
knowledge_entries |
id, query, summary, created_at | Web 検索で得た知識(セッション横断) |
knowledge_sources |
id, entry_id, title, url | 上記知識の出典 URL |
local_knowledge_docs |
id, path, title, content_hash, embedding_json, updated_at | ローカル Markdown KB の埋め込みキャッシュ |
note_pages |
id, title, content, position, section, created_at, updated_at | ユーザーと AI が共同編集する共有ノートのページ(section でタブをグループ化) |
session_note_pages |
id, session_id, page_id, created_at | 「このセッションではこのページの本文をプロンプトに載せる」指定(session_id × page_id は一意) |
app_settings |
key, value, updated_at | GUI 設定画面から編集した接続情報 |
model_presets |
id, label, value, created_at | モデル選択プルダウンの選択肢 |
api_logs |
id, session_id, model, request_json, response_text, error, latency_ms, created_at | LLM API 呼び出しログ |
コンテキスト管理(core/memory_kb/)
LLM に渡すコンテキストは memory_kb/memory.py の get_context_messages() が毎リクエスト組み立てる。
以下の順に system メッセージを積み、最後に会話履歴を連結する。
| # | 層 | 中身 | 実装 | 量の制御 |
|---|---|---|---|---|
| 1 | 蓄積知識 | 過去の Web 検索で得た knowledge_entries の見出し一覧のみ(本文・出典は含めない) |
knowledge.get_knowledge_context() |
最新 KNOWLEDGE_CONTEXT_LIMIT(既定 15)件 |
| 2 | 共有ノート | ページのタイトル一覧+本文先頭 40 文字のみ | notes.get_notes_context() |
全ページ(本文は入れない) |
| 2.5 | 参照指定ノート | ユーザーがこのセッションに紐付けたページの本文全文 | notes.get_session_notes_context() |
紐付いたページのみ。1 ページ SESSION_NOTES_CONTENT_LIMIT(既定 8000)文字で打ち切り |
| 3 | ローカル KB | knowledge_base/**.md から選ばれたファイル全文 |
local_knowledge.get_local_kb_context() |
直近ユーザー発言との cos 類似度上位 LOCAL_KB_TOP_K(既定 3)件、かつ LOCAL_KB_MIN_SCORE(既定 0.75)以上 |
| 4 | 長期メモリ | memories の要約を全件連結 |
memory.get_context_messages() |
上限なし |
| 5 | 短期メモリ | セッション内の messages 全件(要約済みで削除された分を除く) |
同上 | 上限なし(要約で間接的に制御) |
層 3 のプロンプトは「Web 検索結果より優先度が高い」「これで答えられるなら検索するな」と明示しており、層 1 より優先される。
層 2.5 は「重い本文はツールで取りに行かせる」という原則の唯一の例外で、ユーザーが明示的に選んだページだけが対象。
session_note_pages に行がある間だけ効き、GET/PUT /chat/sessions/{id}/notes で読み書きする。
新規セッションではフロントがまだ session_id を持てないため、POST /chat/stream の note_page_ids
でも同じ指定を送れるようにしてあり、コンテキストを組み立てる前にサーバー側で反映される。
層 2 の一覧では紐付け済みのページに印が付き、同じ本文を read_note で読み直させないようにしている。
要約(長期メモリ化)
maybe_summarize() がアシスタント応答の保存直後に走る。
- メッセージ数が
MEMORY_SUMMARIZE_THRESHOLD(既定 20)以上になったら発火 - 古い側の半分(既定 10 件)を LLM で要約し
memoriesに 1 行保存 - 要約した元メッセージは
archived_at/memory_idを立ててアーカイブする(物理削除はしない)。LLM に渡すコンテキストからも、次回以降の要約対象からも外れるが、DB には残るため履歴表示・エクスポートからは読める - 保存した要約に
tagging.tag_memory()でタグを付ける
ツール経由の遅延読み込み
常時注入すると重い情報は、システムプロンプトに目次だけ置いてツールで取りに行かせる。
llm.resolve_tool_calls() が本番のストリーミング生成の前に別途 LLM を呼んでツール呼び出しを解決する(最大 2 ラウンド)。
read_note/write_note… 共有ノート本文の読み書き(dbを渡したときだけモデルに提示)read_knowledge… 層 1 の見出し一覧に出ている知識の本文を読む(同上)。層 3 として本文が既に注入されていれば呼ぶ必要はないweb_search… Tavily 検索。結果はknowledge.summarize_results()で構造化要約にされ、knowledge_entriesとknowledge_base/web_search/*.mdの両方に保存される。 → 次回以降の層 1・層 3 に自動的に効いてくる
自動タグ付け・ワードプール
tagging.py… 各メッセージにasyncio.create_task()でバックグラウンドにタグを 3 つ生成する。 コンテキスト注入には使わず、Discord 雑談 Bot の話題選び(find_related_tag())に使うword_memory.py… Janome で名詞を抽出しdeque(maxlen=200)にプロセス内保持する。 DB に永続化されず再起動で消える(起動時に直近 200 件のメッセージから再シードされる)
設計上の注意点
- トークン数ベースの制御は無く、すべて件数・文字数ベース。層 3(Markdown 全文 ×3)は会話が短くても コンテキストを大きく押し上げる(層 1 は見出しのみなので件数を増やしても軽い)
- ツールを使わない会話でも
resolve_tool_calls()が必ず 1 回フルコンテキストを投げるため、LLM 呼び出しは最低 2 回になる - 層 4(長期メモリ)に上限が無く、長いセッションでは要約が積み上がる(要約の再要約はしない)
get_local_kb_context()が毎リクエストでsync_local_kb()(ディレクトリスキャン)と埋め込み API 呼び出しを 1 回行う
LLM 呼び出し(core/llm.py)
stream_chat()/complete()/embed()が LiteLLM を薄くラップresolve_tool_calls()がweb_search/read_note/write_note/read_knowledgeを解決し、結果を組み込んだメッセージ列を返す_log_contextで呼び出しを囲み、成功・失敗どちらもapi_logsテーブルに記録(embed()は対象外)DEBUG=trueでlitellm._turn_on_debug()が有効になり詳細ログが出力される
共有ノート(core/notes.py / api/notes.py)
ユーザーと AI が共同編集するノート。note_pages の 1 行が UI の 1 タブに対応する。
利用者向けの操作方法は docs/notes-guide.md を参照。
get_notes_context()がタイトル一覧のみをシステムメッセージとして生成し、get_context_messages()が会話コンテキストに差し込む(本文は含めない)。セクションがあれば## セクション名の見出しでグループ化して提示する- 本文の読み書きはツール経由:
read_note(本文取得)/write_note(追記・置換・新規作成) llm.resolve_tool_calls()がweb_searchと同じループでこれらを解決する。ノート系ツールはdbを渡したときのみモデルに提示される- REST(
/api/notes)はフロントのノートパネル用。GET 一覧 / POST 作成 / PATCH 更新 / DELETE 削除 /POST /api/notes/sections/rename(セクション名の一括変更)/POST /api/notes/reorder(ドラッグ&ドロップの並べ替え) - セクションによるグルーピング:
note_pages.sectionに名前を持たせ、同じセクションのページは常にposition上で連続させる。notes.set_section()がページのセクションを変更するたびに全ページを再採番し、同じセクションの末尾に挿入し直す(隣接=同一グループという前提をタブ描画側・システムプロンプト側の両方が使っている)。notes.rename_section()は既に連続しているページ群のラベルだけを一括で書き換える - ドラッグ&ドロップの並べ替え:
notes.reorder()はset_section()と違い、位置の計算をサーバー側ではなくフロント(NotePanel.vue)に任せる。フロントがドラッグ後の全ページIDの順序を計算し、{ page_id, section, order }として一括送信、サーバーは検証(orderが現在の全ページID集合と一致するか)した上でそのままpositionに反映する。ドロップ先タブのsectionを渡すことで、並べ替えと同時にセクション間移動も1リクエストで完結する - 既存 DB への追加カラムは Alembic 等の仕組みを使っておらず、
db/database.pyの_add_missing_columns()が起動時にPRAGMA table_infoを見てALTER TABLE ... ADD COLUMNを素朴に実行する(SQLite 専用の簡易マイグレーション)
SSE ストリーミング(api/chat.py)
POST /chat/stream は StreamingResponse で以下のイベントを順番に送出する:
data: {"type":"session_id","value":"<uuid>"} # 新規セッション時のみ
data: {"type":"tool","value":{...}} # ツール実行ごとに開始・完了の2回
data: {"type":"notes_updated"} # AIが共有ノートを更新した時のみ
data: {"type":"sources","value":[{...}]} # Web検索した時のみ(出典の title/url)
data: {"type":"chunk","value":"テキスト断片"} # 0回以上
data: {"type":"done"} # 正常完了
data: {"type":"error","value":"エラー内容"} # 例外発生時
tool イベントは llm.stream_tool_calls()(resolve_tool_calls() の逐次版)が yield する進行状況を
そのまま中継したもので、{id, name, label, detail, status} で始まり、同じ id の
{id, status:"done", result} で終わる。検索結果の要約・蓄積知識への保存も
id: "save_knowledge" の同形式のイベントとして送る。フロント(ToolActivityList.vue)は
id をキーにマージし、アシスタントバブルの上に実行中スピナーつきで一覧表示する。
表示専用なので DB には保存せず、履歴を読み直すと消える。
バッファリング防止のため Cache-Control: no-cache と X-Accel-Buffering: no を付与する。
フロントエンド
ディレクトリ構成
frontend/src/
├── api/
│ └── client.ts # fetch ラッパー・SSE クライアント・型定義
├── stores/
│ └── chat.ts # Pinia ストア(全アプリ状態)
├── components/
│ ├── SessionList.vue # セッション一覧サイドバー
│ ├── ChatWindow.vue # メッセージ表示エリア
│ ├── MessageInput.vue # 入力欄・送信
│ ├── ModelSelector.vue # LLM モデル切替セレクト
│ ├── MarkdownContent.vue # marked + DOMPurify によるレンダリング
│ ├── LogViewer.vue # API ログ閲覧パネル
│ └── ToastList.vue # エラートースト通知
└── App.vue # ルートレイアウト
状態管理(stores/chat.ts)
| 状態 | 型 | 説明 |
|---|---|---|
sessions |
SessionInfo[] |
セッション一覧 |
currentSessionId |
string | null |
選択中セッション |
messages |
Message[] |
表示中メッセージ |
isStreaming |
boolean |
SSE 受信中フラグ |
selectedModel |
string |
選択中 LLM モデル |
toasts |
Toast[] |
エラー通知キュー |
SSE 受信フロー
sendMessage()がユーザーメッセージと空のアシスタントメッセージをmessagesに追加streamChat()が fetch + ReadableStream で SSE を受信chunkイベント受信のたびにmessages.value[msgIdx].contentを更新(Vue reactive proxy 経由)doneでpendingフラグを解除error/ ネットワークエラーはerrorフラグを立て赤バブル表示 + トースト通知
Markdown レンダリング(MarkdownContent.vue)
- アシスタントメッセージのみ
markedで HTML に変換後DOMPurifyでサニタイズ - ストリーミング中(
pending=true)は未閉じのコードフェンス(```)を自動補完してから描画 - 数式は
src/utils/math.tsの marked 拡張が KaTeX で描画する(mathExtension/mathSanitizeConfig)- ブロック: 行頭の
$$ ... $$と\[ ... \]。インライン:$ ... $、\( ... \)、行中の$$ ... $$ $ ... $は誤爆防止のため「開き$の直後が空白でない」「中身に改行を含まない」「閉じ$の直後が数字でない」を満たすものだけを数式にする($100 から $200のような金額表記は素通しする)。リテラルのドル記号は\$でエスケープできる- インラインコード・コードフェンス内の
$は marked のトークナイザ順により数式にならない - 構文エラーは例外にせず KaTeX の赤字表示にフォールバックする(
throwOnError: false) - KaTeX の MathML 出力に含まれる
semantics/annotationは DOMPurify の既定では許可されないため、mathSanitizeConfigで明示的に許可している - ストリーミング途中で閉じ記号が未到達の数式は、閉じるまでプレーンテキストとして表示される
- ブロック: 行頭の
Vite プロキシ設定
/chat、/memory、/logs、/health を http://localhost:8000 に転送。
/chat は SSE バッファリング無効化のため configure で x-accel-buffering: no を付与。