Tabs can now be dragged to reorder within a group and to move between sections in one gesture: dropping on a tab's left/right half inserts before/after it and adopts that tab's section, while a dedicated zone past the last group ungroups and appends at the end. A blue indicator line shows the drop position while dragging. core/notes.reorder() takes the full new page-id order computed client-side (NotePanel.vue has the reactive list already) rather than recomputing positions server-side, validates it against the current page-id set, sets the moved page's section, then renumbers position. POST /api/notes/reorder exposes it. Also adds docs/notes-guide.md, an end-user walkthrough of the shared notes panel (pages, sections, drag-and-drop, AI interaction) — the existing docs were all developer-facing specs with no operating instructions for the feature itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
208 lines
14 KiB
Markdown
208 lines
14 KiB
Markdown
# アーキテクチャ概要
|
||
|
||
## 全体構成
|
||
|
||
```
|
||
フロントエンド (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` でタブをグループ化) |
|
||
| `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` の**要約全文**+出典 URL | `knowledge.get_knowledge_context()` | 最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件 |
|
||
| 2 | 共有ノート | ページの**タイトル一覧+本文先頭 40 文字**のみ | `notes.get_notes_context()` | 全ページ(本文は入れない) |
|
||
| 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 より優先される。
|
||
|
||
#### 要約(長期メモリ化)
|
||
|
||
`maybe_summarize()` がアシスタント応答の保存直後に走る。
|
||
|
||
1. メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)以上になったら発火
|
||
2. 古い側の半分(既定 10 件)を LLM で要約し `memories` に 1 行保存
|
||
3. **要約した元メッセージは DB から物理削除**する(復元不可。エクスポートしても生ログは残らない)
|
||
4. 保存した要約に `tagging.tag_memory()` でタグを付ける
|
||
|
||
#### ツール経由の遅延読み込み
|
||
|
||
常時注入すると重い情報は、システムプロンプトに目次だけ置いてツールで取りに行かせる。
|
||
`llm.resolve_tool_calls()` が本番のストリーミング生成の**前に**別途 LLM を呼んでツール呼び出しを解決する(最大 2 ラウンド)。
|
||
|
||
- `read_note` / `write_note` … 共有ノート本文の読み書き(`db` を渡したときだけモデルに提示)
|
||
- `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 件のメッセージから再シードされる)
|
||
|
||
#### 設計上の注意点
|
||
|
||
- トークン数ベースの制御は無く、すべて件数・文字数ベース。層 1(知識 15 件の要約全文)と層 3(Markdown 全文 ×3)は
|
||
会話が短くてもコンテキストを大きく押し上げる
|
||
- ツールを使わない会話でも `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` を解決し、結果を組み込んだメッセージ列を返す
|
||
- `_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":"notes_updated"} # AIが共有ノートを更新した時のみ
|
||
data: {"type":"sources","value":[{...}]} # Web検索した時のみ(出典の title/url)
|
||
data: {"type":"chunk","value":"テキスト断片"} # 0回以上
|
||
data: {"type":"done"} # 正常完了
|
||
data: {"type":"error","value":"エラー内容"} # 例外発生時
|
||
```
|
||
|
||
バッファリング防止のため `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 受信フロー
|
||
|
||
1. `sendMessage()` がユーザーメッセージと空のアシスタントメッセージを `messages` に追加
|
||
2. `streamChat()` が fetch + ReadableStream で SSE を受信
|
||
3. `chunk` イベント受信のたびに `messages.value[msgIdx].content` を更新(Vue reactive proxy 経由)
|
||
4. `done` で `pending` フラグを解除
|
||
5. `error` / ネットワークエラーは `error` フラグを立て赤バブル表示 + トースト通知
|
||
|
||
### Markdown レンダリング(`MarkdownContent.vue`)
|
||
|
||
- アシスタントメッセージのみ `marked` で HTML に変換後 `DOMPurify` でサニタイズ
|
||
- ストリーミング中(`pending=true`)は未閉じのコードフェンス(` ``` `)を自動補完してから描画
|
||
|
||
### Vite プロキシ設定
|
||
|
||
`/chat`、`/memory`、`/logs`、`/health` を `http://localhost:8000` に転送。
|
||
`/chat` は SSE バッファリング無効化のため `configure` で `x-accel-buffering: no` を付与。
|