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>
10 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
プロジェクト概要
Python (FastAPI) バックエンド + Vue 3 (TypeScript) フロントエンドのチャットアプリ。
LLM は LiteLLM 経由で OpenAI / Anthropic / Ollama など任意のプロバイダーを統一 API で扱い、会話履歴とメモリは SQLite に永続化する。
コマンド
バックエンド
Set-Location backend
# 依存インストール (SSL プロキシ環境では --native-tls が必要)
uv sync --native-tls
# 開発サーバー起動
uv run uvicorn app.main:app --reload
# テスト
pytest
pytest tests/test_chat.py # 単一ファイル
フロントエンド
Set-Location frontend
# 依存インストール
npm install
# 開発サーバー起動 (port 5173)
npm run dev
# ビルド
npm run build
# 型チェック
npm run type-check
環境設定
Set-Location backend
Copy-Item .env.example .env
# .env を編集して API キーとモデル名を設定
アーキテクチャ
詳細は docs/architecture.md を参照。
frontend (Vue3 + Pinia, :5173)
↕ REST + SSE (Vite proxy)
backend (FastAPI, :8000)
├── app/api/ チャット・メモリ・ノート・ログ・設定 のルーター
├── app/core/ LLM ラッパー・共有ノート・設定
│ ├── memory_kb/ コンテキスト管理(要約・蓄積知識・ローカルKB・タグ付け)
│ ├── search/ Tavily Web 検索
│ └── discord/ Discord Bot・定期通知
└── app/db/ SQLAlchemy モデル・DB 接続
↕
SQLite (playchat.db) + LiteLLM → 任意の LLM
コンテキスト管理
LLM に渡すコンテキストは app/core/memory_kb/memory.py の get_context_messages() が毎リクエスト組み立てる。
以下の順に system メッセージを積み、最後に会話履歴を連結する(詳細は docs/architecture.md)。
- 蓄積知識 (
memory_kb/knowledge.py): 過去の Web 検索で得たknowledge_entriesの見出し一覧のみ(- 「クエリ」: 一行要約)。本文と出典は含めず、必要ならread_knowledgeツールで読み出す。最新KNOWLEDGE_CONTEXT_LIMIT(既定 15)件。セッションを横断する - 共有ノート (
core/notes.py): タイトル一覧+本文先頭 40 文字のプレビューのみ - ローカル KB (
memory_kb/local_knowledge.py):backend/knowledge_base/**.mdのうち、直近ユーザー発言と埋め込みコサイン類似度が高い上位LOCAL_KB_TOP_K(既定 3)件かつLOCAL_KB_MIN_SCORE(既定 0.75)以上を全文注入。プロンプト上は Web 検索結果より優先する扱い - 長期メモリ:
memoriesの要約を全件連結。上限なし - 短期メモリ: セッション内の
messages全件(要約で削除された分を除く)
要約: maybe_summarize() がアシスタント応答の保存直後に走り、メッセージ数が MEMORY_SUMMARIZE_THRESHOLD(既定 20)以上になると古い半分を LLM で要約して memories に保存する。要約した元メッセージは archived_at を立ててアーカイブされる(物理削除はしない)。アーカイブ済みメッセージは get_context_messages() と要約の閾値判定の両方から除外されるため LLM には渡らないが、DB には残り、履歴表示・エクスポートからは参照できる。memory_id にどの要約に取り込まれたかを保持する。
遅延読み込み: 重い情報は目次だけ注入し、llm.stream_tool_calls()(結果だけ欲しい場合は同等のラッパー resolve_tool_calls())が本番生成の前にツール(web_search / read_note / write_note / read_knowledge、最大 2 ラウンド)を解決する。前者は実行の開始・完了を逐次 yield し、api/chat.py がそれを SSE の tool イベントとして中継して GUI に進行状況を出す。Web 検索の結果は構造化要約にされ knowledge_entries と knowledge_base/web_search/*.md の両方に保存されるため、次回以降は層 1・層 3 として自動的に効いてくる。
付随機能: memory_kb/tagging.py が各メッセージ・要約にバックグラウンドでタグを 3 つ付ける(コンテキストには注入せず、Discord 雑談 Bot の話題選びに使う)。memory_kb/word_memory.py は Janome で抽出した名詞を deque(maxlen=200) にプロセス内保持する(DB 非永続。起動時に直近 200 件から再シード)。
変更時の注意: トークン数ベースの制御は一切なく、すべて件数・文字数ベース。またツールを使わない会話でも resolve_tool_calls() がフルコンテキストを 1 回投げるため、1 応答あたり LLM 呼び出しは最低 2 回になる。
共有ノート
ユーザーと AI が共同編集するノート。note_pages テーブルに 1 ページ 1 行で保存し、UI ではタブとして切り替える。
- モデルから見える範囲: システムプロンプトに入るのはページのタイトル一覧(と本文の先頭 40 文字程度のプレビュー)だけ。本文全体は含めない
- モデルによる読み書き:
read_note/write_noteツール(app/core/notes.py)で行う。write_noteは存在しないタイトルを指定するとページを新規作成し、modeがappend(既定)なら追記、replaceなら全置換 - ツール実行の流れ:
llm.resolve_tool_calls()が本番の応答生成前にツール呼び出しを解決する。web_searchと共通の仕組みで、ノート系ツールはdbが渡されたときだけモデルに提示される - UI への反映: AI がノートを書き換えた場合、SSE で
notes_updatedを送ってフロント側のノートパネルを再読み込みさせる - 編集の衝突回避: フロントは未保存の編集があるページ(
dirtyIds)をサーバーの値で上書きしない - セクションによるグルーピング:
note_pages.sectionにセクション名を持たせ、同じセクションのページはposition上で常に連続させる(core/notes.set_section()が割り当て時に並べ替える)。タブバーはposition順に描画し、隣接して同じsectionが続く区間を1グループとして表示する。get_notes_context()はセクションごとに## セクション名の見出しを付けてタイトル一覧を出す。セクション名の一括変更はPOST /api/notes/sections/rename(core/notes.rename_section())
SSE ストリーミング
POST /chat/stream が SSE を返す。イベント種別: session_id(新規セッション ID)/ chunk(テキスト断片)/ tool(ツール実行の開始・完了)/ sources(Web 検索の出典)/ notes_updated(AI が共有ノートを更新)/ done(完了)/ error(エラー)。
フロントは src/api/client.ts の streamChat() で fetch + ReadableStream により受信し、messages.value[msgIdx] を reactive proxy 経由で更新する。
Markdown レンダリング
アシスタントメッセージは marked + DOMPurify でレンダリング。ストリーミング中の未閉じコードフェンスは自動補完してから描画する(MarkdownContent.vue)。
モデル切替
.env の DEFAULT_MODEL またはリクエストの model フィールドで指定。
ローカル Ollama は ollama/llama3 形式、OLLAMA_API_BASE でエンドポイントを設定。
DEBUG=true で LiteLLM の詳細ログが有効になる。
ローカル開発環境メモ
- このマシンでは PostgreSQL がポート 8080 で起動している(本アプリの
DATABASE_URLには使用していないが、ポート競合時は確認すること) - このマシンには TLS 監視プロキシらしき設定があり、環境変数
SSLKEYLOGFILEが名前付きパイプ(nllMonFltProxy)を指している。これが原因でlitellm(OpenSSL を直接ロード)やpyright(内部で Node.js サブプロセスを起動)がOPENSSL_Uplinkエラーでクラッシュすることがある。発生した場合は当該コマンド実行時のみSSLKEYLOGFILEを unset すると回避できるRemove-Item Env:\SSLKEYLOGFILE uv run pyright
環境変数(backend/.env)
| 変数 | デフォルト | 説明 |
|---|---|---|
DEFAULT_MODEL |
gpt-4o |
デフォルト LLM モデル(.env.example では gpt-5.4) |
DATABASE_URL |
sqlite:///./playchat.db |
DB 接続先 |
MEMORY_SUMMARIZE_THRESHOLD |
20 |
長期メモリ化するメッセージ数の閾値 |
KNOWLEDGE_CONTEXT_LIMIT |
15 |
蓄積知識をシステムプロンプトに注入する最大件数 |
TAVILY_API_KEY |
— | Tavily Web 検索の API キー(未設定なら web_search は空を返す) |
SEARCH_SUMMARY_MODEL |
— | Web 検索結果の要約に使うモデル(未設定なら DEFAULT_MODEL) |
TAGGING_MODEL |
— | メッセージの自動タグ付けに使うモデル(未設定なら DEFAULT_MODEL) |
LOCAL_KB_DIR |
knowledge_base |
ローカル Markdown ナレッジベースのディレクトリ |
LOCAL_KB_EMBEDDING_MODEL |
text-embedding-3-small |
ローカル KB の埋め込みモデル(GUI 設定画面から実行時変更可) |
LOCAL_KB_TOP_K |
3 |
ローカル KB から注入する最大ファイル数 |
LOCAL_KB_MIN_SCORE |
0.75 |
ローカル KB を注入するコサイン類似度の下限 |
CORS_ORIGINS |
http://localhost:5173 |
許可する CORS オリジン |
DEBUG |
false |
LiteLLM デバッグログの有効化 |
OPENAI_API_KEY |
— | OpenAI API キー |
ANTHROPIC_API_KEY |
— | Anthropic API キー |
OLLAMA_API_BASE |
http://localhost:11434 |
Ollama エンドポイント |
NOTES_TOOL_CONTENT_LIMIT |
4000 |
read_note が 1 回に返す本文の最大文字数 |
KNOWLEDGE_TOOL_CONTENT_LIMIT |
4000 |
read_knowledge が 1 回に返す本文の最大文字数 |
SITE_PASSWORD |
— | サイト全体を保護する共有パスワード(未設定なら無認証) |
SESSION_SECRET_KEY |
起動毎に自動生成 | ログインセッション Cookie の署名鍵 |