Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae997914c2 | ||
|
|
caeeb5e921 | ||
|
|
c1aba42cf8 | ||
|
|
3b30dab650 | ||
|
|
1b88d23f21 | ||
|
|
904458e7df | ||
|
|
97568ba90e | ||
|
|
52dfc0c349 | ||
|
|
cd38cbf19d | ||
|
|
2ced2e3cd5 | ||
|
|
f085db421c | ||
|
|
5d63a59b86 | ||
|
|
8f9458170e | ||
|
|
4882d291be | ||
|
|
6f9fc10fa1 | ||
|
|
0e58a07af4 | ||
|
|
216d38c7bd | ||
|
|
f89bc9bc9a | ||
|
|
366320c43a | ||
|
|
8cac5d0de0 | ||
|
|
762026aa2c | ||
|
|
2ee7238afb | ||
|
|
be3af44891 |
@@ -15,6 +15,11 @@
|
||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:8000/health --max-time 5)",
|
||||
"Bash(curl -s -N -X POST http://localhost:8000/chat/stream -H \"Content-Type: application/json\" -d '{\"message\":\"latest news about python 3.14 release\",\"model\":\"bedrock/jp.anthropic.claude-sonnet-4-6\"}' --max-time 60)",
|
||||
"Bash(curl -s -N -X POST http://localhost:8000/chat/stream -H \"Content-Type: application/json\" -d '{\"message\":\"latest news about python 3.14 release\",\"model\":\"bedrock/jp.anthropic.claude-sonnet-4-6\"}' --max-time 90)"
|
||||
],
|
||||
"deny": [
|
||||
"Read(**/.env)",
|
||||
"Edit(**/.env)",
|
||||
"Bash(*.env)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ Copy-Item .env.example .env
|
||||
frontend (Vue3 + Pinia, :5173)
|
||||
↕ REST + SSE (Vite proxy)
|
||||
backend (FastAPI, :8000)
|
||||
├── app/api/ チャット・メモリ・ノート・ログ・設定 のルーター
|
||||
├── app/api/ チャット・メモリ・ノート・ナレッジベース閲覧・ログ・設定 のルーター
|
||||
├── app/core/ LLM ラッパー・共有ノート・設定
|
||||
│ ├── memory_kb/ コンテキスト管理(要約・蓄積知識・ローカルKB・タグ付け)
|
||||
│ ├── search/ Tavily Web 検索
|
||||
@@ -74,15 +74,16 @@ SQLite (playchat.db) + LiteLLM → 任意の LLM
|
||||
LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_context_messages()` が毎リクエスト組み立てる。
|
||||
以下の順に system メッセージを積み、最後に会話履歴を連結する(詳細は `docs/architecture.md`)。
|
||||
|
||||
1. **蓄積知識** (`memory_kb/knowledge.py`): 過去の Web 検索で得た `knowledge_entries` の要約全文+出典。最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件。セッションを横断する
|
||||
1. **蓄積知識** (`memory_kb/knowledge.py`): 過去の Web 検索で得た `knowledge_entries` の**見出し一覧のみ**(`- 「クエリ」: 一行要約`)。本文と出典は含めず、必要なら `read_knowledge` ツールで読み出す。最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件。セッションを横断する
|
||||
2. **共有ノート** (`core/notes.py`): タイトル一覧+本文先頭 40 文字のプレビューのみ
|
||||
3. **ローカル KB** (`memory_kb/local_knowledge.py`): `backend/knowledge_base/**.md` のうち、直近ユーザー発言と埋め込みコサイン類似度が高い上位 `LOCAL_KB_TOP_K`(既定 3)件かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上を**全文**注入。プロンプト上は Web 検索結果より優先する扱い
|
||||
2.5. **参照指定ノート** (`core/notes.py`): ユーザーがそのセッションに紐付けた(GUI のタブで 📌 を押した)ページの**本文全文**。`session_note_pages` テーブルで管理し、1 ページ `SESSION_NOTES_CONTENT_LIMIT`(既定 8000)文字で打ち切る。層 2 の一覧側には印が付き、同じ本文を `read_note` で読み直させない
|
||||
3. **ローカル KB** (`memory_kb/local_knowledge.py`): `backend/knowledge_base/**.md` のうち、直近ユーザー発言と埋め込みコサイン類似度が高い上位 `LOCAL_KB_TOP_K`(既定 3)件かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上を**全文**注入。プロンプト上は Web 検索結果より優先する扱い。中身は GUI ヘッダーのパネルメニュー →「ナレッジベース」で開くサイドパネル(`api/knowledge_base.py` + `KnowledgeBasePanel.vue`、`GET /api/kb/files` と `GET /api/kb/file?path=`)から閲覧できる(読み取り専用)。同パネルで埋め込みの状態確認・接続テスト・再埋め込みもできる(`GET /api/kb/embedding`、`POST /api/kb/embedding/test`、`POST /api/kb/embedding/reindex`)。埋め込みは `local_knowledge_docs.embedding_model` に使用モデルを持ち、モデルを変えた行は次の同期で埋め直される
|
||||
4. **長期メモリ**: `memories` の要約を全件連結。上限なし
|
||||
5. **短期メモリ**: セッション内の `messages` 全件(要約で削除された分を除く)
|
||||
|
||||
**要約**: `maybe_summarize()` がアシスタント応答の保存直後に走り、メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)以上になると古い半分を LLM で要約して `memories` に保存する。**要約した元メッセージは物理削除される**(復元不可)。
|
||||
**要約**: `maybe_summarize()` がアシスタント応答の保存直後に走り、メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)以上になると古い半分を LLM で要約して `memories` に保存する。**要約した元メッセージは `archived_at` を立ててアーカイブされる**(物理削除はしない)。アーカイブ済みメッセージは `get_context_messages()` と要約の閾値判定の両方から除外されるため LLM には渡らないが、DB には残り、履歴表示・エクスポートからは参照できる。`memory_id` にどの要約に取り込まれたかを保持する。
|
||||
|
||||
**遅延読み込み**: 重い情報は目次だけ注入し、`llm.resolve_tool_calls()` が本番生成の前にツール(`web_search` / `read_note` / `write_note`、最大 2 ラウンド)を解決する。Web 検索の結果は構造化要約にされ `knowledge_entries` と `knowledge_base/web_search/*.md` の両方に保存されるため、次回以降は層 1・層 3 として自動的に効いてくる。
|
||||
**遅延読み込み**: 重い情報は目次だけ注入し、`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 件から再シード)。
|
||||
|
||||
@@ -97,16 +98,28 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
|
||||
- **ツール実行の流れ**: `llm.resolve_tool_calls()` が本番の応答生成前にツール呼び出しを解決する。`web_search` と共通の仕組みで、ノート系ツールは `db` が渡されたときだけモデルに提示される
|
||||
- **UI への反映**: AI がノートを書き換えた場合、SSE で `notes_updated` を送ってフロント側のノートパネルを再読み込みさせる
|
||||
- **編集の衝突回避**: フロントは未保存の編集があるページ(`dirtyIds`)をサーバーの値で上書きしない
|
||||
- **チャットへの紐付け**: `session_note_pages`(session_id × page_id)に登録したページは、本文全文がそのセッションのシステムプロンプトに毎回入る。`GET/PUT /chat/sessions/{id}/notes` で読み書きする。新規セッションではフロントがまだ session_id を持てないため、`POST /chat/stream` の `note_page_ids` でも同じ指定を送れ、コンテキスト組み立て前にサーバー側で反映される
|
||||
- **取り込み**: `POST /api/notes/import`(multipart)が `.md` / `.txt` を 1 ファイル 1 ページ、`.zip` を中身ごと複数ページに変換する(ZIP 第 1 階層のフォルダ名がセクション)。タイトル衝突時は `(2)` を足して別ページを作り、対象外・サイズ超過・壊れた ZIP は黙って飛ばす。1 件も取り込めなければ 400。`python-multipart` が必要
|
||||
- **ダウンロード**: `GET /api/notes/{page_id}/export` が 1 ページを Markdown、`GET /api/notes/export` が全ページを ZIP(1 ページ 1 Markdown、セクションはフォルダ、ファイル名の通し番号で `position` 順を保持)で返す。ファイル名はサーバーが決めるのでフロントは `download` 属性を付けず `Content-Disposition`(日本語タイトルのため RFC 5987 を併記)に任せる
|
||||
- **セクションによるグルーピング**: `note_pages.section` にセクション名を持たせ、同じセクションのページは `position` 上で常に連続させる(`core/notes.set_section()` が割り当て時に並べ替える)。タブバーは `position` 順に描画し、隣接して同じ `section` が続く区間を1グループとして表示する。`get_notes_context()` はセクションごとに `## セクション名` の見出しを付けてタイトル一覧を出す。セクション名の一括変更は `POST /api/notes/sections/rename`(`core/notes.rename_section()`)
|
||||
|
||||
### Discord Bot
|
||||
|
||||
`main.py` の lifespan が Bot(メンション応答)と APScheduler(定期ジョブ)を起動する。詳細は `docs/architecture.md`。
|
||||
|
||||
- **メンション応答** (`discord/discord_bot.py`): 本文のキーワードで **リマインド → 雑談 → 天気** の順に分岐し、先にマッチしたものが返信する
|
||||
- **定期ジョブ** (`discord/scheduler.py`): 天気通知(Cron)・どうでもいいTips(Interval)・リマインド配信(Interval)の3つ
|
||||
- **リマインド** (`discord/reminders.py`): メンションで自然言語登録する**一回きり**の通知(繰り返し非対応)。LLM に `create_reminder` / `list_reminders` / `cancel_reminder` の3ツールを渡し、system メッセージに入れた現在時刻を基準に相対表現を絶対日時へ直させる。**tool calling 対応モデルが必須**。配信は Webhook ではなく Bot クライアントから登録元チャンネルへ依頼者をメンションして送るので、専用の Webhook URL 設定は不要。DB (`reminders`) は UTC で持ち、ユーザー・LLM とのやり取りはローカル時刻に直す。一覧・取り消しは依頼者の user_id で絞るため他人のリマインドには触れられない
|
||||
|
||||
### SSE ストリーミング
|
||||
|
||||
`POST /chat/stream` が SSE を返す。イベント種別: `session_id`(新規セッション ID)/ `chunk`(テキスト断片)/ `sources`(Web 検索の出典)/ `notes_updated`(AI が共有ノートを更新)/ `done`(完了)/ `error`(エラー)。
|
||||
`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`)。
|
||||
数式は `src/utils/math.ts` の marked 拡張が KaTeX で描画する(ブロック `$$...$$` / `\[...\]`、インライン `$...$` / `\(...\)`)。詳細は `docs/architecture.md` を参照。
|
||||
|
||||
### モデル切替
|
||||
|
||||
@@ -131,18 +144,36 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
|
||||
| `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`) |
|
||||
| `TAVILY_API_KEY` | — | Tavily Web 検索の API キー(未設定なら `web_search` は空を返す。GUI 設定画面から実行時変更可) |
|
||||
| `SEARCH_SUMMARY_MODEL` | — | Web 検索結果の要約に使うモデル(未設定なら `DEFAULT_MODEL`。GUI 設定画面から実行時変更可) |
|
||||
| `TAGGING_MODEL` | — | メッセージの自動タグ付けに使うモデル(未設定なら `DEFAULT_MODEL`) |
|
||||
| `LOCAL_KB_DIR` | `knowledge_base` | ローカル Markdown ナレッジベースのディレクトリ |
|
||||
| `LOCAL_KB_EMBEDDING_MODEL` | `text-embedding-3-small` | ローカル KB の埋め込みモデル(GUI 設定画面から実行時変更可) |
|
||||
| `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 デバッグログの有効化 |
|
||||
| `LOG_LEVEL` | `INFO` | アプリ側ロガーの出力レベル。`main.py` の `basicConfig` に渡す |
|
||||
| `OPENAI_API_KEY` | — | OpenAI API キー |
|
||||
| `OPENAI_API_BASE` / `OPENAI_BASE_URL` | — | OpenAI 互換エンドポイントの接続先(ローカルの llama-server / Ollama 用)。設定したままだと `gpt-*` を選んでも本家 OpenAI に繋がらない。GUI 設定画面から実行時変更可 |
|
||||
| `ANTHROPIC_API_KEY` | — | Anthropic API キー |
|
||||
| `OLLAMA_API_BASE` | `http://localhost:11434` | Ollama エンドポイント |
|
||||
| `NOTES_TOOL_CONTENT_LIMIT` | `4000` | `read_note` が 1 回に返す本文の最大文字数 |
|
||||
| `SESSION_NOTES_CONTENT_LIMIT` | `8000` | セッションに紐付けたノート 1 ページあたり、システムプロンプトに載せる本文の最大文字数 |
|
||||
| `NOTES_IMPORT_MAX_PAGES` | `100` | ノート取り込みで一度に作れるページ数の上限 |
|
||||
| `NOTES_IMPORT_MAX_BYTES` | `2000000` | 取り込む 1 ファイル(ZIP 内は 1 エントリ)あたりの最大バイト数 |
|
||||
| `KNOWLEDGE_TOOL_CONTENT_LIMIT` | `4000` | `read_knowledge` が 1 回に返す本文の最大文字数 |
|
||||
| `REMINDER_CHECK_INTERVAL_SECONDS` | `30` | 期限切れリマインドを探す間隔(秒) |
|
||||
| `REMINDER_MAX_AHEAD_DAYS` | `365` | これより先の日時のリマインドは登録を拒否する |
|
||||
| `REMINDER_LIST_LIMIT` | `20` | `list_reminders` が一度に返す件数 |
|
||||
| `REMINDER_MODEL` | — | リマインドの日時解釈に使うモデル(未設定なら `WEATHER_NOTIFY_MODEL` → `DEFAULT_MODEL`。tool calling 対応が必須) |
|
||||
| `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) |
|
||||
| `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 |
|
||||
|
||||
## AWS Lightsail環境
|
||||
|
||||
以下のコマンドで、lightsail上の環境に入ることができる。
|
||||
|
||||
```bash
|
||||
ssh lightsail-playchat
|
||||
```
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
DEFAULT_MODEL=gpt-5.4
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=sk-...
|
||||
# OpenAI互換エンドポイントに向ける場合だけ設定する(ローカルの llama-server / Ollama など)。
|
||||
# 設定したままにすると、gpt-* のモデルを選んでも接続先がそちらのままになり、
|
||||
# 本家OpenAIには繋がらない(litellm は OPENAI_API_BASE、OpenAI SDK は OPENAI_BASE_URL を見る)。
|
||||
# サーバーにデプロイする際はこの2行を消し忘れないこと。GUIの設定画面からも変更できる。
|
||||
# OPENAI_API_BASE=http://localhost:11434/v1
|
||||
# OPENAI_BASE_URL=http://localhost:11434/v1
|
||||
# Anthropic
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
# Ollama (ローカル) — APIキー不要、モデル名は ollama/llama3 形式で指定
|
||||
@@ -67,7 +73,19 @@ USELESS_TIPS_MODEL=
|
||||
# 未設定ならDISCORD_WEBHOOK_URL(天気通知と同じ)を使う
|
||||
DISCORD_WEBHOOK_URL_TIPS=
|
||||
|
||||
# Discord Bot のリマインド機能(メンションで登録し、同じチャンネルへ通知する)
|
||||
# 期限切れのリマインドを探しにいく間隔(秒)
|
||||
REMINDER_CHECK_INTERVAL_SECONDS=30
|
||||
# これより先の日時は登録を拒否する(日数)
|
||||
REMINDER_MAX_AHEAD_DAYS=365
|
||||
REMINDER_LIST_LIMIT=20
|
||||
# 日時の解釈に使うモデル。tool calling 対応モデルを指定すること
|
||||
# (未設定なら WEATHER_NOTIFY_MODEL → DEFAULT_MODEL の順にフォールバック)
|
||||
REMINDER_MODEL=
|
||||
|
||||
# アプリ設定
|
||||
DATABASE_URL=sqlite:///./playchat.db
|
||||
MEMORY_SUMMARIZE_THRESHOLD=20
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
# アプリ側ログの出力レベル(DEBUG / INFO / WARNING / ERROR)
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
# slim イメージには /usr/share/zoneinfo が無く、TZ を設定してもUTCのままになるため入れる。
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir uv
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
+62
-5
@@ -12,6 +12,7 @@ from app.core.memory_kb import memory as mem
|
||||
from app.core import llm
|
||||
from app.core.memory_kb import knowledge
|
||||
from app.core.memory_kb import local_knowledge
|
||||
from app.core import notes
|
||||
from app.core.notes import NOTE_TOOLS
|
||||
from app.core.search.websearch import WEB_SEARCH_TOOL
|
||||
from app.core.config import DEFAULT_MODEL
|
||||
@@ -25,6 +26,13 @@ class ChatRequest(BaseModel):
|
||||
session_id: str | None = None
|
||||
message: str
|
||||
model: str | None = None
|
||||
# このセッションで本文をプロンプトに載せるノートのページID。
|
||||
# 新規セッションではまだ session_id が無く PUT できないため、送信時にまとめて反映する。
|
||||
note_page_ids: list[int] | None = None
|
||||
|
||||
|
||||
class SessionNotesRequest(BaseModel):
|
||||
page_ids: list[int]
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
@@ -42,6 +50,8 @@ class MessageResponse(BaseModel):
|
||||
role: str
|
||||
content: str
|
||||
created_at: datetime
|
||||
# 要約に取り込まれ、LLMには渡らなくなったメッセージ。人間の履歴確認用に残してある。
|
||||
archived_at: datetime | None = None
|
||||
tags: list[str] = []
|
||||
|
||||
class Config:
|
||||
@@ -52,6 +62,7 @@ class ImportMessage(BaseModel):
|
||||
role: str
|
||||
content: str
|
||||
created_at: datetime | None = None
|
||||
archived_at: datetime | None = None
|
||||
tags: list[str] = []
|
||||
|
||||
|
||||
@@ -83,6 +94,7 @@ def get_messages(session_id: str, db: DBSession = Depends(get_db)):
|
||||
role=m.role,
|
||||
content=m.content,
|
||||
created_at=m.created_at,
|
||||
archived_at=m.archived_at,
|
||||
tags=[t.tag for t in m.tags],
|
||||
)
|
||||
for m in session.messages
|
||||
@@ -110,6 +122,7 @@ def export_session(session_id: str, db: DBSession = Depends(get_db)):
|
||||
"role": m.role,
|
||||
"content": m.content,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
"archived_at": m.archived_at.isoformat() if m.archived_at else None,
|
||||
"tags": [t.tag for t in m.tags],
|
||||
}
|
||||
for m in session.messages
|
||||
@@ -143,7 +156,7 @@ def import_session(req: ImportRequest, db: DBSession = Depends(get_db)):
|
||||
db.flush()
|
||||
|
||||
for m in req.messages:
|
||||
msg = Message(session_id=session.id, role=m.role, content=m.content)
|
||||
msg = Message(session_id=session.id, role=m.role, content=m.content, archived_at=m.archived_at)
|
||||
if m.created_at:
|
||||
msg.created_at = m.created_at
|
||||
db.add(msg)
|
||||
@@ -156,6 +169,22 @@ def import_session(req: ImportRequest, db: DBSession = Depends(get_db)):
|
||||
return session
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/notes")
|
||||
def get_session_notes(session_id: str, db: DBSession = Depends(get_db)):
|
||||
"""このセッションで本文をプロンプトに載せるよう指定された共有ノートのページIDを返す。"""
|
||||
if not db.get(Session, session_id):
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return {"page_ids": notes.get_attached_page_ids(db, session_id)}
|
||||
|
||||
|
||||
@router.put("/sessions/{session_id}/notes")
|
||||
def put_session_notes(session_id: str, req: SessionNotesRequest, db: DBSession = Depends(get_db)):
|
||||
"""このセッションに紐付けるノートのページIDを丸ごと置き換える。存在しないIDは無視する。"""
|
||||
if not db.get(Session, session_id):
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return {"page_ids": notes.set_attached_pages(db, session_id, req.page_ids)}
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}", status_code=204)
|
||||
def delete_session(session_id: str, db: DBSession = Depends(get_db)):
|
||||
session = db.get(Session, session_id)
|
||||
@@ -197,6 +226,11 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
|
||||
record_message(req.message)
|
||||
asyncio.create_task(tag_message_by_id(user_msg.id))
|
||||
|
||||
# 紐付けるノートは送信のたびに送られてくる。新規セッションだとフロントは事前に
|
||||
# PUT できないため、コンテキストを組み立てる前にここで反映しておく。
|
||||
if req.note_page_ids is not None:
|
||||
notes.set_attached_pages(db, session.id, req.note_page_ids)
|
||||
|
||||
context = await mem.get_context_messages(db, session.id)
|
||||
|
||||
async def generate():
|
||||
@@ -211,9 +245,18 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
|
||||
used_model = req.model or session.model
|
||||
full_response = []
|
||||
try:
|
||||
resolved_context, searches, notes_changed = await llm.resolve_tool_calls(
|
||||
# ツールの実行状況は tool イベントとしてそのままフロントへ流し、GUIに進行状況を出す
|
||||
resolved_context, searches, notes_changed = context, [], False
|
||||
async for event in llm.stream_tool_calls(
|
||||
context, model=used_model, session_id=session.id, db=db,
|
||||
)
|
||||
):
|
||||
if event["type"] == "resolved":
|
||||
resolved_context = event["messages"]
|
||||
searches = event["searches"]
|
||||
notes_changed = event["notes_changed"]
|
||||
else:
|
||||
yield sse({"type": "tool", "value": {k: v for k, v in event.items() if k != "type"}})
|
||||
|
||||
if notes_changed:
|
||||
# AIがノートを書き換えたので、フロントのノートパネルを再読み込みさせる
|
||||
yield sse({"type": "notes_updated"})
|
||||
@@ -223,11 +266,25 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
|
||||
for s in searches for r in s["results"]
|
||||
]
|
||||
yield sse({"type": "sources", "value": sources})
|
||||
yield sse({"type": "tool", "value": {
|
||||
"id": "save_knowledge",
|
||||
"name": "save_knowledge",
|
||||
"label": "検索結果を要約して保存",
|
||||
"detail": " / ".join(s["query"] for s in searches),
|
||||
"status": "running",
|
||||
}})
|
||||
saved = 0
|
||||
for s in searches:
|
||||
summary = await knowledge.summarize_results(s["query"], s["results"])
|
||||
headline, summary = await knowledge.summarize_results(s["query"], s["results"])
|
||||
if summary:
|
||||
knowledge.save_knowledge(db, s["query"], summary, s["results"])
|
||||
knowledge.save_knowledge(db, s["query"], summary, s["results"], headline=headline)
|
||||
local_knowledge.save_search_result(s["query"], summary, s["results"])
|
||||
saved += 1
|
||||
yield sse({"type": "tool", "value": {
|
||||
"id": "save_knowledge",
|
||||
"status": "done",
|
||||
"result": f"{saved}件を蓄積知識に保存" if saved else "保存できる要約なし",
|
||||
}})
|
||||
|
||||
# 履歴にtool_callsが含まれる場合、最終呼び出しにも同じツール定義を渡す必要がある
|
||||
used_tools = [WEB_SEARCH_TOOL, *NOTE_TOOLS] if len(resolved_context) > len(context) else None
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""ローカルナレッジベース(backend/knowledge_base/**.md)を GUI から閲覧するための読み取り専用 API。
|
||||
|
||||
コンテキスト注入に使われるファイルそのものを一覧・表示するだけで、ここからの編集はできない。
|
||||
埋め込み済みかどうか(indexed)は local_knowledge_docs テーブルを引いて付ける。
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.core.memory_kb import local_knowledge
|
||||
from app.core.memory_kb.local_knowledge import list_kb_files, read_kb_file
|
||||
from app.db.database import get_db
|
||||
from app.db.models import LocalKnowledgeDoc
|
||||
|
||||
router = APIRouter(prefix="/api/kb", tags=["knowledge-base"])
|
||||
|
||||
|
||||
class KbFileInfo(BaseModel):
|
||||
path: str
|
||||
name: str
|
||||
# ナレッジベース直下のファイルは空文字。UI ではこの単位でグループ表示する
|
||||
directory: str
|
||||
title: str
|
||||
preview: str
|
||||
size: int
|
||||
updated_at: datetime
|
||||
# 埋め込み済み(=話題に応じた自動注入の対象になっている)か
|
||||
indexed: bool
|
||||
|
||||
|
||||
class KbFileContent(BaseModel):
|
||||
path: str
|
||||
name: str
|
||||
title: str
|
||||
content: str
|
||||
size: int
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@router.get("/files", response_model=list[KbFileInfo])
|
||||
def list_files(db: DBSession = Depends(get_db)):
|
||||
"""ナレッジベース配下の Markdown を一覧で返す(本文は含めず、先頭のプレビューのみ)。"""
|
||||
# 「埋め込み済み」は現在のモデルで埋め込まれている行だけを指す(別モデルの行は埋め直し待ち)
|
||||
current_model = local_knowledge.current_embedding_model()
|
||||
indexed_paths = {
|
||||
# DB 側は OS 依存の区切り文字で保存されているため、比較用に posix 形式へ寄せる
|
||||
path.replace("\\", "/")
|
||||
for (path,) in db.query(LocalKnowledgeDoc.path)
|
||||
.filter(LocalKnowledgeDoc.embedding_model == current_model)
|
||||
.all()
|
||||
}
|
||||
return [
|
||||
KbFileInfo(**info, indexed=info["path"] in indexed_paths)
|
||||
for info in list_kb_files()
|
||||
]
|
||||
|
||||
|
||||
@router.get("/file", response_model=KbFileContent)
|
||||
def get_file(path: str = Query(..., description="ナレッジベースディレクトリからの相対パス")):
|
||||
"""1ファイルの全文を返す。"""
|
||||
file = read_kb_file(path)
|
||||
if file is None:
|
||||
raise HTTPException(status_code=404, detail="Knowledge base file not found")
|
||||
return KbFileContent(**file)
|
||||
|
||||
|
||||
class EmbeddingStatus(BaseModel):
|
||||
"""埋め込みモデルの現状。GUI の状態表示・進捗バーに使う。"""
|
||||
model: str
|
||||
# 現在のモデルで埋め込まれたベクトルの次元数(1件も無ければ None)
|
||||
dimensions: int | None
|
||||
file_count: int
|
||||
indexed: int
|
||||
# 別のモデルで埋め込まれていて埋め直しが必要な件数
|
||||
stale: int
|
||||
running: bool
|
||||
total: int
|
||||
done: int
|
||||
failed: int
|
||||
started_at: datetime | None
|
||||
finished_at: datetime | None
|
||||
last_error: str | None
|
||||
|
||||
|
||||
class EmbeddingProbe(BaseModel):
|
||||
"""短いテキストを実際に埋め込んでみた結果。"""
|
||||
ok: bool
|
||||
model: str
|
||||
dimensions: int | None
|
||||
latency_ms: float
|
||||
error: str | None
|
||||
|
||||
|
||||
@router.get("/embedding", response_model=EmbeddingStatus)
|
||||
def get_embedding_status(db: DBSession = Depends(get_db)):
|
||||
"""現在の埋め込みモデルと、埋め込み済み件数・実行中の進捗を返す。"""
|
||||
return local_knowledge.embedding_status(db)
|
||||
|
||||
|
||||
@router.post("/embedding/test", response_model=EmbeddingProbe)
|
||||
async def test_embedding():
|
||||
"""埋め込みモデルに実際に1回リクエストして、応答するかどうかを確かめる。"""
|
||||
return await local_knowledge.probe_embedding()
|
||||
|
||||
|
||||
@router.post("/embedding/reindex", response_model=EmbeddingStatus)
|
||||
async def reindex_embeddings(db: DBSession = Depends(get_db)):
|
||||
"""全ファイルの再埋め込みをバックグラウンドで開始する。実行中なら何もしない(進捗をそのまま返す)。"""
|
||||
local_knowledge.start_reembed_all()
|
||||
return local_knowledge.embedding_status(db)
|
||||
@@ -1,10 +1,14 @@
|
||||
from datetime import datetime
|
||||
import io
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.core.notes import next_position, reorder, rename_section, set_section
|
||||
from app.core.notes import import_files, next_position, reorder, rename_section, set_section
|
||||
from app.db.database import get_db
|
||||
from app.db.models import NotePage
|
||||
|
||||
@@ -46,11 +50,73 @@ class ReorderRequest(BaseModel):
|
||||
order: list[int]
|
||||
|
||||
|
||||
# ファイル名に使えない文字(Windows の禁止文字。制御文字は _safe_filename 側で弾く)
|
||||
_UNSAFE_FILENAME_CHARS = '\\/:*?"<>|'
|
||||
|
||||
|
||||
def _safe_filename(name: str, fallback: str) -> str:
|
||||
"""タイトルやセクション名をファイル名に使える形へ落とす。空になったら fallback を使う。"""
|
||||
cleaned = "".join("_" if c in _UNSAFE_FILENAME_CHARS or ord(c) < 32 else c for c in name)
|
||||
cleaned = cleaned.strip().strip(".")
|
||||
return cleaned[:80] or fallback
|
||||
|
||||
|
||||
def _attachment_headers(filename: str) -> dict[str, str]:
|
||||
"""日本語のファイル名は Content-Disposition にそのまま置けないため RFC 5987 形式を併記する。"""
|
||||
ascii_fallback = filename.encode("ascii", "replace").decode("ascii").replace("?", "_")
|
||||
return {
|
||||
"Content-Disposition": (
|
||||
f'attachment; filename="{ascii_fallback}"; '
|
||||
f"filename*=UTF-8''{quote(filename, safe='')}"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=list[NotePageResponse])
|
||||
def list_pages(db: DBSession = Depends(get_db)):
|
||||
return db.query(NotePage).order_by(NotePage.position, NotePage.id).all()
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export_all_pages(db: DBSession = Depends(get_db)):
|
||||
"""全ページを 1ページ1ファイルの Markdown にして ZIP でダウンロードさせる。
|
||||
|
||||
本文はそのまま書き出す(1つのファイルに連結すると、ページ内の見出しの階層が
|
||||
ページ間の見出しと混ざってしまうため)。タブの並び順を残すためファイル名には
|
||||
通し番号を付け、セクションはフォルダにする。
|
||||
"""
|
||||
pages = db.query(NotePage).order_by(NotePage.position, NotePage.id).all()
|
||||
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for i, page in enumerate(pages, start=1):
|
||||
entry = f"{i:02d}_{_safe_filename(page.title, f'page_{page.id}')}.md"
|
||||
if page.section:
|
||||
entry = f"{_safe_filename(page.section, 'section')}/{entry}"
|
||||
archive.writestr(entry, page.content)
|
||||
|
||||
filename = f"notes_{datetime.now(timezone.utc).strftime('%Y%m%d')}.zip"
|
||||
return Response(
|
||||
content=buffer.getvalue(),
|
||||
media_type="application/zip",
|
||||
headers=_attachment_headers(filename),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{page_id}/export")
|
||||
def export_page(page_id: int, db: DBSession = Depends(get_db)):
|
||||
"""1ページを Markdown ファイルとしてダウンロードさせる。本文はそのまま、ファイル名がタイトルになる。"""
|
||||
page = db.get(NotePage, page_id)
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Note page not found")
|
||||
filename = f"{_safe_filename(page.title, f'page_{page.id}')}.md"
|
||||
return Response(
|
||||
content=page.content,
|
||||
media_type="text/markdown; charset=utf-8",
|
||||
headers=_attachment_headers(filename),
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=NotePageResponse, status_code=201)
|
||||
def create_page(req: NotePageCreate, db: DBSession = Depends(get_db)):
|
||||
title = req.title.strip() or "新しいページ"
|
||||
@@ -63,6 +129,24 @@ def create_page(req: NotePageCreate, db: DBSession = Depends(get_db)):
|
||||
return page
|
||||
|
||||
|
||||
@router.post("/import", response_model=list[NotePageResponse], status_code=201)
|
||||
async def import_pages(files: list[UploadFile] = File(...), db: DBSession = Depends(get_db)):
|
||||
"""アップロードされたファイルをページとして取り込む。
|
||||
|
||||
.md / .txt はそのまま1ページ、.zip は中のテキストファイルを1つずつページにする
|
||||
(ZIP内のフォルダ名がセクションになるので、エクスポートしたZIPをそのまま戻せる)。
|
||||
対象外の拡張子や大きすぎるファイルは黙って飛ばすので、1つも取り込めなければ 400。
|
||||
"""
|
||||
payload = [(f.filename or "無題", await f.read()) for f in files]
|
||||
created = import_files(db, payload)
|
||||
if not created:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="取り込めるファイルがありませんでした(.md / .txt / .zip のみ)",
|
||||
)
|
||||
return created
|
||||
|
||||
|
||||
@router.post("/sections/rename")
|
||||
def rename_section_endpoint(req: SectionRenameRequest, db: DBSession = Depends(get_db)):
|
||||
"""old_name セクションに属する全ページを new_name セクションへ一括で付け替える。new_name が空ならグループ解除。"""
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.core import settings_store
|
||||
from app.core.auth import require_settings_password
|
||||
from app.core.memory_kb import local_knowledge
|
||||
from app.db.database import get_db
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"], dependencies=[Depends(require_settings_password)])
|
||||
@@ -12,7 +15,10 @@ router = APIRouter(prefix="/api/settings", tags=["settings"], dependencies=[Depe
|
||||
class SettingEntry(BaseModel):
|
||||
key: str
|
||||
is_set: bool
|
||||
masked: str | None
|
||||
# 秘匿キーはマスク済みの値、それ以外は現在値そのもの。未設定なら None
|
||||
display_value: str | None
|
||||
# 入力欄をパスワード扱いにするか(APIキー等は True)
|
||||
secret: bool
|
||||
overridden: bool
|
||||
|
||||
|
||||
@@ -26,7 +32,8 @@ def list_settings(db: DBSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.put("", response_model=list[SettingEntry])
|
||||
def save_settings(req: SaveSettingsRequest, db: DBSession = Depends(get_db)):
|
||||
async def save_settings(req: SaveSettingsRequest, db: DBSession = Depends(get_db)):
|
||||
before_embedding_model = os.environ.get("LOCAL_KB_EMBEDDING_MODEL")
|
||||
try:
|
||||
for key, value in req.values.items():
|
||||
if not value:
|
||||
@@ -34,13 +41,29 @@ def save_settings(req: SaveSettingsRequest, db: DBSession = Depends(get_db)):
|
||||
settings_store.save(db, key, value)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
_reembed_if_model_changed(before_embedding_model)
|
||||
return settings_store.list_masked(db)
|
||||
|
||||
|
||||
@router.delete("/{key}", response_model=list[SettingEntry])
|
||||
def clear_setting(key: str, db: DBSession = Depends(get_db)):
|
||||
async def clear_setting(key: str, db: DBSession = Depends(get_db)):
|
||||
before_embedding_model = os.environ.get("LOCAL_KB_EMBEDDING_MODEL")
|
||||
try:
|
||||
settings_store.clear(db, key)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
_reembed_if_model_changed(before_embedding_model)
|
||||
return settings_store.list_masked(db)
|
||||
|
||||
|
||||
def _reembed_if_model_changed(before: str | None) -> None:
|
||||
"""埋め込みモデルが変わったら、ローカルKBの再埋め込みをバックグラウンドで走らせる。
|
||||
|
||||
モデルが違うと次元数も意味空間も変わり、古いベクトルは比較しても無意味なので、
|
||||
設定を変えた時点で全件やり直す(完了を待たずに応答は返す)。
|
||||
"""
|
||||
if os.environ.get("LOCAL_KB_EMBEDDING_MODEL") == before:
|
||||
return
|
||||
local_knowledge.start_reembed_all()
|
||||
|
||||
@@ -9,6 +9,9 @@ DEFAULT_MODEL: str = os.getenv("DEFAULT_MODEL", "gpt-4o")
|
||||
MEMORY_SUMMARIZE_THRESHOLD: int = int(os.getenv("MEMORY_SUMMARIZE_THRESHOLD", "20"))
|
||||
CORS_ORIGINS: list[str] = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")
|
||||
DEBUG: bool = os.getenv("DEBUG", "false").lower() in ("1", "true", "yes")
|
||||
# アプリ側ロガーの出力レベル。未設定だと logging.lastResort(WARNING以上・書式なし)に
|
||||
# 落ちて logger.info が全部消えるため、main.py で明示的に設定する。
|
||||
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
|
||||
# GUI設定画面(/api/settings)を保護する簡易パスワード。未設定の場合、設定画面は常に403で拒否する。
|
||||
SETTINGS_PASSWORD: str | None = os.getenv("SETTINGS_PASSWORD")
|
||||
@@ -25,10 +28,12 @@ SESSION_MAX_AGE_DAYS: int = int(os.getenv("SESSION_MAX_AGE_DAYS", "30"))
|
||||
TAGGING_MODEL: str | None = os.getenv("TAGGING_MODEL")
|
||||
|
||||
# Tavily Web検索
|
||||
# GUI設定画面から実行時に変更できるため、実際の参照は search/websearch._api_key() が os.environ から行う。
|
||||
TAVILY_API_KEY: str | None = os.getenv("TAVILY_API_KEY")
|
||||
# 蓄積した知識をシステムプロンプトに注入する際の最大件数
|
||||
KNOWLEDGE_CONTEXT_LIMIT: int = int(os.getenv("KNOWLEDGE_CONTEXT_LIMIT", "15"))
|
||||
# Web検索結果を構造的な要約にまとめる際に使うモデル(未設定ならDEFAULT_MODELを使用)
|
||||
# GUI設定画面から実行時に変更できるため、実際の参照は memory_kb/knowledge._summary_model() が os.environ から行う。
|
||||
SEARCH_SUMMARY_MODEL: str | None = os.getenv("SEARCH_SUMMARY_MODEL")
|
||||
|
||||
# ローカルMarkdownナレッジベース(話題に応じて関連ファイルをシステムプロンプトに注入)
|
||||
@@ -41,6 +46,16 @@ LOCAL_KB_MIN_SCORE: float = float(os.getenv("LOCAL_KB_MIN_SCORE", "0.75"))
|
||||
# read_note ツールが1回に返す本文の最大文字数
|
||||
NOTES_TOOL_CONTENT_LIMIT: int = int(os.getenv("NOTES_TOOL_CONTENT_LIMIT", "4000"))
|
||||
|
||||
# アップロードで一度に取り込めるページ数と、1ファイル(ZIP内は1エントリ)あたりの最大バイト数
|
||||
NOTES_IMPORT_MAX_PAGES: int = int(os.getenv("NOTES_IMPORT_MAX_PAGES", "100"))
|
||||
NOTES_IMPORT_MAX_BYTES: int = int(os.getenv("NOTES_IMPORT_MAX_BYTES", "2000000"))
|
||||
|
||||
# セッションに紐付けたページ1枚あたり、システムプロンプトに載せる本文の最大文字数
|
||||
SESSION_NOTES_CONTENT_LIMIT: int = int(os.getenv("SESSION_NOTES_CONTENT_LIMIT", "8000"))
|
||||
|
||||
# read_knowledge ツールが1回に返す蓄積知識の本文の最大文字数
|
||||
KNOWLEDGE_TOOL_CONTENT_LIMIT: int = int(os.getenv("KNOWLEDGE_TOOL_CONTENT_LIMIT", "4000"))
|
||||
|
||||
# Amazon Bedrock
|
||||
# 以下はすべて LiteLLM(boto3) が os.environ から直接読み取るため、.env に書くだけで自動的に有効になる。
|
||||
# IAM経由(推奨): AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY を設定する。
|
||||
@@ -76,3 +91,14 @@ USELESS_TIPS_INTERVAL_HOURS: int = int(os.getenv("USELESS_TIPS_INTERVAL_HOURS",
|
||||
USELESS_TIPS_MODEL: str | None = os.getenv("USELESS_TIPS_MODEL")
|
||||
# 未設定なら天気通知と同じ DISCORD_WEBHOOK_URL を使う
|
||||
DISCORD_WEBHOOK_URL_TIPS: str | None = os.getenv("DISCORD_WEBHOOK_URL_TIPS") or os.getenv("DISCORD_WEBHOOK_URL")
|
||||
|
||||
# Discord Bot のリマインド機能(メンションで登録し、時刻になったら同じチャンネルへ通知する)
|
||||
# 期限切れのリマインドを探しにいく間隔。短いほど通知の遅れが減るがDBを引く回数は増える。
|
||||
REMINDER_CHECK_INTERVAL_SECONDS: int = int(os.getenv("REMINDER_CHECK_INTERVAL_SECONDS", "30"))
|
||||
# これより先の日時を指定されたら登録を拒否する(LLMの年の取り違えを弾くため)
|
||||
REMINDER_MAX_AHEAD_DAYS: int = int(os.getenv("REMINDER_MAX_AHEAD_DAYS", "365"))
|
||||
# list_reminders が一度に返す件数
|
||||
REMINDER_LIST_LIMIT: int = int(os.getenv("REMINDER_LIST_LIMIT", "20"))
|
||||
# リマインドの解釈に使うモデル。tool callingに対応したモデルを指定すること
|
||||
# (未設定なら WEATHER_NOTIFY_MODEL → DEFAULT_MODEL の順にフォールバックする)
|
||||
REMINDER_MODEL: str | None = os.getenv("REMINDER_MODEL")
|
||||
|
||||
@@ -11,6 +11,11 @@ from app.core.config import (
|
||||
WEATHER_NOTIFY_MODEL,
|
||||
)
|
||||
from app.core.llm import complete
|
||||
from app.core.discord.reminders import (
|
||||
REMINDER_KEYWORDS,
|
||||
ReminderContext,
|
||||
handle_reminder_request,
|
||||
)
|
||||
from app.core.discord.scheduler import send_useless_tip
|
||||
from app.core.memory_kb.tagging import find_related_tag, generate_tags
|
||||
from app.core.weather.weather import fetch_tomorrow_weather, judge_laundry
|
||||
@@ -67,6 +72,15 @@ async def on_message(message: discord.Message):
|
||||
|
||||
async with message.channel.typing():
|
||||
try:
|
||||
if any(keyword in content for keyword in REMINDER_KEYWORDS):
|
||||
ctx = ReminderContext(
|
||||
channel_id=str(message.channel.id),
|
||||
user_id=str(message.author.id),
|
||||
user_name=message.author.display_name,
|
||||
)
|
||||
await message.reply(await handle_reminder_request(content, ctx))
|
||||
return
|
||||
|
||||
if any(keyword in content for keyword in USELESS_TALK_KEYWORDS):
|
||||
redirect = await _generate_useless_talk_redirect(content)
|
||||
await message.reply(redirect)
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
"""Discord Bot のリマインド機能。
|
||||
|
||||
メンション本文にリマインド系のキーワードが含まれていたとき、discord_bot.on_message が
|
||||
handle_reminder_request() を呼ぶ。中では LLM にツール(create_reminder / list_reminders /
|
||||
cancel_reminder)を渡して自然言語から日時と本文を取り出させ、reminders テーブルに保存する。
|
||||
|
||||
保存したリマインドは scheduler が REMINDER_CHECK_INTERVAL_SECONDS ごとに
|
||||
deliver_due_reminders() を呼ぶことで配信される。配信先は登録されたチャンネルで、
|
||||
Webhook ではなく Bot クライアントから送るため追加の URL 設定は要らない。
|
||||
|
||||
日時の扱い: DB は他のテーブルと同じくUTC。ユーザーとLLMとのやり取りはすべて
|
||||
このマシンのローカル時刻(JST想定)で行い、この境界で相互に変換する。
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import discord
|
||||
import litellm
|
||||
|
||||
from app.core.config import (
|
||||
DEFAULT_MODEL,
|
||||
REMINDER_LIST_LIMIT,
|
||||
REMINDER_MAX_AHEAD_DAYS,
|
||||
REMINDER_MODEL,
|
||||
WEATHER_NOTIFY_MODEL,
|
||||
)
|
||||
from app.db.database import SessionLocal
|
||||
from app.db.models import Reminder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# メンション本文にこれらが含まれるとリマインド処理に入る(discord_bot.on_message を参照)
|
||||
REMINDER_KEYWORDS = (
|
||||
"リマインド",
|
||||
"リマインダ",
|
||||
"remind",
|
||||
"思い出させ",
|
||||
"忘れないように",
|
||||
"忘れずに",
|
||||
"覚えといて",
|
||||
"覚えておいて",
|
||||
"アラーム",
|
||||
)
|
||||
|
||||
# 同じリマインドの配信にこの回数失敗したら諦める。チャンネル削除などで永久に
|
||||
# 失敗し続けるものが、ポーリングのたびにエラーログを出すのを防ぐ。
|
||||
_MAX_DELIVERY_ATTEMPTS = 5
|
||||
# 配信に失敗した回数(プロセス内のみ。再起動すればまた試す)
|
||||
_delivery_failures: dict[int, int] = {}
|
||||
|
||||
_AT_FORMATS = ("%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReminderContext:
|
||||
"""リマインドを頼んできた相手。配信先チャンネルと、メンションする相手を決めるのに使う。"""
|
||||
|
||||
channel_id: str
|
||||
user_id: str
|
||||
user_name: str
|
||||
|
||||
|
||||
def _model() -> str:
|
||||
return REMINDER_MODEL or WEATHER_NOTIFY_MODEL or DEFAULT_MODEL
|
||||
|
||||
|
||||
def _local_now() -> datetime:
|
||||
"""このマシンのローカルタイムゾーンでの現在時刻(aware)。"""
|
||||
return datetime.now().astimezone()
|
||||
|
||||
|
||||
def _to_local(value: datetime) -> datetime:
|
||||
"""DBから読んだUTCの日時を、表示用にローカル時刻へ直す。"""
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(_local_now().tzinfo)
|
||||
|
||||
|
||||
def _parse_at(text: str) -> datetime:
|
||||
"""LLMが返した "YYYY-MM-DD HH:MM" をローカル時刻の aware な datetime にする。
|
||||
|
||||
解釈できない場合は ValueError を投げ、呼び出し側がその旨をツール結果としてLLMに返す。
|
||||
"""
|
||||
cleaned = (text or "").strip()
|
||||
for fmt in _AT_FORMATS:
|
||||
try:
|
||||
return datetime.strptime(cleaned, fmt).replace(tzinfo=_local_now().tzinfo)
|
||||
except ValueError:
|
||||
continue
|
||||
raise ValueError(f"日時 '{text}' を解釈できませんでした")
|
||||
|
||||
|
||||
CREATE_REMINDER_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_reminder",
|
||||
"description": "指定した日時にDiscordへ通知するリマインドを登録する。"
|
||||
"ユーザーが『あとで教えて』『◯時になったら知らせて』などと頼んできたときに使う。"
|
||||
"一回きりの通知のみで、繰り返しには対応していない。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"at": {
|
||||
"type": "string",
|
||||
"description": "通知する日時。必ず 'YYYY-MM-DD HH:MM' 形式のローカル時刻で指定する。"
|
||||
"『明日の朝9時』『30分後』のような相対表現は、システムメッセージで与えられた"
|
||||
"現在時刻をもとに自分で計算して絶対日時に直すこと。",
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "通知するときに読み上げる用件。ユーザーの言葉をそのまま短くまとめる。",
|
||||
},
|
||||
},
|
||||
"required": ["at", "content"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
LIST_REMINDERS_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_reminders",
|
||||
"description": "このユーザーの未通知のリマインド一覧を取得する。"
|
||||
"『リマインド何が入ってる?』のように予定を尋ねられたときや、"
|
||||
"取り消し対象のIDを知りたいときに使う。",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
|
||||
CANCEL_REMINDER_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "cancel_reminder",
|
||||
"description": "登録済みのリマインドを取り消す。IDが分からない場合は先に list_reminders で調べること。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer", "description": "取り消すリマインドのID(list_reminders が返す番号)"},
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
REMINDER_TOOLS = [CREATE_REMINDER_TOOL, LIST_REMINDERS_TOOL, CANCEL_REMINDER_TOOL]
|
||||
|
||||
|
||||
def _create_reminder(db, args: dict, ctx: ReminderContext) -> str:
|
||||
content = (args.get("content") or "").strip()
|
||||
if not content:
|
||||
return "エラー: 通知する用件が空です。ユーザーに何をリマインドすればよいか聞き返してください。"
|
||||
|
||||
try:
|
||||
due_local = _parse_at(args.get("at", ""))
|
||||
except ValueError as e:
|
||||
return f"エラー: {e}。'YYYY-MM-DD HH:MM' の形式で指定し直してください。"
|
||||
|
||||
now_local = _local_now()
|
||||
if due_local <= now_local:
|
||||
return (
|
||||
f"エラー: 指定された {due_local:%Y-%m-%d %H:%M} は現在時刻 {now_local:%Y-%m-%d %H:%M} より過去です。"
|
||||
"未来の日時を指定し直すか、ユーザーに確認してください。"
|
||||
)
|
||||
if due_local - now_local > timedelta(days=REMINDER_MAX_AHEAD_DAYS):
|
||||
return (
|
||||
f"エラー: 指定された {due_local:%Y-%m-%d %H:%M} は{REMINDER_MAX_AHEAD_DAYS}日以上先です。"
|
||||
"年を取り違えていないか確認してください。"
|
||||
)
|
||||
|
||||
reminder = Reminder(
|
||||
channel_id=ctx.channel_id,
|
||||
user_id=ctx.user_id,
|
||||
user_name=ctx.user_name,
|
||||
content=content,
|
||||
due_at=due_local.astimezone(timezone.utc),
|
||||
)
|
||||
db.add(reminder)
|
||||
db.commit()
|
||||
db.refresh(reminder)
|
||||
|
||||
logger.info("Reminder created: id=%s due=%s user=%s", reminder.id, due_local, ctx.user_name)
|
||||
return (
|
||||
f"リマインドを登録しました(ID {reminder.id})。"
|
||||
f"{due_local:%Y-%m-%d %H:%M} に「{content}」を通知します。"
|
||||
"ユーザーへの返事には、いつ何を通知するのかを必ず含めてください。"
|
||||
)
|
||||
|
||||
|
||||
def _list_reminders(db, ctx: ReminderContext) -> str:
|
||||
rows = (
|
||||
db.query(Reminder)
|
||||
.filter(
|
||||
Reminder.user_id == ctx.user_id,
|
||||
Reminder.notified_at.is_(None),
|
||||
Reminder.cancelled_at.is_(None),
|
||||
)
|
||||
.order_by(Reminder.due_at)
|
||||
.limit(REMINDER_LIST_LIMIT)
|
||||
.all()
|
||||
)
|
||||
if not rows:
|
||||
return "このユーザーの未通知のリマインドはありません。"
|
||||
|
||||
lines = [f"ID {r.id}: {_to_local(r.due_at):%Y-%m-%d %H:%M} 「{r.content}」" for r in rows]
|
||||
return "未通知のリマインド:\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def _cancel_reminder(db, args: dict, ctx: ReminderContext) -> str:
|
||||
reminder_id = args.get("id")
|
||||
reminder = (
|
||||
db.query(Reminder)
|
||||
.filter(
|
||||
Reminder.id == reminder_id,
|
||||
Reminder.user_id == ctx.user_id,
|
||||
Reminder.notified_at.is_(None),
|
||||
Reminder.cancelled_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if reminder is None:
|
||||
return (
|
||||
f"エラー: ID {reminder_id} の未通知のリマインドは見つかりませんでした。"
|
||||
"list_reminders で確認してください。"
|
||||
)
|
||||
|
||||
reminder.cancelled_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
logger.info("Reminder cancelled: id=%s user=%s", reminder_id, ctx.user_name)
|
||||
return f"ID {reminder_id}「{reminder.content}」のリマインドを取り消しました。"
|
||||
|
||||
|
||||
def execute_reminder_tool(db, name: str, args: dict, ctx: ReminderContext) -> str:
|
||||
"""リマインド系ツールを実行し、LLMに返す結果テキストを作る。
|
||||
|
||||
一覧・取り消しは ctx.user_id で絞るため、他人のリマインドには触れられない。
|
||||
"""
|
||||
if name == "create_reminder":
|
||||
return _create_reminder(db, args, ctx)
|
||||
if name == "list_reminders":
|
||||
return _list_reminders(db, ctx)
|
||||
if name == "cancel_reminder":
|
||||
return _cancel_reminder(db, args, ctx)
|
||||
return f"エラー: 未知のツール {name}"
|
||||
|
||||
|
||||
class _ToolFailure(Exception):
|
||||
"""ツール実行そのものが失敗した(DBエラーなど)。モデルに投げ返さず即座に諦めるための印。"""
|
||||
|
||||
|
||||
def _run_tool(name: str, args: dict, ctx: ReminderContext) -> str:
|
||||
"""ツール1回分だけDBセッションを開いて実行する。
|
||||
|
||||
LLMとの往復のあいだセッションを握り続けると、SQLite のロックを数秒〜数十秒
|
||||
保持することになり、同時に走る API リクエストと競合して "database is locked"
|
||||
を起こしうる。開いて閉じるのをツール単位にすることでそれを避ける。
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return execute_reminder_tool(db, name, args, ctx)
|
||||
except Exception as e:
|
||||
logger.exception("Reminder tool %s failed (args=%r)", name, args)
|
||||
db.rollback()
|
||||
raise _ToolFailure(name) from e
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _system_prompt() -> str:
|
||||
now = _local_now()
|
||||
weekday = "月火水木金土日"[now.weekday()]
|
||||
return (
|
||||
"あなたはDiscordでリマインドを預かるアシスタントです。"
|
||||
"ユーザーの依頼に応じて create_reminder / list_reminders / cancel_reminder ツールを使ってください。\n"
|
||||
f"現在の日時は {now:%Y-%m-%d %H:%M}({weekday}曜日)です。"
|
||||
"『明日』『30分後』『今週末』などの相対表現は、この現在時刻を基準に絶対日時へ変換すること。\n"
|
||||
"時刻が指定されていない『明日』のような依頼は朝9時と解釈してかまいませんが、"
|
||||
"その解釈を返事の中で明示してください。\n"
|
||||
"ツールを実行したら、その結果をもとに1〜2文の短い返事を書いてください。"
|
||||
"登録した場合は「いつ」「何を」通知するかを必ず含め、絵文字を1つ程度使った親しみやすい口調にします。"
|
||||
"出力は返事の本文のみで、ツール名やIDの内部的な説明は不要です。"
|
||||
)
|
||||
|
||||
|
||||
async def handle_reminder_request(content: str, ctx: ReminderContext, max_rounds: int = 3) -> str:
|
||||
"""リマインド依頼をLLMに解釈させ、ツールを実行し、ユーザーへの返事を返す。
|
||||
|
||||
ツール呼び出しに対応していないモデルや、LLM呼び出し自体が失敗した場合は
|
||||
その旨の固定文を返す(Botが無言になるのを避ける)。
|
||||
"""
|
||||
messages: list[dict] = [
|
||||
{"role": "system", "content": _system_prompt()},
|
||||
{"role": "user", "content": content},
|
||||
]
|
||||
used_model = _model()
|
||||
logger.info("Reminder request from %s [model=%s]: %s", ctx.user_name, used_model, content)
|
||||
|
||||
for round_no in range(1, max_rounds + 1):
|
||||
try:
|
||||
# tool_choice は渡さない。Bedrock が非対応で UnsupportedParamsError になり、
|
||||
# それを握り潰しているのは app.core.llm の litellm.drop_params=True という
|
||||
# 別モジュールの副作用に過ぎない。tools を渡したときの既定が "auto" なので
|
||||
# 省いても挙動は変わらず、その隠れた依存だけが消える。
|
||||
response = await litellm.acompletion(
|
||||
model=used_model,
|
||||
messages=messages,
|
||||
tools=REMINDER_TOOLS,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Reminder LLM call failed [model=%s round=%d]", used_model, round_no)
|
||||
return "⚠️ リマインドの登録に失敗しました。時間を変えてもう一度試してみてください。"
|
||||
|
||||
# tool calling 非対応のエンドポイントは、例外を投げずに choices が空だったり
|
||||
# 形の違う応答を返すことがある。ここで落ちると原因が分からなくなるので個別に捕まえる。
|
||||
try:
|
||||
choice_message = response.choices[0].message
|
||||
except (AttributeError, IndexError, KeyError, TypeError):
|
||||
logger.exception(
|
||||
"Unexpected completion shape [model=%s round=%d]: %r", used_model, round_no, response,
|
||||
)
|
||||
return "⚠️ モデルからの応答を解釈できませんでした。tool calling に対応したモデルか確認してください。"
|
||||
|
||||
tool_calls = getattr(choice_message, "tool_calls", None)
|
||||
if not tool_calls:
|
||||
reply = (choice_message.content or "").strip()
|
||||
logger.info("Reminder request finished without tools [round=%d]", round_no)
|
||||
return reply or "⚠️ リマインドの内容をうまく読み取れませんでした。日時と用件を教えてください。"
|
||||
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": choice_message.content or "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {"name": tc.function.name, "arguments": tc.function.arguments},
|
||||
}
|
||||
for tc in tool_calls
|
||||
],
|
||||
})
|
||||
|
||||
for tc in tool_calls:
|
||||
try:
|
||||
args = json.loads(tc.function.arguments or "{}")
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
try:
|
||||
result = _run_tool(tc.function.name, args, ctx)
|
||||
except _ToolFailure:
|
||||
return "⚠️ リマインドの保存に失敗しました(データベースエラー)。サーバーのログを確認してください。"
|
||||
messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})
|
||||
|
||||
# max_rounds 使い切ってもツールを呼び続けている=堂々巡り。登録自体は済んでいる可能性がある。
|
||||
logger.warning("Reminder tool loop exceeded %d rounds [model=%s]", max_rounds, used_model)
|
||||
return "⚠️ うまく処理しきれませんでした。登録できているかは「リマインド一覧」と聞いて確認してください。"
|
||||
|
||||
|
||||
async def deliver_due_reminders() -> None:
|
||||
"""期限を過ぎた未通知のリマインドを、登録元のチャンネルへ配信する。
|
||||
|
||||
scheduler が REMINDER_CHECK_INTERVAL_SECONDS ごとに呼ぶ。Bot がまだ起動しきって
|
||||
いない間は何もしないので、バックエンド停止中に期限を迎えたぶんは起動後にまとめて届く。
|
||||
"""
|
||||
# discord_bot が scheduler 経由でこのモジュールを読むため、モジュールレベルで
|
||||
# import すると循環 import になる。
|
||||
from app.core.discord.discord_bot import client
|
||||
|
||||
if not client.is_ready():
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
due = (
|
||||
db.query(Reminder)
|
||||
.filter(
|
||||
Reminder.notified_at.is_(None),
|
||||
Reminder.cancelled_at.is_(None),
|
||||
Reminder.due_at <= datetime.now(timezone.utc),
|
||||
)
|
||||
.order_by(Reminder.due_at)
|
||||
.all()
|
||||
)
|
||||
for reminder in due:
|
||||
try:
|
||||
channel = client.get_channel(int(reminder.channel_id))
|
||||
if channel is None:
|
||||
channel = await client.fetch_channel(int(reminder.channel_id))
|
||||
# カテゴリやフォーラムそのものには投稿できない(登録時のチャンネルが
|
||||
# 別種のものに作り替えられた場合に起きうる)ので、送れる相手かを確かめる。
|
||||
if not isinstance(channel, discord.abc.Messageable):
|
||||
raise TypeError(f"channel {reminder.channel_id} is not messageable")
|
||||
await channel.send(f"⏰ <@{reminder.user_id}> リマインドの時間です!\n> {reminder.content}")
|
||||
except Exception:
|
||||
failures = _delivery_failures.get(reminder.id, 0) + 1
|
||||
_delivery_failures[reminder.id] = failures
|
||||
if failures < _MAX_DELIVERY_ATTEMPTS:
|
||||
logger.exception(
|
||||
"Failed to deliver reminder id=%s (attempt %d/%d)",
|
||||
reminder.id, failures, _MAX_DELIVERY_ATTEMPTS,
|
||||
)
|
||||
continue
|
||||
logger.error("Giving up on reminder id=%s after %d attempts", reminder.id, failures)
|
||||
|
||||
_delivery_failures.pop(reminder.id, None)
|
||||
reminder.notified_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to process due reminders")
|
||||
finally:
|
||||
db.close()
|
||||
@@ -10,6 +10,7 @@ from apscheduler.triggers.interval import IntervalTrigger
|
||||
from app.core.config import (
|
||||
DISCORD_WEBHOOK_URL,
|
||||
DISCORD_WEBHOOK_URL_TIPS,
|
||||
REMINDER_CHECK_INTERVAL_SECONDS,
|
||||
USELESS_TIPS_ENABLED,
|
||||
USELESS_TIPS_INTERVAL_HOURS,
|
||||
USELESS_TIPS_MODEL,
|
||||
@@ -21,6 +22,7 @@ from app.core.config import (
|
||||
)
|
||||
from app.core.llm import complete
|
||||
from app.core.discord.notify import send_discord_message
|
||||
from app.core.discord.reminders import deliver_due_reminders
|
||||
from app.core.weather.weather import fetch_tomorrow_weather, judge_laundry
|
||||
from app.core.weather.weather_cache import set_latest
|
||||
from app.core.memory_kb.word_memory import pick_random_word
|
||||
@@ -144,4 +146,12 @@ def start_scheduler() -> None:
|
||||
)
|
||||
logger.info("Useless tips job started: every %d hour(s)", USELESS_TIPS_INTERVAL_HOURS)
|
||||
|
||||
scheduler.add_job(
|
||||
deliver_due_reminders,
|
||||
trigger=IntervalTrigger(seconds=REMINDER_CHECK_INTERVAL_SECONDS),
|
||||
id="discord_reminders",
|
||||
replace_existing=True,
|
||||
)
|
||||
logger.info("Reminder delivery job started: every %d second(s)", REMINDER_CHECK_INTERVAL_SECONDS)
|
||||
|
||||
scheduler.start()
|
||||
|
||||
+82
-10
@@ -107,26 +107,56 @@ async def embed(texts: list[str], model: str) -> list[list[float]]:
|
||||
return [item["embedding"] for item in response.data]
|
||||
|
||||
|
||||
async def resolve_tool_calls(
|
||||
def _tool_display(name: str, args: dict) -> tuple[str, str]:
|
||||
"""ツール呼び出しをGUI表示用の (ラベル, 詳細) に変換する。"""
|
||||
if name == "web_search":
|
||||
return "Web検索", args.get("query", "")
|
||||
if name == "read_note":
|
||||
return "ノートを読む", args.get("title", "")
|
||||
if name == "write_note":
|
||||
label = "ノートを上書き" if args.get("mode") == "replace" else "ノートに追記"
|
||||
return label, args.get("title", "")
|
||||
if name == "read_knowledge":
|
||||
return "蓄積知識を読む", args.get("query", "")
|
||||
return name, ""
|
||||
|
||||
|
||||
def _first_line(text: str, limit: int = 80) -> str:
|
||||
"""ツール結果の要点だけをGUIに出すため、1行目を切り出して長さを詰める。"""
|
||||
line = text.strip().splitlines()[0] if text.strip() else ""
|
||||
return line[:limit] + "…" if len(line) > limit else line
|
||||
|
||||
|
||||
async def stream_tool_calls(
|
||||
messages: list[dict],
|
||||
model: str | None = None,
|
||||
session_id: str | None = None,
|
||||
db: "DBSession | None" = None,
|
||||
max_rounds: int = 2,
|
||||
) -> tuple[list[dict], list[dict], bool]:
|
||||
"""必要に応じてLLMにツールを呼ばせ、その結果を会話に組み込んだ上でメッセージ列を返す。
|
||||
) -> AsyncIterator[dict]:
|
||||
"""必要に応じてLLMにツールを呼ばせ、その進行状況をイベントとして逐次 yield する。
|
||||
|
||||
扱うツールは web_search と共有ノート(read_note / write_note)。ノート系は db が
|
||||
渡されたときだけ提示する。モデルがtool callingに対応していない場合は何もせず
|
||||
元のmessagesをそのまま返す。
|
||||
戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト, ノートを変更したか)
|
||||
扱うツールは web_search、共有ノート(read_note / write_note)、蓄積知識(read_knowledge)。
|
||||
DBを見るものは db が渡されたときだけ提示する。モデルがtool callingに対応していない場合は
|
||||
何もせず元のmessagesをそのまま返す。
|
||||
|
||||
yield するイベント:
|
||||
- {"type": "tool", "id", "name", "label", "detail", "status": "running"} … 実行開始
|
||||
- {"type": "tool", "id", "status": "done", "result"} … 実行完了(GUI用の短い結果つき)
|
||||
- {"type": "resolved", "messages", "searches", "notes_changed"} … 最後に必ず1回
|
||||
|
||||
最後の resolved イベントが従来の戻り値にあたる(resolve_tool_calls がそれを取り出す)。
|
||||
"""
|
||||
# app.core.memory_kb.knowledge は app.core.llm を import しているため、
|
||||
# モジュールレベルで読むと循環 import になる(_save_log と同じ理由で関数内 import する)。
|
||||
from app.core.memory_kb.knowledge import KNOWLEDGE_TOOLS, execute_knowledge_tool
|
||||
|
||||
used_model = model or DEFAULT_MODEL
|
||||
working_messages = list(messages)
|
||||
searches: list[dict] = []
|
||||
notes_changed = False
|
||||
|
||||
tools = [WEB_SEARCH_TOOL] + (NOTE_TOOLS if db is not None else [])
|
||||
tools = [WEB_SEARCH_TOOL] + (NOTE_TOOLS + KNOWLEDGE_TOOLS if db is not None else [])
|
||||
|
||||
for _ in range(max_rounds):
|
||||
try:
|
||||
@@ -138,7 +168,8 @@ async def resolve_tool_calls(
|
||||
)
|
||||
except Exception:
|
||||
logger.info("Model %s does not support tool calling; skipping tools", used_model)
|
||||
return messages, searches, notes_changed
|
||||
yield {"type": "resolved", "messages": messages, "searches": searches, "notes_changed": notes_changed}
|
||||
return
|
||||
|
||||
choice_message = response.choices[0].message
|
||||
tool_calls = getattr(choice_message, "tool_calls", None)
|
||||
@@ -165,17 +196,35 @@ async def resolve_tool_calls(
|
||||
args = {}
|
||||
|
||||
name = tc.function.name
|
||||
label, detail = _tool_display(name, args)
|
||||
yield {
|
||||
"type": "tool",
|
||||
"id": tc.id,
|
||||
"name": name,
|
||||
"label": label,
|
||||
"detail": detail,
|
||||
"status": "running",
|
||||
}
|
||||
|
||||
if name in ("read_note", "write_note") and db is not None:
|
||||
tool_text, changed = execute_note_tool(db, name, args)
|
||||
notes_changed = notes_changed or changed
|
||||
result = _first_line(tool_text)
|
||||
elif name == "read_knowledge" and db is not None:
|
||||
tool_text = execute_knowledge_tool(db, name, args)
|
||||
result = _first_line(tool_text)
|
||||
else:
|
||||
query = args.get("query", "")
|
||||
results = await search_web(query)
|
||||
if results:
|
||||
tool_text = "\n\n".join(f"[{r['title']}]({r['url']})\n{r['content']}" for r in results)
|
||||
searches.append({"query": query, "results": results})
|
||||
result = f"{len(results)}件の結果"
|
||||
else:
|
||||
tool_text = "検索結果が見つかりませんでした。"
|
||||
result = "結果なし"
|
||||
|
||||
yield {"type": "tool", "id": tc.id, "status": "done", "result": result}
|
||||
|
||||
working_messages.append({
|
||||
"role": "tool",
|
||||
@@ -183,4 +232,27 @@ async def resolve_tool_calls(
|
||||
"content": tool_text,
|
||||
})
|
||||
|
||||
return working_messages, searches, notes_changed
|
||||
yield {
|
||||
"type": "resolved",
|
||||
"messages": working_messages,
|
||||
"searches": searches,
|
||||
"notes_changed": notes_changed,
|
||||
}
|
||||
|
||||
|
||||
async def resolve_tool_calls(
|
||||
messages: list[dict],
|
||||
model: str | None = None,
|
||||
session_id: str | None = None,
|
||||
db: "DBSession | None" = None,
|
||||
max_rounds: int = 2,
|
||||
) -> tuple[list[dict], list[dict], bool]:
|
||||
"""stream_tool_calls の進行状況イベントを捨て、最終結果だけを返すラッパー。
|
||||
|
||||
戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト, ノートを変更したか)
|
||||
"""
|
||||
resolved = (messages, [], False)
|
||||
async for event in stream_tool_calls(messages, model, session_id, db, max_rounds):
|
||||
if event["type"] == "resolved":
|
||||
resolved = (event["messages"], event["searches"], event["notes_changed"])
|
||||
return resolved
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import httpx
|
||||
@@ -7,7 +8,10 @@ import trafilatura
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.core import llm
|
||||
from app.core.config import KNOWLEDGE_CONTEXT_LIMIT, SEARCH_SUMMARY_MODEL
|
||||
from app.core.config import (
|
||||
KNOWLEDGE_CONTEXT_LIMIT,
|
||||
KNOWLEDGE_TOOL_CONTENT_LIMIT,
|
||||
)
|
||||
from app.core.memory_kb import local_knowledge
|
||||
from app.db.models import KnowledgeEntry, KnowledgeSource
|
||||
|
||||
@@ -19,6 +23,9 @@ _HTML_TAG_RE = re.compile(r"<[^>]+>")
|
||||
_SUMMARY_PROMPT = (
|
||||
"以下はWeb検索「{query}」で得られた複数の検索結果です。"
|
||||
"これらの内容を整理し、Markdown形式で構造的にまとめてください。\n"
|
||||
"- 1行目は「要約: 」で始まる60文字以内の一文にする"
|
||||
"(何についての知識かが一目で分かる見出し。システムプロンプトにはこの行だけを載せる)\n"
|
||||
"- 2行目以降を本文とし、以下の要領でまとめる\n"
|
||||
"- 検索結果間で重複する情報はまとめ、矛盾する情報があれば併記する\n"
|
||||
"- 見出し(##)やトピックごとの箇条書きを使い、要点が一目で分かるようにする\n"
|
||||
"- 各項目の末尾に (出典: サイト名) を付ける\n"
|
||||
@@ -27,15 +34,54 @@ _SUMMARY_PROMPT = (
|
||||
)
|
||||
|
||||
|
||||
async def summarize_results(query: str, results: list[dict], model: str | None = None) -> str:
|
||||
_HEADLINE_MARKERS = ("要約:", "要約:")
|
||||
|
||||
|
||||
def _summary_model() -> str | None:
|
||||
"""GUIの設定画面から実行時に変更できるよう、起動時定数ではなく都度 os.environ を見に行く。
|
||||
|
||||
未設定(空文字含む)なら None を返し、llm.complete() 側で DEFAULT_MODEL にフォールバックさせる。
|
||||
"""
|
||||
return os.environ.get("SEARCH_SUMMARY_MODEL") or None
|
||||
|
||||
|
||||
def _split_headline(text: str) -> tuple[str, str]:
|
||||
"""LLMの出力を (一行要約, 本文) に分ける。1行目が想定の形式でなければ見出しは空で返す。"""
|
||||
lines = text.strip().splitlines()
|
||||
if not lines:
|
||||
return "", ""
|
||||
first = lines[0].strip().lstrip("#").lstrip("-*").strip().strip("*").strip()
|
||||
for marker in _HEADLINE_MARKERS:
|
||||
if not first.startswith(marker):
|
||||
continue
|
||||
headline = first[len(marker):].strip().strip("*").strip()
|
||||
body = "\n".join(lines[1:]).strip()
|
||||
if headline and body:
|
||||
return headline[:120], body
|
||||
break
|
||||
return "", text.strip()
|
||||
|
||||
|
||||
def derive_headline(query: str, summary: str) -> str:
|
||||
"""headline を持たないエントリ(この機能より前に保存されたもの)のための代替の見出し。"""
|
||||
for line in summary.splitlines():
|
||||
stripped = line.strip().lstrip("#").lstrip("-*").strip().strip("*").strip()
|
||||
if stripped:
|
||||
return stripped[:120]
|
||||
return query
|
||||
|
||||
|
||||
async def summarize_results(query: str, results: list[dict], model: str | None = None) -> tuple[str, str]:
|
||||
"""Web検索結果(title/url/content のリスト)をLLMで構造的なMarkdown要約にまとめる。
|
||||
|
||||
要約に失敗した場合は空文字を返す(生のスクレイピング結果を未加工のままファイルに
|
||||
戻り値は (一行要約, 本文)。一行要約はシステムプロンプトに並べる見出しとして使い、
|
||||
本文はDBとローカルナレッジベースに保存する。
|
||||
要約に失敗した場合は ("", "") を返す(生のスクレイピング結果を未加工のままファイルに
|
||||
書き込むと、ページに残ったスクリプト断片等がセキュリティソフトに誤検知されることがあるため、
|
||||
フォールバックとして生テキストを書き出すことはしない)。
|
||||
"""
|
||||
if not results:
|
||||
return ""
|
||||
return "", ""
|
||||
|
||||
raw = "\n\n".join(
|
||||
f"### {r.get('title') or r['url']}\n{r.get('content', '')[:1500]}"
|
||||
@@ -43,17 +89,20 @@ async def summarize_results(query: str, results: list[dict], model: str | None =
|
||||
if r.get("content")
|
||||
)
|
||||
if not raw:
|
||||
return ""
|
||||
return "", ""
|
||||
|
||||
prompt = _SUMMARY_PROMPT.format(query=query, results=raw)
|
||||
try:
|
||||
return await llm.complete(
|
||||
text = await llm.complete(
|
||||
[{"role": "user", "content": prompt}],
|
||||
model=model or SEARCH_SUMMARY_MODEL,
|
||||
model=model or _summary_model(),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to summarize search results for query=%r", query)
|
||||
return ""
|
||||
return "", ""
|
||||
|
||||
headline, body = _split_headline(text)
|
||||
return headline or derive_headline(query, body), body
|
||||
|
||||
|
||||
def _strip_tags(html: str) -> str:
|
||||
@@ -111,12 +160,13 @@ async def regenerate_from_sources(db: DBSession) -> int:
|
||||
if not results:
|
||||
continue
|
||||
|
||||
new_summary = await summarize_results(entry.query, results)
|
||||
new_headline, new_summary = await summarize_results(entry.query, results)
|
||||
if not new_summary:
|
||||
continue
|
||||
|
||||
old_summary = entry.summary
|
||||
entry.summary = new_summary
|
||||
entry.headline = new_headline or derive_headline(entry.query, new_summary)
|
||||
db.commit()
|
||||
|
||||
local_knowledge.delete_search_result_file(entry.query, old_summary)
|
||||
@@ -129,9 +179,11 @@ async def regenerate_from_sources(db: DBSession) -> int:
|
||||
return count
|
||||
|
||||
|
||||
def save_knowledge(db: DBSession, query: str, summary: str, sources: list[dict]) -> KnowledgeEntry:
|
||||
def save_knowledge(
|
||||
db: DBSession, query: str, summary: str, sources: list[dict], headline: str = "",
|
||||
) -> KnowledgeEntry:
|
||||
"""Web検索で得た知見をDBに保存する。"""
|
||||
entry = KnowledgeEntry(query=query, summary=summary)
|
||||
entry = KnowledgeEntry(query=query, summary=summary, headline=headline or derive_headline(query, summary))
|
||||
db.add(entry)
|
||||
db.flush()
|
||||
for src in sources:
|
||||
@@ -143,8 +195,80 @@ def save_knowledge(db: DBSession, query: str, summary: str, sources: list[dict])
|
||||
return entry
|
||||
|
||||
|
||||
READ_KNOWLEDGE_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_knowledge",
|
||||
"description": "過去のWeb検索で蓄積した知識の本文を読み出す。システムプロンプトに提示された"
|
||||
"「調べた事柄の一覧」の中に今の話題に関係しそうな項目があり、その本文がまだ提示されていないときに使う。"
|
||||
"ここで読めるならWeb検索をやり直す必要はない。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "読み出す項目のクエリ(一覧の「」内に載っているもの)"},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
KNOWLEDGE_TOOLS = [READ_KNOWLEDGE_TOOL]
|
||||
|
||||
|
||||
def _find_entry(db: DBSession, query: str) -> KnowledgeEntry | None:
|
||||
"""クエリで蓄積知識を探す。完全一致 → 大文字小文字・前後空白を無視した一致 → 部分一致 の順に試す。
|
||||
|
||||
同じクエリで複数回検索されている場合は、最も新しいものを返す。
|
||||
"""
|
||||
entries = db.query(KnowledgeEntry).order_by(KnowledgeEntry.created_at.desc()).all()
|
||||
for entry in entries:
|
||||
if entry.query == query:
|
||||
return entry
|
||||
normalized = query.strip().casefold()
|
||||
for entry in entries:
|
||||
if entry.query.strip().casefold() == normalized:
|
||||
return entry
|
||||
for entry in entries:
|
||||
if normalized and normalized in entry.query.strip().casefold():
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def read_knowledge(db: DBSession, query: str) -> str:
|
||||
"""蓄積知識の本文をツール結果用のテキストとして返す。"""
|
||||
entry = _find_entry(db, query)
|
||||
if not entry:
|
||||
available = ", ".join(
|
||||
e.query
|
||||
for e in db.query(KnowledgeEntry).order_by(KnowledgeEntry.created_at.desc()).limit(KNOWLEDGE_CONTEXT_LIMIT)
|
||||
)
|
||||
return f"「{query}」に一致する知識はありません。読み出せる項目: {available or '(なし)'}"
|
||||
|
||||
content = entry.summary
|
||||
if len(content) > KNOWLEDGE_TOOL_CONTENT_LIMIT:
|
||||
content = content[:KNOWLEDGE_TOOL_CONTENT_LIMIT] + "\n…(以下省略)"
|
||||
urls = ", ".join(s.url for s in entry.sources) or "出典不明"
|
||||
return f"「{entry.query}」について調べた内容:\n\n{content}\n\n(出典: {urls})"
|
||||
|
||||
|
||||
def execute_knowledge_tool(db: DBSession, name: str, args: dict) -> str:
|
||||
"""蓄積知識のツールを実行する。ノート系と違いDBを変更しないため、戻り値はテキストのみ。"""
|
||||
try:
|
||||
if name == "read_knowledge":
|
||||
return read_knowledge(db, args.get("query", ""))
|
||||
except Exception as e:
|
||||
logger.exception("Knowledge tool %s failed", name)
|
||||
return f"知識の読み出しに失敗しました: {e}"
|
||||
return f"未知のツールです: {name}"
|
||||
|
||||
|
||||
def get_knowledge_context(db: DBSession) -> str | None:
|
||||
"""蓄積された知識を、システムプロンプトに注入できる形のテキストにして返す。"""
|
||||
"""蓄積された知識の「見出し一覧」を、システムプロンプトに注入できる形のテキストにして返す。
|
||||
|
||||
本文(summary)はここには含めない。同じ内容が knowledge_base/web_search/ 配下にも
|
||||
Markdownとして保存されており、話題に関連していればローカルナレッジベース側が
|
||||
全文を注入するため、ここで全件の本文を積むと同じ情報を二重に渡すことになる。
|
||||
"""
|
||||
entries = (
|
||||
db.query(KnowledgeEntry)
|
||||
.order_by(KnowledgeEntry.created_at.desc())
|
||||
@@ -154,12 +278,14 @@ def get_knowledge_context(db: DBSession) -> str | None:
|
||||
if not entries:
|
||||
return None
|
||||
|
||||
blocks = []
|
||||
for entry in reversed(entries):
|
||||
urls = ", ".join(s.url for s in entry.sources) or "出典不明"
|
||||
blocks.append(f"- 「{entry.query}」について: {entry.summary}\n (出典: {urls})")
|
||||
blocks = [
|
||||
f"- 「{entry.query}」: {entry.headline or derive_headline(entry.query, entry.summary)}"
|
||||
for entry in reversed(entries)
|
||||
]
|
||||
|
||||
return (
|
||||
"以下はこれまでのWeb検索で得た知識です。関連する話題が出たら活用してください。"
|
||||
"ただしローカルナレッジベースの資料が別途提示されている場合は、そちらを優先してください:\n\n" + "\n".join(blocks)
|
||||
"以下はこれまでのWeb検索で調べた事柄の一覧です(見出しのみで、本文は含みません)。\n"
|
||||
"関連する話題が出たときは、まず本文がローカルナレッジベースの資料として別途提示されていないか確認し、"
|
||||
"あればそれを本文として使ってください。提示されていない場合は read_knowledge ツールで読み出せます。"
|
||||
"いずれかで足りるならWeb検索をやり直す必要はありません:\n\n" + "\n".join(blocks)
|
||||
)
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
- sync_local_kb(): ディレクトリをスキャンし、新規/変更されたファイルだけ埋め込みを計算してDBに保存する
|
||||
- get_local_kb_context(): 直近のユーザー発言に埋め込み類似度の高いファイルを探し、システムプロンプト用テキストにする
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
@@ -18,6 +21,46 @@ from app.db.models import KnowledgeEntry, LocalKnowledgeDoc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 埋め込みの進行状況。GUIに出すためだけのプロセス内の状態で、永続化はしない
|
||||
# (uvicornを複数ワーカーで動かすとワーカーごとに別の値になる点に注意)。
|
||||
_progress: dict = {
|
||||
"running": False,
|
||||
"total": 0,
|
||||
"done": 0,
|
||||
"failed": 0,
|
||||
"started_at": None,
|
||||
"finished_at": None,
|
||||
"last_error": None,
|
||||
"last_model": None,
|
||||
}
|
||||
|
||||
# create_task の戻り値。GCで途中終了しないように保持しておく
|
||||
_reembed_task: "asyncio.Task | None" = None
|
||||
|
||||
|
||||
def _start_progress(model: str, total: int) -> None:
|
||||
_progress.update(
|
||||
running=True, total=total, done=0, failed=0,
|
||||
started_at=datetime.now(timezone.utc), finished_at=None,
|
||||
last_error=None, last_model=model,
|
||||
)
|
||||
|
||||
|
||||
def _finish_progress() -> None:
|
||||
_progress.update(running=False, finished_at=datetime.now(timezone.utc))
|
||||
|
||||
|
||||
def _progress_snapshot() -> dict:
|
||||
return {
|
||||
"running": _progress["running"],
|
||||
"total": _progress["total"],
|
||||
"done": _progress["done"],
|
||||
"failed": _progress["failed"],
|
||||
"started_at": _progress["started_at"],
|
||||
"finished_at": _progress["finished_at"],
|
||||
"last_error": _progress["last_error"],
|
||||
}
|
||||
|
||||
|
||||
def _kb_dir() -> Path:
|
||||
return Path(LOCAL_KB_DIR)
|
||||
@@ -28,6 +71,11 @@ def _embedding_model() -> str:
|
||||
return os.environ.get("LOCAL_KB_EMBEDDING_MODEL", "text-embedding-3-small")
|
||||
|
||||
|
||||
def current_embedding_model() -> str:
|
||||
"""いま使われている埋め込みモデル名。"""
|
||||
return _embedding_model()
|
||||
|
||||
|
||||
def _hash(content: str) -> str:
|
||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
|
||||
@@ -88,46 +136,75 @@ def backfill_search_results(db: DBSession) -> int:
|
||||
return count
|
||||
|
||||
|
||||
async def sync_local_kb(db: DBSession) -> None:
|
||||
"""ナレッジベースディレクトリをスキャンし、新規/変更ファイルの埋め込みを更新する。
|
||||
削除されたファイルに対応するレコードも削除する。
|
||||
async def sync_local_kb(db: DBSession, force: bool = False) -> None:
|
||||
"""ナレッジベースディレクトリをスキャンし、埋め込みが古いファイルだけ計算し直す。
|
||||
|
||||
埋め込みを取り直すのは、ファイル内容が変わったか、埋め込みモデルが変わったとき
|
||||
(次元数の違うベクトルを比べても意味のあるスコアにならないため)。force=True なら全件やり直す。
|
||||
削除されたファイルに対応するレコードも消す。
|
||||
進捗は _progress に載せてGUIから見えるようにする。
|
||||
"""
|
||||
kb_dir = _kb_dir()
|
||||
if not kb_dir.is_dir():
|
||||
return
|
||||
|
||||
# 再埋め込みが走っている最中は、チャット毎の同期は譲って何もしない
|
||||
# (同じファイルを二重に埋め込むのを避けるため。ここまでawaitを挟まないので取りこぼしはない)。
|
||||
# force=True で呼ばれるのは start_reembed_all() が実行中フラグを立てた本人だけなので素通しする。
|
||||
if _progress["running"] and not force:
|
||||
return
|
||||
|
||||
model = _embedding_model()
|
||||
md_files = sorted(kb_dir.rglob("*.md"))
|
||||
seen_paths: set[str] = set()
|
||||
|
||||
# 先に対象を洗い出す。1件も無ければ進捗を触らない(毎リクエストの同期で履歴が消えないように)
|
||||
pending: list[tuple[Path, str, str, LocalKnowledgeDoc | None]] = []
|
||||
for file_path in md_files:
|
||||
rel_path = str(file_path.relative_to(kb_dir))
|
||||
seen_paths.add(rel_path)
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
content = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
content_hash = _hash(content)
|
||||
|
||||
existing = db.query(LocalKnowledgeDoc).filter(LocalKnowledgeDoc.path == rel_path).first()
|
||||
if existing and existing.content_hash == content_hash:
|
||||
up_to_date = (
|
||||
existing is not None
|
||||
and existing.content_hash == content_hash
|
||||
and existing.embedding_model == model
|
||||
)
|
||||
if up_to_date and not force:
|
||||
continue
|
||||
pending.append((file_path, rel_path, content, existing))
|
||||
|
||||
if pending:
|
||||
_start_progress(model, len(pending))
|
||||
try:
|
||||
[embedding] = await llm.embed([content], model=_embedding_model())
|
||||
except Exception:
|
||||
logger.exception("Failed to embed local knowledge file: %s", rel_path)
|
||||
continue
|
||||
for file_path, rel_path, content, existing in pending:
|
||||
try:
|
||||
[embedding] = await llm.embed([content], model=model)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to embed local knowledge file: %s", rel_path)
|
||||
_progress["failed"] += 1
|
||||
_progress["last_error"] = f"{rel_path}: {e}"[:500]
|
||||
continue
|
||||
|
||||
title = _title_from_content(content, fallback=file_path.stem)
|
||||
if existing:
|
||||
existing.content_hash = content_hash
|
||||
existing.title = title
|
||||
existing.embedding_json = json.dumps(embedding)
|
||||
else:
|
||||
db.add(LocalKnowledgeDoc(
|
||||
path=rel_path,
|
||||
title=title,
|
||||
content_hash=content_hash,
|
||||
embedding_json=json.dumps(embedding),
|
||||
))
|
||||
db.commit()
|
||||
title = _title_from_content(content, fallback=file_path.stem)
|
||||
if existing:
|
||||
existing.content_hash = _hash(content)
|
||||
existing.title = title
|
||||
existing.embedding_json = json.dumps(embedding)
|
||||
existing.embedding_model = model
|
||||
else:
|
||||
db.add(LocalKnowledgeDoc(
|
||||
path=rel_path,
|
||||
title=title,
|
||||
content_hash=_hash(content),
|
||||
embedding_json=json.dumps(embedding),
|
||||
embedding_model=model,
|
||||
))
|
||||
db.commit()
|
||||
_progress["done"] += 1
|
||||
finally:
|
||||
_finish_progress()
|
||||
|
||||
stale = db.query(LocalKnowledgeDoc).filter(LocalKnowledgeDoc.path.notin_(seen_paths)).all()
|
||||
for doc in stale:
|
||||
@@ -136,6 +213,85 @@ async def sync_local_kb(db: DBSession) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
async def reembed_all() -> None:
|
||||
"""全ファイルを現在のモデルで埋め込み直す。バックグラウンドタスクから呼ぶ想定で、DBセッションは自前で持つ。"""
|
||||
from app.db.database import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
await sync_local_kb(db, force=True)
|
||||
except Exception as e:
|
||||
logger.exception("Re-embedding of the local knowledge base failed")
|
||||
_progress["last_error"] = str(e)[:500]
|
||||
finally:
|
||||
# 対象0件で sync 側が進捗を触らなかった場合でも、実行中フラグは必ず下ろす
|
||||
_finish_progress()
|
||||
db.close()
|
||||
|
||||
|
||||
def start_reembed_all() -> bool:
|
||||
"""再埋め込みをバックグラウンドで開始する。すでに走っていれば False を返して何もしない。"""
|
||||
global _reembed_task
|
||||
if _progress["running"]:
|
||||
return False
|
||||
# GUIは開始リクエストの応答をそのまま状態表示に使うので、タスクが動き出すのを待たず
|
||||
# ここで「実行中」にしておく(総数は実際にスキャンした時点で入る)。
|
||||
_start_progress(_embedding_model(), 0)
|
||||
# create_task の戻り値を保持しないとGCで途中終了することがあるためモジュールに持たせる
|
||||
_reembed_task = asyncio.create_task(reembed_all())
|
||||
return True
|
||||
|
||||
|
||||
def embedding_status(db: DBSession) -> dict:
|
||||
"""GUIの「埋め込み」表示用。現在のモデルと、そのモデルで埋め込み済みの件数を返す。"""
|
||||
model = _embedding_model()
|
||||
kb_dir = _kb_dir()
|
||||
file_count = len(list(kb_dir.rglob("*.md"))) if kb_dir.is_dir() else 0
|
||||
|
||||
rows = db.query(LocalKnowledgeDoc.embedding_model, LocalKnowledgeDoc.embedding_json).all()
|
||||
indexed = 0
|
||||
dimensions = None
|
||||
for row_model, embedding_json in rows:
|
||||
if row_model != model:
|
||||
continue
|
||||
indexed += 1
|
||||
if dimensions is None:
|
||||
dimensions = len(json.loads(embedding_json))
|
||||
|
||||
return {
|
||||
"model": model,
|
||||
"dimensions": dimensions,
|
||||
"file_count": file_count,
|
||||
"indexed": indexed,
|
||||
# 別のモデルで埋め込まれた(=埋め直しが要る)行。モデル不明の古い行も含む
|
||||
"stale": len(rows) - indexed,
|
||||
**_progress_snapshot(),
|
||||
}
|
||||
|
||||
|
||||
async def probe_embedding() -> dict:
|
||||
"""埋め込みモデルが実際に応答するかを短いテキストで確認する。GUIの「接続テスト」用。"""
|
||||
model = _embedding_model()
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
[vector] = await llm.embed(["これは埋め込みモデルの疎通確認です。"], model=model)
|
||||
except Exception as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"model": model,
|
||||
"dimensions": None,
|
||||
"latency_ms": round((time.perf_counter() - start) * 1000, 1),
|
||||
"error": f"{type(e).__name__}: {e}"[:500],
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"model": model,
|
||||
"dimensions": len(vector),
|
||||
"latency_ms": round((time.perf_counter() - start) * 1000, 1),
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
async def get_local_kb_context(db: DBSession, query_text: str) -> str | None:
|
||||
"""直近のユーザー発言に関連するローカルMarkdown知識をシステムプロンプト用テキストにして返す。"""
|
||||
if not query_text.strip():
|
||||
@@ -174,3 +330,79 @@ async def get_local_kb_context(db: DBSession, query_text: str) -> str | None:
|
||||
"ここに必要な情報が含まれている場合はWeb検索を行わず、この内容のみで回答してください。"
|
||||
"情報が不足する場合に限り、補足としてWeb検索を使ってください:\n\n" + "\n\n".join(blocks)
|
||||
)
|
||||
|
||||
|
||||
# --- GUI からの閲覧用(埋め込みやコンテキスト注入とは無関係の、ファイルブラウズ機能) ---
|
||||
|
||||
# 一覧のタイトル・プレビュー生成のために読む先頭バイト数(全文を読まずに済ませるため)
|
||||
_PREVIEW_READ_CHARS = 2000
|
||||
|
||||
|
||||
def _preview_from_content(content: str) -> str:
|
||||
"""本文から見出し・空行を除いた最初の行を、一覧に出すプレビューとして切り出す。"""
|
||||
for line in content.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith("#"):
|
||||
return stripped[:120]
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_kb_path(rel_path: str) -> Path | None:
|
||||
"""ブラウザから渡された相対パスを実ファイルに解決する。
|
||||
|
||||
ナレッジベースディレクトリの外を指すパス(`../` など)や .md 以外は None を返す。
|
||||
"""
|
||||
root = _kb_dir().resolve()
|
||||
try:
|
||||
target = (root / rel_path).resolve()
|
||||
except OSError:
|
||||
return None
|
||||
if not target.is_relative_to(root) or target.suffix.lower() != ".md":
|
||||
return None
|
||||
return target if target.is_file() else None
|
||||
|
||||
|
||||
def list_kb_files() -> list[dict]:
|
||||
"""ナレッジベース配下の Markdown を一覧にする。本文は先頭だけ読む。"""
|
||||
root = _kb_dir()
|
||||
if not root.is_dir():
|
||||
return []
|
||||
|
||||
files = []
|
||||
for file_path in sorted(root.rglob("*.md")):
|
||||
rel_path = file_path.relative_to(root)
|
||||
try:
|
||||
stat = file_path.stat()
|
||||
with file_path.open(encoding="utf-8", errors="replace") as f:
|
||||
head = f.read(_PREVIEW_READ_CHARS)
|
||||
except OSError:
|
||||
logger.exception("Failed to read local knowledge file: %s", rel_path)
|
||||
continue
|
||||
files.append({
|
||||
"path": rel_path.as_posix(),
|
||||
"name": file_path.name,
|
||||
"directory": rel_path.parent.as_posix() if rel_path.parent != Path(".") else "",
|
||||
"title": _title_from_content(head, fallback=file_path.stem),
|
||||
"preview": _preview_from_content(head),
|
||||
"size": stat.st_size,
|
||||
"updated_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
||||
})
|
||||
return files
|
||||
|
||||
|
||||
def read_kb_file(rel_path: str) -> dict | None:
|
||||
"""1ファイルの全文を返す。パスが不正・存在しない場合は None。"""
|
||||
target = resolve_kb_path(rel_path)
|
||||
if target is None:
|
||||
return None
|
||||
content = target.read_text(encoding="utf-8", errors="replace")
|
||||
stat = target.stat()
|
||||
normalized = target.resolve().relative_to(_kb_dir().resolve()).as_posix()
|
||||
return {
|
||||
"path": normalized,
|
||||
"name": target.name,
|
||||
"title": _title_from_content(content, fallback=target.stem),
|
||||
"content": content,
|
||||
"size": stat.st_size,
|
||||
"updated_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
from app.db.models import Memory, Message, Session
|
||||
from app.core.config import MEMORY_SUMMARIZE_THRESHOLD
|
||||
@@ -11,7 +13,13 @@ from app.core import notes
|
||||
async def get_context_messages(db: DBSession, session_id: str) -> list[dict]:
|
||||
"""蓄積知識・長期メモリ(要約)・短期メモリ(最近のメッセージ)を結合してコンテキストを返す。"""
|
||||
memories = db.query(Memory).filter(Memory.session_id == session_id).order_by(Memory.created_at).all()
|
||||
messages = db.query(Message).filter(Message.session_id == session_id).order_by(Message.created_at).all()
|
||||
# アーカイブ済み(要約に取り込まれた)メッセージは、その内容が長期メモリ側に入っているため除外する。
|
||||
messages = (
|
||||
db.query(Message)
|
||||
.filter(Message.session_id == session_id, Message.archived_at.is_(None))
|
||||
.order_by(Message.created_at)
|
||||
.all()
|
||||
)
|
||||
|
||||
context: list[dict] = []
|
||||
|
||||
@@ -20,10 +28,15 @@ async def get_context_messages(db: DBSession, session_id: str) -> list[dict]:
|
||||
context.append({"role": "system", "content": knowledge_context})
|
||||
|
||||
# 共有ノートは「タイトル一覧」だけを常時見せ、本文は read_note ツールで読ませる
|
||||
notes_context = notes.get_notes_context(db)
|
||||
notes_context = notes.get_notes_context(db, session_id)
|
||||
if notes_context:
|
||||
context.append({"role": "system", "content": notes_context})
|
||||
|
||||
# ただしユーザーがこのセッションに紐付けたページは、本文もそのまま載せる
|
||||
session_notes_context = notes.get_session_notes_context(db, session_id)
|
||||
if session_notes_context:
|
||||
context.append({"role": "system", "content": session_notes_context})
|
||||
|
||||
if messages:
|
||||
latest_user_message = next((m.content for m in reversed(messages) if m.role == "user"), "")
|
||||
local_kb_context = await local_knowledge.get_local_kb_context(db, latest_user_message)
|
||||
@@ -42,8 +55,18 @@ async def get_context_messages(db: DBSession, session_id: str) -> list[dict]:
|
||||
|
||||
|
||||
async def maybe_summarize(db: DBSession, session: Session) -> None:
|
||||
"""メッセージ数が閾値を超えたら古いメッセージを要約してMemoryに保存し、元メッセージを削除する。"""
|
||||
messages = db.query(Message).filter(Message.session_id == session.id).order_by(Message.created_at).all()
|
||||
"""メッセージ数が閾値を超えたら古いメッセージを要約してMemoryに保存し、元メッセージをアーカイブする。
|
||||
|
||||
アーカイブしたメッセージはLLMに渡すコンテキストからは外れるが、人間が後から履歴を
|
||||
確認できるようDBには残す(物理削除はしない)。閾値の判定もアーカイブ済みを除いた
|
||||
件数で行うため、一度要約した分が再び対象になることはない。
|
||||
"""
|
||||
messages = (
|
||||
db.query(Message)
|
||||
.filter(Message.session_id == session.id, Message.archived_at.is_(None))
|
||||
.order_by(Message.created_at)
|
||||
.all()
|
||||
)
|
||||
if len(messages) < MEMORY_SUMMARIZE_THRESHOLD:
|
||||
return
|
||||
|
||||
@@ -60,8 +83,12 @@ async def maybe_summarize(db: DBSession, session: Session) -> None:
|
||||
|
||||
memory = Memory(session_id=session.id, summary=summary_text)
|
||||
db.add(memory)
|
||||
db.flush() # どの要約に取り込まれたかをメッセージ側に持たせるためIDを確定させる
|
||||
|
||||
archived_at = datetime.now(timezone.utc)
|
||||
for msg in to_summarize:
|
||||
db.delete(msg)
|
||||
msg.archived_at = archived_at
|
||||
msg.memory_id = memory.id
|
||||
db.commit()
|
||||
db.refresh(memory)
|
||||
|
||||
|
||||
@@ -185,10 +185,16 @@ async def backfill_tags(db: DBSession, model: str | None = None, limit: int | No
|
||||
単体スクリプト・APIエンドポイントの両方から呼べるようにここに切り出している。
|
||||
戻り値: タグ付けしたメッセージ数
|
||||
"""
|
||||
# アーカイブ済み(要約に取り込まれた)メッセージは対象外。話題選びには要約側のタグを使うため、
|
||||
# 過去ログ全体にLLMを走らせる意味が薄い。
|
||||
query = (
|
||||
db.query(Message)
|
||||
.outerjoin(MessageTag, MessageTag.message_id == Message.id)
|
||||
.filter(Message.role.in_(["user", "assistant"]), MessageTag.id.is_(None))
|
||||
.filter(
|
||||
Message.role.in_(["user", "assistant"]),
|
||||
MessageTag.id.is_(None),
|
||||
Message.archived_at.is_(None),
|
||||
)
|
||||
.order_by(Message.created_at.asc())
|
||||
)
|
||||
if limit:
|
||||
|
||||
+178
-4
@@ -2,14 +2,25 @@
|
||||
|
||||
システムプロンプトにはページの「タイトル一覧」だけを注入し、本文はモデルが
|
||||
read_note ツールで必要なときだけ読み出す。書き込みは write_note ツールで行う。
|
||||
|
||||
例外として、ユーザーがセッションに紐付けたページ(SessionNotePage)は本文も
|
||||
毎回そのままシステムプロンプトに載る。ツールの往復を待たずに確実に参照させたい
|
||||
資料のための仕組みで、指定はセッション単位なので会話ごとに組み合わせを変えられる。
|
||||
"""
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
import zipfile
|
||||
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.core.config import NOTES_TOOL_CONTENT_LIMIT
|
||||
from app.db.models import NotePage
|
||||
from app.core.config import (
|
||||
NOTES_IMPORT_MAX_BYTES,
|
||||
NOTES_IMPORT_MAX_PAGES,
|
||||
NOTES_TOOL_CONTENT_LIMIT,
|
||||
SESSION_NOTES_CONTENT_LIMIT,
|
||||
)
|
||||
from app.db.models import NotePage, SessionNotePage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -138,9 +149,169 @@ def rename_section(db: DBSession, old_name: str, new_name: str | None) -> int:
|
||||
return len(pages)
|
||||
|
||||
|
||||
def get_notes_context(db: DBSession) -> str | None:
|
||||
"""共有ノートのタイトル一覧をシステムプロンプト用のテキストとして返す。セクションごとに見出しを付ける。"""
|
||||
# 取り込めるテキストファイルの拡張子。これ以外はZIP内でも単体でも無視する
|
||||
_TEXT_SUFFIXES = (".md", ".markdown", ".txt", ".text")
|
||||
# ダウンロードしたZIPのファイル名に付く並び順の通し番号(01_タイトル.md)
|
||||
_EXPORT_PREFIX = re.compile(r"^\d{2,}_")
|
||||
|
||||
|
||||
def _decode(raw: bytes) -> str:
|
||||
"""取り込んだファイルを文字列にする。UTF-8優先、日本語Windows由来のCP932も拾う。"""
|
||||
for encoding in ("utf-8-sig", "utf-8", "cp932"):
|
||||
try:
|
||||
return raw.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _title_from_filename(filename: str) -> str:
|
||||
"""ファイル名からページタイトルを作る。拡張子とエクスポート時の通し番号を落とす。"""
|
||||
stem = filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
for suffix in _TEXT_SUFFIXES:
|
||||
if stem.lower().endswith(suffix):
|
||||
stem = stem[: -len(suffix)]
|
||||
break
|
||||
return _EXPORT_PREFIX.sub("", stem).strip() or "無題のページ"
|
||||
|
||||
|
||||
def _unique_title(db: DBSession, title: str, taken: set[str]) -> str:
|
||||
"""既存ページや同時取り込み分とタイトルがぶつかったら「(2)」を付けてずらす。
|
||||
|
||||
上書きせずに増やすのは、エクスポートしたZIPを既存のノートに投げ戻したときに
|
||||
元のページを壊さないため。要らない方は後からタブの × で消せる。
|
||||
"""
|
||||
existing = {p.title for p in db.query(NotePage.title).all()} | taken
|
||||
if title not in existing:
|
||||
return title
|
||||
n = 2
|
||||
while f"{title} ({n})" in existing:
|
||||
n += 1
|
||||
return f"{title} ({n})"
|
||||
|
||||
|
||||
def _create_imported_page(db: DBSession, title: str, content: str, section: str | None) -> NotePage:
|
||||
page = NotePage(title=title, content=content, position=next_position(db))
|
||||
db.add(page)
|
||||
db.commit()
|
||||
db.refresh(page)
|
||||
if section:
|
||||
# 同じセクションの末尾に並べ直す(連続配置の前提を保つ)
|
||||
page = set_section(db, page.id, section) or page
|
||||
return page
|
||||
|
||||
|
||||
def import_files(db: DBSession, files: list[tuple[str, bytes]]) -> list[NotePage]:
|
||||
"""アップロードされたファイルをノートのページとして取り込む。
|
||||
|
||||
files は (ファイル名, 中身) のリスト。.md / .txt はそのまま1ページになり、.zip は
|
||||
中のテキストファイルを1つずつページにする(ZIP内のフォルダ名がセクションになる)。
|
||||
エクスポートで出したZIPをそのまま投げ戻せば、セクションと並び順ごと復元できる。
|
||||
|
||||
取り込めないファイル(拡張子が対象外・大きすぎる)は黙って飛ばす。作成したページを返す。
|
||||
"""
|
||||
created: list[NotePage] = []
|
||||
taken: set[str] = set()
|
||||
|
||||
def add(filename: str, raw: bytes, section: str | None) -> None:
|
||||
if len(created) >= NOTES_IMPORT_MAX_PAGES or len(raw) > NOTES_IMPORT_MAX_BYTES:
|
||||
logger.info("Skipped note import: %s (%d bytes)", filename, len(raw))
|
||||
return
|
||||
title = _unique_title(db, _title_from_filename(filename), taken)
|
||||
taken.add(title)
|
||||
created.append(_create_imported_page(db, title, _decode(raw), section))
|
||||
|
||||
for filename, raw in files:
|
||||
if filename.lower().endswith(".zip"):
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(raw)) as archive:
|
||||
for info in archive.infolist():
|
||||
if info.is_dir() or not info.filename.lower().endswith(_TEXT_SUFFIXES):
|
||||
continue
|
||||
if info.file_size > NOTES_IMPORT_MAX_BYTES:
|
||||
logger.info("Skipped zip entry: %s (%d bytes)", info.filename, info.file_size)
|
||||
continue
|
||||
# ZIP内のフォルダ名をセクションにする(ネストは最初の階層だけ見る)
|
||||
parts = info.filename.replace("\\", "/").split("/")
|
||||
section = parts[0] if len(parts) > 1 and parts[0] not in ("", ".", "..") else None
|
||||
add(info.filename, archive.read(info), section)
|
||||
except zipfile.BadZipFile:
|
||||
logger.warning("Not a valid zip: %s", filename)
|
||||
elif filename.lower().endswith(_TEXT_SUFFIXES):
|
||||
add(filename, raw, None)
|
||||
else:
|
||||
logger.info("Skipped unsupported file: %s", filename)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
def get_attached_page_ids(db: DBSession, session_id: str) -> list[int]:
|
||||
"""セッションに紐付けられた(本文をプロンプトに載せる)ページIDを position 順で返す。"""
|
||||
rows = (
|
||||
db.query(SessionNotePage.page_id)
|
||||
.join(NotePage, NotePage.id == SessionNotePage.page_id)
|
||||
.filter(SessionNotePage.session_id == session_id)
|
||||
.order_by(NotePage.position, NotePage.id)
|
||||
.all()
|
||||
)
|
||||
return [row[0] for row in rows]
|
||||
|
||||
|
||||
def set_attached_pages(db: DBSession, session_id: str, page_ids: list[int]) -> list[int]:
|
||||
"""セッションに紐付けるページを page_ids で置き換える。存在しないIDは黙って捨てる。
|
||||
|
||||
戻り値は実際に紐付いたページIDを position 順で並べたもの。
|
||||
"""
|
||||
valid = {p.id for p in db.query(NotePage.id).filter(NotePage.id.in_(page_ids)).all()} if page_ids else set()
|
||||
|
||||
existing = db.query(SessionNotePage).filter(SessionNotePage.session_id == session_id).all()
|
||||
for link in existing:
|
||||
if link.page_id not in valid:
|
||||
db.delete(link)
|
||||
already = {link.page_id for link in existing if link.page_id in valid}
|
||||
for page_id in valid - already:
|
||||
db.add(SessionNotePage(session_id=session_id, page_id=page_id))
|
||||
db.commit()
|
||||
return get_attached_page_ids(db, session_id)
|
||||
|
||||
|
||||
def get_session_notes_context(db: DBSession, session_id: str) -> str | None:
|
||||
"""セッションに紐付けられたページの本文を、システムプロンプト用のテキストとして返す。
|
||||
|
||||
1ページも紐付いていなければ None。1ページあたり SESSION_NOTES_CONTENT_LIMIT 文字で切る。
|
||||
"""
|
||||
pages = (
|
||||
db.query(NotePage)
|
||||
.join(SessionNotePage, SessionNotePage.page_id == NotePage.id)
|
||||
.filter(SessionNotePage.session_id == session_id)
|
||||
.order_by(NotePage.position, NotePage.id)
|
||||
.all()
|
||||
)
|
||||
if not pages:
|
||||
return None
|
||||
|
||||
lines = [
|
||||
"【この会話で参照するノート】",
|
||||
"ユーザーがこの会話に紐付けたページです。本文をそのまま載せているので、"
|
||||
"これらのページに read_note を使う必要はありません。",
|
||||
"",
|
||||
]
|
||||
for page in pages:
|
||||
content = page.content.strip() or "(空)"
|
||||
if len(content) > SESSION_NOTES_CONTENT_LIMIT:
|
||||
content = content[:SESSION_NOTES_CONTENT_LIMIT] + "\n…(以下省略。全文は read_note で読めます)"
|
||||
lines += [f"## {page.title}", content, ""]
|
||||
return "\n".join(lines).rstrip()
|
||||
|
||||
|
||||
def get_notes_context(db: DBSession, session_id: str | None = None) -> str | None:
|
||||
"""共有ノートのタイトル一覧をシステムプロンプト用のテキストとして返す。セクションごとに見出しを付ける。
|
||||
|
||||
session_id を渡すと、そのセッションに紐付いた(本文が別途プロンプトに載る)ページに
|
||||
印を付ける。同じ本文を read_note で読み直させないため。
|
||||
"""
|
||||
pages = db.query(NotePage).order_by(NotePage.position, NotePage.id).all()
|
||||
attached = set(get_attached_page_ids(db, session_id)) if session_id else set()
|
||||
|
||||
lines = [
|
||||
"【共有ノート】",
|
||||
@@ -155,6 +326,9 @@ def get_notes_context(db: DBSession) -> str | None:
|
||||
current_section = page.section
|
||||
if current_section:
|
||||
lines.append(f"## {current_section}")
|
||||
if page.id in attached:
|
||||
lines.append(f"- {page.title} — (本文は「この会話で参照するノート」に全文あり)")
|
||||
continue
|
||||
preview = " ".join(page.content.split())[:40]
|
||||
suffix = f" — {preview}…" if preview else " — (空)"
|
||||
lines.append(f"- {page.title}{suffix}")
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import TAVILY_API_KEY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TAVILY_SEARCH_URL = "https://api.tavily.com/search"
|
||||
|
||||
|
||||
def _api_key() -> str | None:
|
||||
"""GUIの設定画面から実行時に変更できるよう、起動時定数ではなく都度 os.environ を見に行く。
|
||||
|
||||
app.core.config の定数は import 時に評価されるため、DBに保存した値を
|
||||
settings_store.load_into_env() が os.environ に入れても反映されない。
|
||||
"""
|
||||
return os.environ.get("TAVILY_API_KEY") or None
|
||||
|
||||
|
||||
WEB_SEARCH_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -27,7 +36,8 @@ WEB_SEARCH_TOOL = {
|
||||
|
||||
async def search_web(query: str, max_results: int = 5) -> list[dict]:
|
||||
"""Tavily APIでWeb検索する。結果は [{title, url, content}, ...]。失敗時は空リスト。"""
|
||||
if not TAVILY_API_KEY:
|
||||
api_key = _api_key()
|
||||
if not api_key:
|
||||
logger.warning("TAVILY_API_KEY is not set; skipping web search")
|
||||
return []
|
||||
|
||||
@@ -36,7 +46,7 @@ async def search_web(query: str, max_results: int = 5) -> list[dict]:
|
||||
response = await client.post(
|
||||
TAVILY_SEARCH_URL,
|
||||
json={
|
||||
"api_key": TAVILY_API_KEY,
|
||||
"api_key": api_key,
|
||||
"query": query,
|
||||
"max_results": max_results,
|
||||
},
|
||||
|
||||
@@ -7,6 +7,11 @@ from app.db.models import AppSetting
|
||||
# GUIから編集可能な環境変数の許可リスト。ここに無いキーは save()/clear() で拒否する。
|
||||
CONFIGURABLE_KEYS = [
|
||||
"OPENAI_API_KEY",
|
||||
# OpenAI 互換エンドポイント。ローカルの llama-server / Ollama を向けたまま忘れると、
|
||||
# OpenAI のモデルを選んでも接続先がそちらのままになる(litellm は OPENAI_API_BASE、
|
||||
# OpenAI SDK は OPENAI_BASE_URL を見るので両方を出しておく)。
|
||||
"OPENAI_BASE_URL",
|
||||
"OPENAI_API_BASE",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
@@ -16,8 +21,20 @@ CONFIGURABLE_KEYS = [
|
||||
"OLLAMA_API_BASE",
|
||||
"TAVILY_API_KEY",
|
||||
"LOCAL_KB_EMBEDDING_MODEL",
|
||||
"SEARCH_SUMMARY_MODEL",
|
||||
]
|
||||
|
||||
# 秘匿情報ではないキー。値をマスクせずそのまま返し、GUIでも通常のテキスト入力として扱う。
|
||||
# モデル名やURLは「今どれが効いているか」が見えないと設定しづらいため。
|
||||
NON_SECRET_KEYS = {
|
||||
"OPENAI_BASE_URL",
|
||||
"OPENAI_API_BASE",
|
||||
"AWS_REGION_NAME",
|
||||
"OLLAMA_API_BASE",
|
||||
"LOCAL_KB_EMBEDDING_MODEL",
|
||||
"SEARCH_SUMMARY_MODEL",
|
||||
}
|
||||
|
||||
# .env由来の起動時の値のスナップショット。clear() で戻す先として使う。
|
||||
_env_defaults: dict[str, str | None] = {}
|
||||
_snapshot_taken = False
|
||||
@@ -47,15 +64,18 @@ def load_into_env(db: DBSession) -> None:
|
||||
|
||||
|
||||
def list_masked(db: DBSession) -> list[dict]:
|
||||
"""GUI設定画面に出す一覧。秘匿キーの値はマスクし、そうでないキーは現在値をそのまま見せる。"""
|
||||
_ensure_snapshot()
|
||||
saved = {row.key: row.value for row in db.query(AppSetting).all()}
|
||||
result = []
|
||||
for key in CONFIGURABLE_KEYS:
|
||||
current = os.environ.get(key)
|
||||
secret = key not in NON_SECRET_KEYS
|
||||
result.append({
|
||||
"key": key,
|
||||
"is_set": bool(current),
|
||||
"masked": _mask(current) if current else None,
|
||||
"display_value": (_mask(current) if secret else current) if current else None,
|
||||
"secret": secret,
|
||||
"overridden": key in saved,
|
||||
})
|
||||
return result
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""直近で取得した「明日の天気」データをメモリ上にキャッシュする。"""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_latest: dict | None = None
|
||||
|
||||
@@ -11,7 +11,8 @@ def set_latest(weather: str, pop: int | None, judgement: str, reason: str) -> No
|
||||
"pop": pop,
|
||||
"judgement": judgement,
|
||||
"reason": reason,
|
||||
"fetched_at": datetime.now().isoformat(),
|
||||
# オフセット付きで返す(フロントの new Date() がローカル時刻と誤解しないようにするため)
|
||||
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -18,14 +18,25 @@ def get_db():
|
||||
db.close()
|
||||
|
||||
|
||||
# 既存DBに後から追加されたカラム: (テーブル名, カラム名, 型)
|
||||
_ADDED_COLUMNS = [
|
||||
("note_pages", "section", "VARCHAR"),
|
||||
("knowledge_entries", "headline", "VARCHAR"),
|
||||
("messages", "archived_at", "DATETIME"),
|
||||
("messages", "memory_id", "INTEGER"),
|
||||
("local_knowledge_docs", "embedding_model", "VARCHAR"),
|
||||
]
|
||||
|
||||
|
||||
def _add_missing_columns() -> None:
|
||||
"""マイグレーションフレームワークを使っていないため、既存DBに後から追加されたカラムを
|
||||
ここで簡易的に補う。create_all() は既存テーブルへのカラム追加は行わないので必要。"""
|
||||
with engine.connect() as conn:
|
||||
existing = {row[1] for row in conn.execute(text("PRAGMA table_info(note_pages)"))}
|
||||
if existing and "section" not in existing:
|
||||
conn.execute(text("ALTER TABLE note_pages ADD COLUMN section VARCHAR"))
|
||||
conn.commit()
|
||||
for table, column, column_type in _ADDED_COLUMNS:
|
||||
existing = {row[1] for row in conn.execute(text(f"PRAGMA table_info({table})"))}
|
||||
if existing and column not in existing:
|
||||
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {column_type}"))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def init_db():
|
||||
|
||||
+76
-16
@@ -1,7 +1,8 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Float
|
||||
from sqlalchemy import String, Text, ForeignKey, Integer, Float, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.db.database import Base
|
||||
from app.db.types import UtcDateTime
|
||||
|
||||
|
||||
class Session(Base):
|
||||
@@ -10,11 +11,12 @@ class Session(Base):
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String, default="New Chat")
|
||||
model: Mapped[str] = mapped_column(String, default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
messages: Mapped[list["Message"]] = relationship(back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at")
|
||||
memories: Mapped[list["Memory"]] = relationship(back_populates="session", cascade="all, delete-orphan")
|
||||
note_links: Mapped[list["SessionNotePage"]] = relationship(back_populates="session", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Message(Base):
|
||||
@@ -24,7 +26,14 @@ class Message(Base):
|
||||
session_id: Mapped[str] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE"))
|
||||
role: Mapped[str] = mapped_column(String) # "user" | "assistant" | "system"
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
|
||||
# 要約されて長期メモリに移ったメッセージ。LLMに渡すコンテキストからは外れるが、
|
||||
# 人間が後から履歴を確認できるようDBには残す(app.core.memory_kb.memory.maybe_summarize を参照)。
|
||||
archived_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
|
||||
# どの要約に取り込まれたか。要約を削除してもメッセージ自体は残す(SET NULL)。
|
||||
memory_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("memories.id", ondelete="SET NULL"), nullable=True,
|
||||
)
|
||||
|
||||
session: Mapped["Session"] = relationship(back_populates="messages")
|
||||
tags: Mapped[list["MessageTag"]] = relationship(back_populates="message", cascade="all, delete-orphan")
|
||||
@@ -36,7 +45,7 @@ class MessageTag(Base):
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
message_id: Mapped[int] = mapped_column(ForeignKey("messages.id", ondelete="CASCADE"))
|
||||
tag: Mapped[str] = mapped_column(String)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
message: Mapped["Message"] = relationship(back_populates="tags")
|
||||
|
||||
@@ -48,7 +57,7 @@ class Memory(Base):
|
||||
session_id: Mapped[str] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE"))
|
||||
summary: Mapped[str] = mapped_column(Text)
|
||||
embedding_json: Mapped[str | None] = mapped_column(Text, nullable=True) # 将来のベクトル検索用
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
session: Mapped["Session"] = relationship(back_populates="memories")
|
||||
tags: Mapped[list["MemoryTag"]] = relationship(back_populates="memory", cascade="all, delete-orphan")
|
||||
@@ -60,7 +69,7 @@ class MemoryTag(Base):
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
memory_id: Mapped[int] = mapped_column(ForeignKey("memories.id", ondelete="CASCADE"))
|
||||
tag: Mapped[str] = mapped_column(String)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
memory: Mapped["Memory"] = relationship(back_populates="tags")
|
||||
|
||||
@@ -72,7 +81,10 @@ class KnowledgeEntry(Base):
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
query: Mapped[str] = mapped_column(String)
|
||||
summary: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
# システムプロンプトには本文ではなくこの一行要約だけを並べる(app.core.memory_kb.knowledge を参照)。
|
||||
# このカラム追加より前に作られた行では None になるため、表示側で summary から導出する。
|
||||
headline: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
sources: Mapped[list["KnowledgeSource"]] = relationship(back_populates="entry", cascade="all, delete-orphan")
|
||||
|
||||
@@ -92,7 +104,7 @@ class LocalKnowledgeDoc(Base):
|
||||
"""backend/knowledge_base/ 配下のMarkdownファイルとその埋め込みベクトル。
|
||||
|
||||
話題に応じて関連ファイルをシステムプロンプトに注入するために使う。
|
||||
ファイル内容のハッシュが変わった時だけ埋め込みを再計算する。
|
||||
ファイル内容のハッシュか埋め込みモデルが変わった時だけ埋め込みを再計算する。
|
||||
"""
|
||||
__tablename__ = "local_knowledge_docs"
|
||||
|
||||
@@ -101,14 +113,19 @@ class LocalKnowledgeDoc(Base):
|
||||
title: Mapped[str] = mapped_column(String)
|
||||
content_hash: Mapped[str] = mapped_column(String)
|
||||
embedding_json: Mapped[str] = mapped_column(Text)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
# この埋め込みを計算したモデル名。モデルを切り替えた行を見分けて埋め直すために持つ
|
||||
# (次元数の違うベクトル同士を比較しても意味のないスコアしか出ないため)。
|
||||
# このカラムより前に作られた行は NULL で、モデル不明として埋め直し対象になる。
|
||||
embedding_model: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class NotePage(Base):
|
||||
"""ユーザーとAIが共同で編集する共有ノートの1ページ。UIではタブとして表示する。
|
||||
|
||||
ページのタイトル一覧は毎回システムプロンプトに注入され、本文はモデルが
|
||||
read_note ツールで読み出す(app.core.notes を参照)。
|
||||
read_note ツールで読み出す(app.core.notes を参照)。ただし SessionNotePage で
|
||||
セッションに紐付けられたページは、本文もそのままシステムプロンプトに載る。
|
||||
"""
|
||||
__tablename__ = "note_pages"
|
||||
|
||||
@@ -117,8 +134,30 @@ class NotePage(Base):
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
position: Mapped[int] = mapped_column(Integer, default=0) # タブの並び順(セクションでまとめる際、同じセクションのページは連続させる)
|
||||
section: Mapped[str | None] = mapped_column(String, nullable=True) # タブをグループ化するセクション名。未設定なら未分類
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
session_links: Mapped[list["SessionNotePage"]] = relationship(back_populates="page", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class SessionNotePage(Base):
|
||||
"""「このセッションではこのページの本文をプロンプトに載せる」という指定。
|
||||
|
||||
通常、共有ノートはタイトル一覧だけがシステムプロンプトに入り、本文はモデルが
|
||||
read_note ツールで取りに行く。ここに登録されたページはそれを待たず、本文が毎回
|
||||
そのままシステムプロンプトに入る(app.core.notes.get_session_notes_context を参照)。
|
||||
セッション単位なので、会話ごとに違う組み合わせを選べる。
|
||||
"""
|
||||
__tablename__ = "session_note_pages"
|
||||
__table_args__ = (UniqueConstraint("session_id", "page_id", name="uq_session_note_page"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[str] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE"))
|
||||
page_id: Mapped[int] = mapped_column(ForeignKey("note_pages.id", ondelete="CASCADE"))
|
||||
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
session: Mapped["Session"] = relationship(back_populates="note_links")
|
||||
page: Mapped["NotePage"] = relationship(back_populates="session_links")
|
||||
|
||||
|
||||
class AppSetting(Base):
|
||||
@@ -127,7 +166,7 @@ class AppSetting(Base):
|
||||
|
||||
key: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
value: Mapped[str] = mapped_column(Text)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class ModelPreset(Base):
|
||||
@@ -137,7 +176,7 @@ class ModelPreset(Base):
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
label: Mapped[str] = mapped_column(String)
|
||||
value: Mapped[str] = mapped_column(String, unique=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class ApiLog(Base):
|
||||
@@ -150,4 +189,25 @@ class ApiLog(Base):
|
||||
response_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
latency_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class Reminder(Base):
|
||||
"""Discord Bot がメンションで受け付けた一回きりのリマインド。
|
||||
|
||||
登録は app.core.discord.reminders の create_reminder ツール、配信は同モジュールの
|
||||
deliver_due_reminders() を APScheduler が定期的に呼ぶことで行う。
|
||||
日時は他のテーブルと同じくUTCで持ち、ユーザーとのやり取りの際にローカル時刻へ直す。
|
||||
"""
|
||||
__tablename__ = "reminders"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
channel_id: Mapped[str] = mapped_column(String) # 登録された(=配信先の)チャンネル
|
||||
user_id: Mapped[str] = mapped_column(String) # 依頼者。配信時にメンションする
|
||||
user_name: Mapped[str] = mapped_column(String) # 一覧表示とログ用の表示名
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
due_at: Mapped[datetime] = mapped_column(UtcDateTime)
|
||||
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
|
||||
# 配信済み。配信を諦めた場合もここを立てて再試行を止める(deliver_due_reminders を参照)
|
||||
notified_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
|
||||
cancelled_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""タイムゾーンを保ったまま日時を扱うためのカラム型。"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime
|
||||
from sqlalchemy.types import TypeDecorator
|
||||
|
||||
|
||||
class UtcDateTime(TypeDecorator):
|
||||
"""常に UTC の aware な datetime として読み書きする DateTime。
|
||||
|
||||
SQLite の DATETIME は tzinfo を保存できず、`DateTime(timezone=True)` を指定しても
|
||||
バインド時に黙って捨てられる(=naive な値が返ってくる)。そのままだと API のレスポンスが
|
||||
オフセット無しの ISO 文字列になり、フロントの `new Date()` がローカル時刻として
|
||||
解釈してしまうため、この型で入口と出口を揃える。
|
||||
|
||||
- 書き込み: aware な値は UTC に変換し、naive な値は UTC とみなす。いずれも naive な UTC として格納する
|
||||
- 読み出し: 格納値を UTC の aware な datetime として返す
|
||||
|
||||
格納フォーマットは素の DateTime と同じ(naive な UTC)なので、
|
||||
既存DBのデータはそのまま正しく解釈でき、マイグレーションは不要。
|
||||
"""
|
||||
|
||||
impl = DateTime
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value: datetime | None, dialect) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value
|
||||
return value.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
def process_result_value(self, value: datetime | None, dialect) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
+14
-2
@@ -1,15 +1,16 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api import auth as auth_api
|
||||
from app.api import chat, logs, memory, notes, system
|
||||
from app.api import chat, knowledge_base, logs, memory, notes, system
|
||||
from app.api import models as models_api
|
||||
from app.api import settings as settings_api
|
||||
from app.core import settings_store
|
||||
from app.core.config import CORS_ORIGINS
|
||||
from app.core.config import CORS_ORIGINS, LOG_LEVEL
|
||||
from app.core.site_auth import require_site_session
|
||||
from app.core.discord.discord_bot import start_discord_bot
|
||||
from app.core.discord.scheduler import start_scheduler
|
||||
@@ -17,6 +18,16 @@ from app.core.memory_kb.word_memory import seed_from_history
|
||||
from app.db.database import SessionLocal, init_db
|
||||
from app.db.models import Message, ModelPreset
|
||||
|
||||
# uvicorn は自分のロガーしか設定しないため、アプリ側の logging.getLogger(__name__) は
|
||||
# 何もしないと logging.lastResort 送りになる。それだと WARNING 未満が消えるうえ、
|
||||
# 出力にレベルもタイムスタンプもロガー名も付かず、ログから障害を追えない。
|
||||
logging.basicConfig(
|
||||
level=LOG_LEVEL,
|
||||
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
|
||||
)
|
||||
# LiteLLM は自前のハンドラを持つので、root にも流すと同じ行が二重に出る。
|
||||
logging.getLogger("LiteLLM").propagate = False
|
||||
|
||||
# ModelSelector.vue に元々ハードコードされていたプリセット。初回起動時のみDBへシードする。
|
||||
_DEFAULT_MODEL_PRESETS = [
|
||||
("GPT-5.5", "gpt-5.5"),
|
||||
@@ -94,6 +105,7 @@ app.include_router(auth_api.router)
|
||||
app.include_router(chat.router, dependencies=[Depends(require_site_session)])
|
||||
app.include_router(memory.router, dependencies=[Depends(require_site_session)])
|
||||
app.include_router(notes.router, dependencies=[Depends(require_site_session)])
|
||||
app.include_router(knowledge_base.router, dependencies=[Depends(require_site_session)])
|
||||
app.include_router(logs.router, dependencies=[Depends(require_site_session)])
|
||||
app.include_router(system.router, dependencies=[Depends(require_site_session)])
|
||||
app.include_router(settings_api.router, dependencies=[Depends(require_site_session)])
|
||||
|
||||
@@ -19,6 +19,7 @@ dependencies = [
|
||||
"discord-py>=2.7.1",
|
||||
"janome>=0.5.0",
|
||||
"trafilatura>=2.1.0",
|
||||
"python-multipart>=0.0.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
Generated
+11
@@ -1300,6 +1300,7 @@ dependencies = [
|
||||
{ name = "nvidia-ml-py" },
|
||||
{ name = "psutil" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "trafilatura" },
|
||||
@@ -1336,6 +1337,7 @@ requires-dist = [
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.9" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.0" },
|
||||
{ name = "sse-starlette", specifier = ">=2.1.0" },
|
||||
{ name = "trafilatura", specifier = ">=2.1.0" },
|
||||
@@ -1642,6 +1644,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.32"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2026.2"
|
||||
|
||||
@@ -8,6 +8,10 @@ services:
|
||||
environment:
|
||||
# SQLite ファイルと知識ベースを永続ボリュームに置く(.env の値を上書き)
|
||||
DATABASE_URL: sqlite:////app/data/playchat.db
|
||||
# コンテナのタイムゾーン。未設定だとUTCになり、天気通知のcron時刻・深夜スキップ判定・
|
||||
# 「明日」の日付計算がすべて9時間ずれる。プロセス起動前に効かせる必要があるため
|
||||
# .env ではなくここで指定する(後から os.environ に入れても libc には反映されない)。
|
||||
TZ: Asia/Tokyo
|
||||
volumes:
|
||||
- backend_data:/app/data
|
||||
- backend_knowledge_base:/app/knowledge_base
|
||||
|
||||
+167
-4
@@ -15,15 +15,22 @@ SSE でストリーミング応答を返す。
|
||||
{
|
||||
"message": "こんにちは",
|
||||
"session_id": "uuid(省略時は新規セッション作成)",
|
||||
"model": "gpt-5.4(省略時は DEFAULT_MODEL)"
|
||||
"model": "gpt-5.4(省略時は DEFAULT_MODEL)",
|
||||
"note_page_ids": [1, 2]
|
||||
}
|
||||
```
|
||||
|
||||
`note_page_ids` は「このセッションで本文をシステムプロンプトに載せる共有ノートのページ ID」。
|
||||
渡すとコンテキスト組み立ての前にサーバー側の指定を丸ごと置き換える(`PUT /chat/sessions/{id}/notes` と同じ内容)。
|
||||
新規セッションではフロントがまだ session_id を持てないため、初回はここでしか指定できない。省略すると現在の指定を変更しない。
|
||||
|
||||
**SSE イベント**
|
||||
| type | value | タイミング |
|
||||
|---|---|---|
|
||||
| `session_id` | セッション UUID | 新規セッション作成直後 |
|
||||
| `chunk` | テキスト断片 | LLM トークン生成ごと |
|
||||
| `tool` | `{id, name, label, detail, status, result}` | ツール実行の開始時 (`status: running`) と完了時 (`status: done`)。同じ `id` で 2 回届くのでフロントはマージして表示する |
|
||||
| `sources` | `[{title, url}]` | Web 検索した時のみ(出典一覧) |
|
||||
| `notes_updated` | — | AI が共有ノートを書き換えた直後 |
|
||||
| `done` | — | 正常完了 |
|
||||
| `error` | エラーメッセージ | 例外発生時 |
|
||||
@@ -48,18 +55,42 @@ SSE でストリーミング応答を返す。
|
||||
---
|
||||
|
||||
### GET /chat/sessions/{session_id}/messages
|
||||
セッションのメッセージ一覧を `created_at` 昇順で返す。
|
||||
セッションのメッセージ一覧を `created_at` 昇順で返す。要約に取り込まれた(アーカイブ済みの)
|
||||
メッセージも含めて返し、`archived_at` で区別する。アーカイブ済みのものは LLM に渡す
|
||||
コンテキストには入らないが、履歴としては残っている。
|
||||
|
||||
**レスポンス**
|
||||
```json
|
||||
[
|
||||
{ "id": 1, "role": "user", "content": "...", "created_at": "..." },
|
||||
{ "id": 2, "role": "assistant", "content": "...", "created_at": "..." }
|
||||
{ "id": 1, "role": "user", "content": "...", "created_at": "...", "archived_at": "2026-08-20T04:00:00+00:00", "tags": [] },
|
||||
{ "id": 2, "role": "assistant", "content": "...", "created_at": "...", "archived_at": null, "tags": [] }
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /chat/sessions/{session_id}/notes
|
||||
このセッションで本文をシステムプロンプトに載せるよう指定された共有ノートのページ ID を返す。
|
||||
順序はノートのタブ順(`position`)。セッションが無ければ `404`。
|
||||
|
||||
**レスポンス**
|
||||
```json
|
||||
{ "page_ids": [1, 2] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PUT /chat/sessions/{session_id}/notes
|
||||
指定を丸ごと置き換える。存在しないページ ID は黙って捨てられ、実際に紐付いた ID が返る。
|
||||
空配列を渡すと全解除。セッションが無ければ `404`。
|
||||
|
||||
**リクエスト**
|
||||
```json
|
||||
{ "page_ids": [1, 2] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DELETE /chat/sessions/{session_id}
|
||||
セッションと紐づくメッセージ・メモリをすべて削除。`204 No Content` を返す。
|
||||
|
||||
@@ -183,6 +214,138 @@ SSE でストリーミング応答を返す。
|
||||
|
||||
---
|
||||
|
||||
### POST /api/notes/import
|
||||
`multipart/form-data` の `files` でアップロードされたファイルをページとして取り込み、
|
||||
`201 Created` で作成したページの配列を返す。
|
||||
|
||||
| 拡張子 | 取り込み方 |
|
||||
|---|---|
|
||||
| `.md` / `.markdown` / `.txt` / `.text` | 1 ファイル 1 ページ。タイトルはファイル名から拡張子と `01_` 形式の通し番号を落としたもの |
|
||||
| `.zip` | 中のテキストファイルを 1 つずつページにする。第 1 階層のフォルダ名がセクションになる(`GET /api/notes/export` の ZIP をそのまま戻せる) |
|
||||
|
||||
- 既存ページとタイトルが衝突したら `タイトル (2)` のように連番を足して**別ページとして作る**(上書きはしない)
|
||||
- 文字コードは UTF-8 (BOM 可) → CP932 の順に試す
|
||||
- 対象外の拡張子・`NOTES_IMPORT_MAX_BYTES`(既定 2,000,000)超のファイル・`NOTES_IMPORT_MAX_PAGES`(既定 100)を超えた分・壊れた ZIP は**黙って飛ばす**
|
||||
- 1 ページも取り込めなかった場合のみ `400`
|
||||
|
||||
---
|
||||
|
||||
### GET /api/notes/export
|
||||
全ページを ZIP でダウンロードさせる(`Content-Disposition: attachment`)。
|
||||
1 ページ 1 ファイルの Markdown で、本文はそのまま。エントリ名は `<セクション>/<通し番号>_<タイトル>.md`
|
||||
(セクション無しのページは ZIP 直下)。通し番号は `position` 順なのでタブの並びが残る。
|
||||
ファイル名は `notes_YYYYMMDD.zip`。
|
||||
|
||||
---
|
||||
|
||||
### GET /api/notes/{page_id}/export
|
||||
1 ページを Markdown ファイルとしてダウンロードさせる。本文はそのままで、ファイル名がタイトルになる。
|
||||
ページが存在しない場合は `404`。
|
||||
|
||||
日本語タイトルを扱うため `Content-Disposition` には RFC 5987 形式を併記する:
|
||||
|
||||
```
|
||||
attachment; filename="______.md"; filename*=UTF-8''%E8%B2%B7%E3%81%84%E7%89%A9%E3%83%AA%E3%82%B9%E3%83%88.md
|
||||
```
|
||||
|
||||
ファイル名に使えない文字(`\ / : * ? " < > |` と制御文字)は `_` に置き換え、80 文字で切る。
|
||||
|
||||
---
|
||||
|
||||
## ローカルナレッジベース(閲覧)
|
||||
|
||||
`backend/knowledge_base/**.md` を GUI から読むための読み取り専用 API。編集・削除はできない。
|
||||
|
||||
### GET /api/kb/files
|
||||
ナレッジベース配下の Markdown を、パス順の一覧で返す(本文は含めない)。
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"path": "web_search/02b627ac75bb2fd4.md",
|
||||
"name": "02b627ac75bb2fd4.md",
|
||||
"directory": "web_search",
|
||||
"title": "SOUL.md use-soul-with-hermes",
|
||||
"preview": "SOUL.mdは、Hermes Agent インスタンスの主要な…",
|
||||
"size": 6150,
|
||||
"updated_at": "2026-06-21T05:03:52.684324Z",
|
||||
"indexed": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`directory` はナレッジベース直下なら空文字。`indexed` は `local_knowledge_docs` に埋め込み済み
|
||||
(=話題に応じた自動注入の対象になっている)かどうか。未埋め込みのファイルは次回のチャット時に
|
||||
`sync_local_kb()` が取り込む。
|
||||
|
||||
---
|
||||
|
||||
### GET /api/kb/file
|
||||
1 ファイルの全文を返す。
|
||||
|
||||
**クエリパラメーター**
|
||||
|
||||
| 名前 | 必須 | 説明 |
|
||||
|---|---|---|
|
||||
| `path` | ✓ | ナレッジベースディレクトリからの相対パス(`web_search/xxxx.md` など) |
|
||||
|
||||
ナレッジベースの外を指すパス(`../` や絶対パス)、`.md` 以外、存在しないファイルはすべて `404`。
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "fgo.md",
|
||||
"name": "fgo.md",
|
||||
"title": "Fate / Grand Order",
|
||||
"content": "# Fate / Grand Order
|
||||
|
||||
…",
|
||||
"size": 13942,
|
||||
"updated_at": "2026-06-20T09:39:45.542875Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/kb/embedding
|
||||
埋め込みモデルの現状と、再埋め込みの進捗を返す。
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "ollama/nomic-embed-text",
|
||||
"dimensions": 768,
|
||||
"file_count": 36,
|
||||
"indexed": 36,
|
||||
"stale": 0,
|
||||
"running": false,
|
||||
"total": 36,
|
||||
"done": 36,
|
||||
"failed": 0,
|
||||
"started_at": "2026-08-23T13:50:23.196274Z",
|
||||
"finished_at": "2026-08-23T13:50:29.951099Z",
|
||||
"last_error": null
|
||||
}
|
||||
```
|
||||
|
||||
`indexed` は**現在のモデルで**埋め込まれている件数、`stale` は別モデル(またはモデル不明)のまま残っている件数。
|
||||
`running` 以下は実行中の進捗で、プロセス内のメモリにしか無い(再起動で消える。ワーカーを複数立てるとワーカーごとに別の値になる)。
|
||||
|
||||
---
|
||||
|
||||
### POST /api/kb/embedding/test
|
||||
短いテキストを実際に 1 回埋め込んでみて、モデルが応答するかを確かめる。失敗しても 200 で返し、`error` に理由が入る。
|
||||
|
||||
```json
|
||||
{ "ok": true, "model": "ollama/nomic-embed-text", "dimensions": 768, "latency_ms": 412.3, "error": null }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /api/kb/embedding/reindex
|
||||
全ファイルの再埋め込みをバックグラウンドで開始し、開始直後の状態(`running: true`)を返す。
|
||||
すでに実行中なら何も起こさず現在の進捗を返す。
|
||||
|
||||
---
|
||||
|
||||
## ログ
|
||||
|
||||
### GET /logs
|
||||
|
||||
+77
-6
@@ -26,6 +26,7 @@ backend/
|
||||
│ │ ├── chat.py # チャット・セッション関連エンドポイント(SSE / export / import)
|
||||
│ │ ├── memory.py # 長期メモリ参照・削除
|
||||
│ │ ├── notes.py # 共有ノート CRUD
|
||||
│ │ ├── knowledge_base.py # ローカル KB の Markdown を GUI から閲覧(読み取り専用)
|
||||
│ │ ├── models.py # モデルプリセット CRUD
|
||||
│ │ ├── settings.py # GUI 設定画面(APIキー等)
|
||||
│ │ ├── system.py # タグ backfill・ワードプール参照などの運用系
|
||||
@@ -44,6 +45,10 @@ backend/
|
||||
│ │ ├── search/
|
||||
│ │ │ └── websearch.py # Tavily による web_search ツール
|
||||
│ │ ├── discord/ # Discord Bot・定期通知
|
||||
│ │ │ ├── discord_bot.py # メンションへの返信(天気 / 雑談 / リマインドで分岐)
|
||||
│ │ │ ├── scheduler.py # APScheduler ジョブ(天気通知・Tips・リマインド配信)
|
||||
│ │ │ ├── reminders.py # リマインドの登録ツールと配信ジョブ
|
||||
│ │ │ └── notify.py # Webhook 送信
|
||||
│ │ ├── settings_store.py # GUI 設定を os.environ へ反映
|
||||
│ │ └── auth.py / site_auth.py # 設定画面パスワード・サイトログイン
|
||||
│ └── db/
|
||||
@@ -64,11 +69,13 @@ backend/
|
||||
| `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 の埋め込みキャッシュ |
|
||||
| `local_knowledge_docs` | id, path, title, content_hash, embedding_json, embedding_model, updated_at | ローカル Markdown KB の埋め込みキャッシュ(`embedding_model` が現在の設定と違う行は埋め直す) |
|
||||
| `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 呼び出しログ |
|
||||
| `reminders` | id, channel_id, user_id, user_name, content, due_at, created_at, notified_at, cancelled_at | Discord Bot が預かった一回きりのリマインド(`due_at` は UTC) |
|
||||
|
||||
### コンテキスト管理(`core/memory_kb/`)
|
||||
|
||||
@@ -77,21 +84,28 @@ LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messag
|
||||
|
||||
| # | 層 | 中身 | 実装 | 量の制御 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 蓄積知識 | 過去の Web 検索で得た `knowledge_entries` の**要約全文**+出典 URL | `knowledge.get_knowledge_context()` | 最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件 |
|
||||
| 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()` がアシスタント応答の保存直後に走る。
|
||||
|
||||
1. メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)以上になったら発火
|
||||
2. 古い側の半分(既定 10 件)を LLM で要約し `memories` に 1 行保存
|
||||
3. **要約した元メッセージは DB から物理削除**する(復元不可。エクスポートしても生ログは残らない)
|
||||
3. **要約した元メッセージは `archived_at` / `memory_id` を立ててアーカイブ**する(物理削除はしない)。LLM に渡すコンテキストからも、次回以降の要約対象からも外れるが、DB には残るため履歴表示・エクスポートからは読める
|
||||
4. 保存した要約に `tagging.tag_memory()` でタグを付ける
|
||||
|
||||
#### ツール経由の遅延読み込み
|
||||
@@ -100,6 +114,7 @@ LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messag
|
||||
`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 に自動的に効いてくる
|
||||
@@ -113,8 +128,8 @@ LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messag
|
||||
|
||||
#### 設計上の注意点
|
||||
|
||||
- トークン数ベースの制御は無く、すべて件数・文字数ベース。層 1(知識 15 件の要約全文)と層 3(Markdown 全文 ×3)は
|
||||
会話が短くてもコンテキストを大きく押し上げる
|
||||
- トークン数ベースの制御は無く、すべて件数・文字数ベース。層 3(Markdown 全文 ×3)は会話が短くても
|
||||
コンテキストを大きく押し上げる(層 1 は見出しのみなので件数を増やしても軽い)
|
||||
- ツールを使わない会話でも `resolve_tool_calls()` が必ず 1 回フルコンテキストを投げるため、**LLM 呼び出しは最低 2 回**になる
|
||||
- 層 4(長期メモリ)に上限が無く、長いセッションでは要約が積み上がる(要約の再要約はしない)
|
||||
- `get_local_kb_context()` が毎リクエストで `sync_local_kb()`(ディレクトリスキャン)と埋め込み API 呼び出しを 1 回行う
|
||||
@@ -122,7 +137,7 @@ LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messag
|
||||
### LLM 呼び出し(`core/llm.py`)
|
||||
|
||||
- `stream_chat()` / `complete()` / `embed()` が LiteLLM を薄くラップ
|
||||
- `resolve_tool_calls()` が `web_search` / `read_note` / `write_note` を解決し、結果を組み込んだメッセージ列を返す
|
||||
- `resolve_tool_calls()` が `web_search` / `read_note` / `write_note` / `read_knowledge` を解決し、結果を組み込んだメッセージ列を返す
|
||||
- `_log_context` で呼び出しを囲み、成功・失敗どちらも `api_logs` テーブルに記録(`embed()` は対象外)
|
||||
- `DEBUG=true` で `litellm._turn_on_debug()` が有効になり詳細ログが出力される
|
||||
|
||||
@@ -145,6 +160,7 @@ LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messag
|
||||
|
||||
```
|
||||
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回以上
|
||||
@@ -152,8 +168,56 @@ 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` を付与する。
|
||||
|
||||
### Discord Bot(`core/discord/`)
|
||||
|
||||
`main.py` の lifespan が `start_discord_bot()`(メンション応答)と `start_scheduler()`(定期ジョブ)を起動する。
|
||||
|
||||
`on_message` はメンション本文をキーワードで見て分岐する。判定順は **リマインド → 雑談 → 天気** で、
|
||||
先にマッチしたものが返信を返して終わる。
|
||||
|
||||
| 分岐 | キーワード例 | 動作 |
|
||||
|---|---|---|
|
||||
| リマインド | リマインド / 忘れないように / 覚えといて / アラーム | `reminders.handle_reminder_request()` に渡す |
|
||||
| 雑談 | どうでもいい / 雑談 / 暇 | 『どうでもいいTips』へ誘導しつつ、話題に近いタグで Tips を1本投稿 |
|
||||
| 天気 | 天気 / 洗濯 / 雨 | 気象庁の明日の予報を system メッセージに足してから返信 |
|
||||
|
||||
APScheduler に登録されるジョブは 3 つ。
|
||||
|
||||
| ジョブ | トリガー | 内容 |
|
||||
|---|---|---|
|
||||
| `weather_laundry_notification` | Cron(`WEATHER_NOTIFY_HOUR:MINUTE`) | 明日の天気と洗濯指数を Webhook で投稿 |
|
||||
| `useless_tips` | Interval(`USELESS_TIPS_INTERVAL_HOURS`) | どうでもいい Tips を Webhook で投稿(3時台のみ抑制) |
|
||||
| `discord_reminders` | Interval(`REMINDER_CHECK_INTERVAL_SECONDS`) | 期限を過ぎたリマインドを配信 |
|
||||
|
||||
#### リマインド(`core/discord/reminders.py`)
|
||||
|
||||
一回きりのリマインドのみを扱う(繰り返しには非対応)。
|
||||
|
||||
- **登録**: LLM に `create_reminder` / `list_reminders` / `cancel_reminder` の 3 ツールを渡し、
|
||||
自然言語から日時と用件を取り出させる。system メッセージに現在時刻と曜日を入れてあるので、
|
||||
『明日』『30分後』といった相対表現はモデル側が絶対日時に直す。使うモデルは
|
||||
`REMINDER_MODEL` →`WEATHER_NOTIFY_MODEL` → `DEFAULT_MODEL` の順にフォールバックするが、
|
||||
**tool calling 対応モデルでないと動かない**(非対応時はエラー文を返す)
|
||||
- **バリデーション**: 過去・`REMINDER_MAX_AHEAD_DAYS` より先・解釈不能な日時はツール結果として
|
||||
エラー文をモデルに返し、モデルに聞き直させる
|
||||
- **配信先**: Webhook ではなく Bot クライアントから、登録されたチャンネルへ依頼者をメンションして投稿する。
|
||||
そのため専用の Webhook URL 設定は要らない
|
||||
- **タイムゾーン**: DB は他テーブルと同じく UTC。ユーザーおよび LLM とのやり取りはすべて
|
||||
このマシンのローカル時刻で行い、`reminders.py` の境界で変換する
|
||||
- **配信の失敗**: チャンネル削除などで送れない場合はプロセス内カウンタで数え、5 回失敗したら
|
||||
`notified_at` を立てて諦める(毎回エラーログを出し続けるのを防ぐ)。カウンタは DB に持たないので
|
||||
バックエンドを再起動すればまた試す
|
||||
- **バックエンド停止中に期限を迎えた分**: 起動して Bot が ready になった直後にまとめて配信される
|
||||
|
||||
---
|
||||
|
||||
## フロントエンド
|
||||
@@ -200,6 +264,13 @@ frontend/src/
|
||||
|
||||
- アシスタントメッセージのみ `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 プロキシ設定
|
||||
|
||||
|
||||
@@ -113,6 +113,11 @@ nano backend/.env # APIキーやモデル名など本番用の値を設定
|
||||
|
||||
`backend/.env` はコンテナに `env_file` として渡され、Git 管理対象外(`.gitignore` 済み)なので直接編集してよい。
|
||||
|
||||
**ローカルの `.env` をそのまま持ち込むときの注意:** `OPENAI_BASE_URL` / `OPENAI_API_BASE` を
|
||||
ローカルの llama-server や Ollama に向けていると、サーバー上では存在しない宛先に接続しに行くため、
|
||||
`gpt-*` のモデルを選んでも litellm が「Connection Error」になる。サーバーの `.env` からは消しておくこと
|
||||
(設定画面の「OpenAI 互換エンドポイント」からも上書きできる)。
|
||||
|
||||
### GUIからのAPIキー管理
|
||||
|
||||
初回起動後は、`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / AWS認証情報などをSSHで`.env`を編集し直さなくても、
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
# 機能一覧
|
||||
|
||||
このリポジトリにあるアプリ(PlayChat)が「何ができるか」を機能単位でまとめたもの。
|
||||
内部設計は [architecture.md](architecture.md)、エンドポイント仕様は [api.md](api.md)、
|
||||
Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は [notes-guide.md](notes-guide.md)、
|
||||
デプロイ手順は [deploy-lightsail.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` |
|
||||
| 6.5 | ナレッジベース閲覧パネル | KB の Markdown を一覧・全文表示。埋め込みの状態確認・接続テスト・再埋め込み | `api/knowledge_base.py` / `KnowledgeBasePanel.vue` |
|
||||
| 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` |
|
||||
| 17.5 | 右サイドパネルのメニュー | ノート/ナレッジベース/設定/ログを 1 つのメニューから切替 | `panels.ts` / `SidePanelMenu.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 文字 | 全ページ(本文は入れない) |
|
||||
| 2.5 参照指定ノート | このチャットに紐付けたページの**本文全文** | 紐付いたページのみ・1 ページ `SESSION_NOTES_CONTENT_LIMIT`(既定 8000)文字 |
|
||||
| 3 ローカル KB | 選ばれた Markdown の**全文** | 類似度上位 `LOCAL_KB_TOP_K`(既定 3)件かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上 |
|
||||
| 4 長期メモリ | 要約を全件連結 | 上限なし |
|
||||
| 5 短期メモリ | セッション内の全メッセージ | 上限なし(要約で間接的に制御) |
|
||||
|
||||
層 2.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`(未設定なら `DEFAULT_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` に
|
||||
内容ハッシュと**使用した埋め込みモデル名**付きでキャッシュされ、
|
||||
ファイルが変わったときと、埋め込みモデルが変わったときだけ取り直す。
|
||||
- 設定画面で `LOCAL_KB_EMBEDDING_MODEL` を変更すると、保存した時点でバックグラウンドの再埋め込みが走る
|
||||
(モデルが違えば次元も意味空間も変わり、古いベクトルは比較しても無意味なため)。
|
||||
パネルの「埋め直し」ボタンからも手動で開始できる。
|
||||
- プロンプト上は「Web 検索結果より優先。これで答えられるなら検索するな」と明示している。
|
||||
- ヘッダーのパネルメニュー →「ナレッジベース」で開くサイドパネル(`KnowledgeBasePanel.vue`)から中身を閲覧できる。
|
||||
パネル上部には埋め込みの状態(モデル名・次元数・埋め込み済み件数・進捗バー)が出る。
|
||||
「接続テスト」は短いテキストを 1 回埋め込んでみて、モデルが応答するかその場で確かめる。
|
||||
フォルダ単位のグループ表示・折りたたみ、タイトル/パス/本文先頭での絞り込み、Markdown 描画に対応。
|
||||
各ファイルの緑の点は埋め込み済み(=自動注入の対象)であることを示す。閲覧専用で、編集はできない。
|
||||
|
||||
## 7. 共有ノート
|
||||
|
||||
- 1 ページ=1 タブ。作成・改名・削除・本文編集(入力が止まると自動保存)ができる。
|
||||
- **セクション**でページをグループ化でき、同じセクションのタブは常に隣り合って表示される。
|
||||
- タブは**ドラッグ&ドロップ**で並べ替えでき、他セクションのタブにドロップすればそのまま移動になる。
|
||||
- AI 側はタイトル一覧だけを常に見ており、`read_note` で本文を読み、`write_note` で追記/全置換/新規作成する。
|
||||
AI が書き換えると SSE の `notes_updated` が飛び、開いているノートパネルが自動で最新化される。
|
||||
- 自分が編集中(未保存)のページは AI の更新で上書きされない。
|
||||
- **取り込み**: `.md` / `.txt` / `.zip` を本文エリアにドロップするか ⬆ ボタンで選ぶとページになる。
|
||||
ZIP はフォルダ名がセクションになるので、⬇ で出した ZIP をそのまま戻せば構成ごと復元される。
|
||||
タイトルが衝突したら `(2)` を足して別ページにするので、既存のページは壊れない。
|
||||
- **チャットへの紐付け**: タブの 📌 を押すと、そのページの本文が**今開いているチャット**のシステム
|
||||
プロンプトに毎回そのまま載る。AI が `read_note` を呼ぶのを待たずに確実に参照させたいときに使う。
|
||||
指定はチャット単位で保存されるので、会話ごとに違う組み合わせを選べる。
|
||||
- **ダウンロード**: パネル下部の ⬇ ボタンで、開いているページを `.md` として、または全ページを
|
||||
ZIP(1 ページ 1 Markdown、セクションはフォルダ、ファイル名の通し番号でタブ順を保持)として
|
||||
手元に保存できる。未保存の編集は書き出す前に保存される。
|
||||
|
||||
## 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` / `OPENAI_BASE_URL` / `OPENAI_API_BASE` /
|
||||
`ANTHROPIC_API_KEY` / AWS 系 / `OLLAMA_API_BASE` / `TAVILY_API_KEY` /
|
||||
`LOCAL_KB_EMBEDDING_MODEL` / `SEARCH_SUMMARY_MODEL`)
|
||||
- `OPENAI_BASE_URL` / `OPENAI_API_BASE` は OpenAI 互換エンドポイント(ローカルの llama-server や Ollama)
|
||||
に向けるためのもの。向けたままだと `gpt-*` のモデルを選んでも本家 OpenAI には繋がらず、
|
||||
litellm からは「Connection Error」に見える
|
||||
- API キー類は末尾 4 文字だけを見せるマスク表示。モデル名や URL(`settings_store.NON_SECRET_KEYS`)は
|
||||
現在値をそのまま表示し、入力欄も伏せ字にしない(今どれが効いているか分からないと設定しづらいため)
|
||||
- 空欄で保存すると「変更なし」扱い
|
||||
- 保存すると `app_settings` テーブルに入り `os.environ` に反映される。削除すると `.env` 由来の起動時の値に戻る
|
||||
- 保存した値は `backend_data` ボリュームに残るため、コンテナを作り直しても消えない。
|
||||
起動時に `load_into_env()` が `os.environ` へ流し込むので、`.env` の同名の値より優先される
|
||||
- **アプリ側は `config.py` の定数ではなく `os.environ` を都度読むこと**。定数は import 時に評価され、
|
||||
`load_into_env()` より前に確定してしまうため、GUI で保存した値が反映されない
|
||||
(`websearch._api_key()` / `knowledge._summary_model()` / `local_knowledge._embedding_model()` がその形)
|
||||
|
||||
## 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` にする。
|
||||
|
||||
## 17.5. 右サイドパネル(1 つのメニューに集約)
|
||||
|
||||
- ノート / ナレッジベース / 設定 / ログは、ヘッダー右端のメニュー 1 つから切り替える。
|
||||
- 開いているパネルのアイコンと名前がボタンに出る。同じ項目をもう一度選ぶか「パネルを閉じる」で閉じる。
|
||||
- メニューの外のクリックと `Esc` でメニューだけを閉じられる(パネルは開いたまま)。
|
||||
- パネルを増やすときは `frontend/src/panels.ts` に 1 行足すだけ。
|
||||
メニューの項目と、開いたときに描画するコンポーネントをこの配列が兼ねている。
|
||||
|
||||
## 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/` は存在しない。
|
||||
+64
-1
@@ -18,10 +18,73 @@ API 仕様は `docs/api.md` の「共有ノート」節を参照。
|
||||
| タイトルを変更 | タブを**ダブルクリック**すると入力欄になるので書き換えて Enter(または欄外をクリック) |
|
||||
| ページを削除 | タブ内の **×** をクリック(確認ダイアログあり) |
|
||||
| 本文を編集 | ページを開いた状態で下の編集エリアに直接入力 |
|
||||
| ファイルを取り込む | 本文エリアにファイルをドロップ、またはパネル下部の **⬆ 取り込み** から選択 |
|
||||
| チャットに紐付ける | タブ内の **📌** をクリック(もう一度押すと解除) |
|
||||
| ページを1つ保存 | パネル下部の **⬇ このページ (.md)** をクリック |
|
||||
| 全ページを保存 | パネル下部の **⬇ すべて (.zip)** をクリック |
|
||||
|
||||
本文は入力を止めてから少し待つと自動保存される。保存待ちの間はタブに **●**(未保存マーク)が付く。
|
||||
ページが1つも無い場合は「+ 最初のページを作成」から始められる。
|
||||
|
||||
## ファイルを取り込む
|
||||
|
||||
手元のファイルをページとして取り込める。本文エリアに**ドラッグ&ドロップ**するか、
|
||||
パネル下部の **⬆ 取り込み** からファイルを選ぶ(複数まとめて可)。
|
||||
|
||||
| ファイル | どうなるか |
|
||||
|---|---|
|
||||
| `.md` / `.txt` | 1 ファイルが 1 ページになる。ファイル名がタイトル |
|
||||
| `.zip` | 中のテキストファイルが 1 つずつページになる。フォルダ名がセクションになる |
|
||||
|
||||
**⬇ すべて (.zip) で出した ZIP をそのまま戻せる**ので、バックアップや別環境への引っ越しに使える。
|
||||
ファイル名の `01_` のような通し番号は取り込み時に落とされる。
|
||||
|
||||
既存のページと同じタイトルだった場合は上書きせず、`設計メモ (2)` のように連番を足して別ページを作る。
|
||||
要らない方はタブの × で消せばよい。
|
||||
|
||||
取り込めない拡張子(PDF や画像など)や大きすぎるファイル(1 ファイル 2MB 超)は飛ばされる。
|
||||
1 つも取り込めなかったときだけエラーが表示される。
|
||||
|
||||
## チャットに紐付けて必ず読ませる
|
||||
|
||||
普段、AIに渡っているのは**ページのタイトル一覧だけ**で、本文はAIが必要だと判断したときに
|
||||
`read_note` で読みに行く。つまり「読みに行ってくれないと参照されない」。
|
||||
|
||||
タブの **📌** を押すと、そのページの本文が**今開いているチャット**のプロンプトに毎回そのまま載る。
|
||||
AIの判断を待たずに確実に参照させたい資料(仕様書、設定、前提条件など)に使う。
|
||||
|
||||
📌 が付いたタブは上端に黄色い線が入る。もう一度押すと解除。
|
||||
|
||||
**チャットごとの設定**であることに注意。
|
||||
|
||||
| チャット | 📌 を付けたページ |
|
||||
|---|---|
|
||||
| 「新機能の相談」 | 設計メモ / APIメモ |
|
||||
| 「夕飯どうする」 | 買い物リスト |
|
||||
|
||||
チャットを切り替えると 📌 の状態もそのチャットのものに切り替わる。新規チャットでは
|
||||
📌 を付けてから最初のメッセージを送れば、その1通目からちゃんと反映される。
|
||||
|
||||
長いページは 1 ページ 8000 文字(`SESSION_NOTES_CONTENT_LIMIT`)で打ち切られ、
|
||||
残りが必要ならAIが `read_note` で読み足せる。あれもこれも 📌 すると毎回のリクエストが
|
||||
その分重くなるので、本当に読ませたいページだけに絞るのがよい。
|
||||
|
||||
## 手元にダウンロードする
|
||||
|
||||
パネル下部の ⬇ ボタンでノートをファイルとして保存できる。未保存の編集があっても、
|
||||
保存してから書き出すので取りこぼしはない。
|
||||
|
||||
| ボタン | 出てくるもの |
|
||||
|---|---|
|
||||
| **⬇ このページ (.md)** | 開いているページの本文そのままの Markdown ファイル。ファイル名はページのタイトル |
|
||||
| **⬇ すべて (.zip)** | 全ページをまとめた ZIP。1 ページ 1 Markdown で、セクションはフォルダになる |
|
||||
|
||||
ZIP の中のファイル名には `01_`, `02_` … と通し番号が付く。タブの並び順を残すためで、
|
||||
展開したときも画面と同じ順番で並ぶ。ファイル名に使えない文字(`/` や `:` など)は `_` に置き換わる。
|
||||
|
||||
複数ページを 1 つのファイルに連結せず ZIP にしているのは、ページ内の見出し(`#`)が
|
||||
ページ間の見出しと混ざって階層が壊れるのを避けるため。
|
||||
|
||||
## セクションでまとめる
|
||||
|
||||
複数のページを1つのグループとしてタブ上にまとめられる。
|
||||
@@ -51,7 +114,7 @@ API 仕様は `docs/api.md` の「共有ノート」節を参照。
|
||||
## AIとの連携
|
||||
|
||||
- 全ページの**タイトル一覧**は、会話のたびに自動でAIに渡される(セクションがあれば見出し付きで)。
|
||||
ただし本文はここには含まれない。
|
||||
ただし本文はここには含まれない(📌 を付けたページを除く)。
|
||||
- AIは `read_note` ツールでページ本文を読み、`write_note` ツールで追記・置き換え・新規ページ作成ができる。
|
||||
「ノートに書いておいて」「〇〇について記録して」のように頼むと、AIが該当ページを判断して書き込む。
|
||||
- AIがノートを書き換えると、開いているノートパネルは自動的に最新の内容へ更新される。
|
||||
|
||||
Generated
+22
-282
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.1.0",
|
||||
"katex": "^0.16.47",
|
||||
"marked": "^12.0.0",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.0",
|
||||
@@ -1117,6 +1118,15 @@
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -1130,18 +1140,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
|
||||
@@ -1254,278 +1252,20 @@
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.47",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
|
||||
"integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
"commander": "^8.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
"bin": {
|
||||
"katex": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.1.0",
|
||||
"katex": "^0.16.47",
|
||||
"marked": "^12.0.0",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.0",
|
||||
|
||||
+15
-31
@@ -1,27 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import SessionList from './components/SessionList.vue'
|
||||
import ChatWindow from './components/ChatWindow.vue'
|
||||
import MessageInput from './components/MessageInput.vue'
|
||||
import ModelSelector from './components/ModelSelector.vue'
|
||||
import LogViewer from './components/LogViewer.vue'
|
||||
import SettingsPanel from './components/SettingsPanel.vue'
|
||||
import NotePanel from './components/NotePanel.vue'
|
||||
import SidePanelMenu from './components/SidePanelMenu.vue'
|
||||
import ToastList from './components/ToastList.vue'
|
||||
import MemoryMonitor from './components/MemoryMonitor.vue'
|
||||
import Clock from './components/Clock.vue'
|
||||
import WordPool from './components/WordPool.vue'
|
||||
import LoginGate from './components/LoginGate.vue'
|
||||
import { SIDE_PANELS, type PanelId } from './panels'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
type SidePanel = 'none' | 'logs' | 'settings' | 'notes'
|
||||
const activePanel = ref<SidePanel>('none')
|
||||
|
||||
function togglePanel(panel: SidePanel) {
|
||||
activePanel.value = activePanel.value === panel ? 'none' : panel
|
||||
}
|
||||
/** 右サイドに開いているパネル。null はどれも開いていない状態。 */
|
||||
const activePanel = ref<PanelId | null>(null)
|
||||
const activePanelDef = computed(() => SIDE_PANELS.find((p) => p.id === activePanel.value) ?? null)
|
||||
|
||||
function handleUnauthorized() {
|
||||
authStore.markUnauthenticated()
|
||||
@@ -51,32 +47,20 @@ onUnmounted(() => {
|
||||
<span class="title">PlayChat</span>
|
||||
<ModelSelector />
|
||||
<Clock />
|
||||
<button class="log-toggle" :class="{ active: activePanel === 'notes' }" @click="togglePanel('notes')">
|
||||
ノート
|
||||
</button>
|
||||
<button class="log-toggle" :class="{ active: activePanel === 'settings' }" @click="togglePanel('settings')">
|
||||
設定
|
||||
</button>
|
||||
<button class="log-toggle" :class="{ active: activePanel === 'logs' }" @click="togglePanel('logs')">
|
||||
ログ
|
||||
</button>
|
||||
<button v-if="authStore.required" class="log-toggle" @click="authStore.logout()">
|
||||
ログアウト
|
||||
</button>
|
||||
<div class="header-actions">
|
||||
<SidePanelMenu :active="activePanel" @select="activePanel = $event" />
|
||||
<button v-if="authStore.required" class="log-toggle" @click="authStore.logout()">
|
||||
ログアウト
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="body">
|
||||
<div class="chat-pane">
|
||||
<ChatWindow />
|
||||
<MessageInput />
|
||||
</div>
|
||||
<div v-if="activePanel === 'logs'" class="log-pane">
|
||||
<LogViewer />
|
||||
</div>
|
||||
<div v-else-if="activePanel === 'settings'" class="log-pane">
|
||||
<SettingsPanel />
|
||||
</div>
|
||||
<div v-else-if="activePanel === 'notes'" class="log-pane">
|
||||
<NotePanel />
|
||||
<div v-if="activePanelDef" class="log-pane">
|
||||
<component :is="activePanelDef.component" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,11 +85,11 @@ header {
|
||||
border-bottom: 1px solid #333; background: #1a1a1a; flex-shrink: 0;
|
||||
}
|
||||
.title { font-size: 16px; font-weight: 600; color: #fff; }
|
||||
.header-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.log-toggle {
|
||||
background: #2a2a2a; color: #aaa; border: 1px solid #444;
|
||||
border-radius: 6px; padding: 4px 14px; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.log-toggle.active { background: #1d3557; color: #93c5fd; border-color: #2563eb; }
|
||||
.body { flex: 1; display: flex; overflow: hidden; }
|
||||
.chat-pane { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.log-pane { width: 400px; min-width: 300px; border-left: 1px solid #2a2a2a; overflow: hidden; }
|
||||
|
||||
+154
-4
@@ -21,9 +21,23 @@ export interface Source {
|
||||
url: string
|
||||
}
|
||||
|
||||
/** ツール実行1件の進行状況。開始時に status='running' で届き、完了時に同じ id で status='done' が届く。 */
|
||||
export interface ToolActivity {
|
||||
id: string
|
||||
/** バックエンドのツール名 (web_search / read_note / write_note / read_knowledge / save_knowledge)。 */
|
||||
name?: string
|
||||
/** 「Web検索」などの表示用ラベル。 */
|
||||
label?: string
|
||||
/** 検索クエリやページタイトルなど、何に対して実行したか。 */
|
||||
detail?: string
|
||||
status: 'running' | 'done' | 'error'
|
||||
/** 完了時の短い結果(「5件の結果」など)。 */
|
||||
result?: string
|
||||
}
|
||||
|
||||
export interface SSEChunk {
|
||||
type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources' | 'notes_updated'
|
||||
value?: string | Source[]
|
||||
type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources' | 'notes_updated' | 'tool'
|
||||
value?: string | Source[] | ToolActivity
|
||||
}
|
||||
|
||||
const BASE = ''
|
||||
@@ -51,6 +65,8 @@ export interface MessageRecord {
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
created_at: string
|
||||
// 要約に取り込まれたメッセージ。モデルには渡らないが履歴としては残っている
|
||||
archived_at: string | null
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
@@ -59,6 +75,24 @@ export async function fetchMessages(sessionId: string): Promise<MessageRecord[]>
|
||||
return parseJson<MessageRecord[]>(res, 'メッセージ取得')
|
||||
}
|
||||
|
||||
/** このセッションで本文をプロンプトに載せるよう指定されたノートのページID。 */
|
||||
export async function fetchSessionNotes(sessionId: string): Promise<number[]> {
|
||||
const res = await fetch(`${BASE}/chat/sessions/${sessionId}/notes`)
|
||||
const body = await parseJson<{ page_ids: number[] }>(res, '参照ノート取得')
|
||||
return body.page_ids
|
||||
}
|
||||
|
||||
/** セッションに紐付けるノートを丸ごと置き換える。戻り値は実際に紐付いたページID。 */
|
||||
export async function updateSessionNotes(sessionId: string, pageIds: number[]): Promise<number[]> {
|
||||
const res = await fetch(`${BASE}/chat/sessions/${sessionId}/notes`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ page_ids: pageIds }),
|
||||
})
|
||||
const body = await parseJson<{ page_ids: number[] }>(res, '参照ノート更新')
|
||||
return body.page_ids
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
await fetch(`${BASE}/chat/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
}
|
||||
@@ -83,6 +117,7 @@ export interface ImportPayload {
|
||||
role: string
|
||||
content: string
|
||||
created_at?: string | null
|
||||
archived_at?: string | null
|
||||
tags?: string[]
|
||||
}[]
|
||||
}
|
||||
@@ -141,7 +176,10 @@ export async function logout(): Promise<void> {
|
||||
export interface SettingEntry {
|
||||
key: string
|
||||
is_set: boolean
|
||||
masked: string | null
|
||||
/** 秘匿キーはマスク済みの値、それ以外は現在値そのもの。未設定なら null。 */
|
||||
display_value: string | null
|
||||
/** APIキーなど、入力欄をパスワード扱いにするキーか。 */
|
||||
secret: boolean
|
||||
overridden: boolean
|
||||
}
|
||||
|
||||
@@ -208,6 +246,8 @@ export function streamChat(
|
||||
message: string,
|
||||
sessionId: string | null,
|
||||
model: string,
|
||||
/** 本文をプロンプトに載せるノートのページID。新規セッションではここでしか指定できない。 */
|
||||
notePageIds: number[],
|
||||
onChunk: (chunk: SSEChunk) => void,
|
||||
onError: (err: Error) => void,
|
||||
): () => void {
|
||||
@@ -216,7 +256,7 @@ export function streamChat(
|
||||
fetch(`${BASE}/chat/stream`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, session_id: sessionId, model }),
|
||||
body: JSON.stringify({ message, session_id: sessionId, model, note_page_ids: notePageIds }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (res) => {
|
||||
@@ -297,6 +337,40 @@ export async function deleteNotePage(pageId: number): Promise<void> {
|
||||
await fetch(`${BASE}/api/notes/${pageId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/**
|
||||
* ファイルをページとして取り込む。.md / .txt は1ファイル1ページ、.zip は中身を展開して複数ページ。
|
||||
* 取り込めるファイルが1つも無ければサーバーが 400 を返す。
|
||||
*/
|
||||
export async function importNotePages(files: File[]): Promise<NotePage[]> {
|
||||
const form = new FormData()
|
||||
for (const file of files) form.append('files', file)
|
||||
const res = await fetch(`${BASE}/api/notes/import`, { method: 'POST', body: form })
|
||||
return parseJson<NotePage[]>(res, 'ノート取り込み')
|
||||
}
|
||||
|
||||
/**
|
||||
* ダウンロードのためだけにアンカーをクリックする。
|
||||
* ファイル名はサーバーの Content-Disposition に任せたいので download 属性は付けない
|
||||
* (ノートのタイトルには日本語やスラッシュが入りうるため、名前付けはサーバー側に一本化している)。
|
||||
*/
|
||||
function triggerDownload(url: string): void {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
}
|
||||
|
||||
/** 1ページを Markdown ファイルとしてダウンロードする。 */
|
||||
export function exportNotePage(pageId: number): void {
|
||||
triggerDownload(`${BASE}/api/notes/${pageId}/export`)
|
||||
}
|
||||
|
||||
/** 全ページを ZIP(1ページ1 Markdown、セクションはフォルダ)としてダウンロードする。 */
|
||||
export function exportAllNotePages(): void {
|
||||
triggerDownload(`${BASE}/api/notes/export`)
|
||||
}
|
||||
|
||||
/** section = old のページ全体を section = new へ一括で付け替える。new が空/nullならグループ解除。 */
|
||||
export async function renameNoteSection(oldName: string, newName: string | null): Promise<{ renamed: number }> {
|
||||
const res = await fetch(`${BASE}/api/notes/sections/rename`, {
|
||||
@@ -324,3 +398,79 @@ export async function reorderNotePages(
|
||||
})
|
||||
return parseJson<{ reordered: number }>(res, 'ノート並べ替え')
|
||||
}
|
||||
|
||||
/** ローカルナレッジベース(backend/knowledge_base/**.md)の1ファイル分のメタ情報。 */
|
||||
export interface KbFileInfo {
|
||||
path: string
|
||||
name: string
|
||||
/** ナレッジベース直下は空文字。UI ではこの単位でグループ表示する。 */
|
||||
directory: string
|
||||
title: string
|
||||
preview: string
|
||||
size: number
|
||||
updated_at: string
|
||||
/** 埋め込み済み(=話題に応じた自動注入の対象)か。 */
|
||||
indexed: boolean
|
||||
}
|
||||
|
||||
export interface KbFileContent {
|
||||
path: string
|
||||
name: string
|
||||
title: string
|
||||
content: string
|
||||
size: number
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export async function fetchKbFiles(): Promise<KbFileInfo[]> {
|
||||
const res = await fetch(`${BASE}/api/kb/files`)
|
||||
return parseJson<KbFileInfo[]>(res, 'ナレッジベース取得')
|
||||
}
|
||||
|
||||
export async function fetchKbFile(path: string): Promise<KbFileContent> {
|
||||
const res = await fetch(`${BASE}/api/kb/file?path=${encodeURIComponent(path)}`)
|
||||
return parseJson<KbFileContent>(res, 'ナレッジベースファイル取得')
|
||||
}
|
||||
|
||||
/** ローカルナレッジベースの埋め込み状況。GUI の状態表示・進捗バーに使う。 */
|
||||
export interface KbEmbeddingStatus {
|
||||
model: string
|
||||
/** 現在のモデルで埋め込まれたベクトルの次元数(1件も無ければ null)。 */
|
||||
dimensions: number | null
|
||||
file_count: number
|
||||
indexed: number
|
||||
/** 別のモデルで埋め込まれていて埋め直しが必要な件数。 */
|
||||
stale: number
|
||||
running: boolean
|
||||
total: number
|
||||
done: number
|
||||
failed: number
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
last_error: string | null
|
||||
}
|
||||
|
||||
/** 埋め込みモデルに実際に1回リクエストしてみた結果。 */
|
||||
export interface KbEmbeddingProbe {
|
||||
ok: boolean
|
||||
model: string
|
||||
dimensions: number | null
|
||||
latency_ms: number
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export async function fetchKbEmbeddingStatus(): Promise<KbEmbeddingStatus> {
|
||||
const res = await fetch(`${BASE}/api/kb/embedding`)
|
||||
return parseJson<KbEmbeddingStatus>(res, '埋め込み状態取得')
|
||||
}
|
||||
|
||||
export async function testKbEmbedding(): Promise<KbEmbeddingProbe> {
|
||||
const res = await fetch(`${BASE}/api/kb/embedding/test`, { method: 'POST' })
|
||||
return parseJson<KbEmbeddingProbe>(res, '埋め込みテスト')
|
||||
}
|
||||
|
||||
/** 全ファイルの再埋め込みをバックグラウンドで開始する。戻り値は開始直後の状態。 */
|
||||
export async function reindexKbEmbeddings(): Promise<KbEmbeddingStatus> {
|
||||
const res = await fetch(`${BASE}/api/kb/embedding/reindex`, { method: 'POST' })
|
||||
return parseJson<KbEmbeddingStatus>(res, '再埋め込み開始')
|
||||
}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { computed, ref, watch, nextTick } from 'vue'
|
||||
import { useChatStore } from '../stores/chat'
|
||||
import MarkdownContent from './MarkdownContent.vue'
|
||||
import ToolActivityList from './ToolActivityList.vue'
|
||||
import type { Source } from '../api/client'
|
||||
|
||||
const store = useChatStore()
|
||||
const scrollEl = ref<HTMLElement | null>(null)
|
||||
|
||||
// 要約済みメッセージの最後の位置。ここに「ここから先がモデルに渡る」区切りを描く。
|
||||
const lastArchivedIndex = computed(() => {
|
||||
for (let i = store.messages.length - 1; i >= 0; i--) {
|
||||
if (store.messages[i].archived) return i
|
||||
}
|
||||
return -1
|
||||
})
|
||||
|
||||
function uniqueSources(sources: Source[]): Source[] {
|
||||
const seen = new Set<string>()
|
||||
return sources.filter((s) => {
|
||||
@@ -30,11 +39,12 @@ watch(
|
||||
<div v-if="store.messages.length === 0" class="empty">
|
||||
メッセージを送信してチャットを開始してください
|
||||
</div>
|
||||
<template v-for="(msg, i) in store.messages" :key="i">
|
||||
<div
|
||||
v-for="(msg, i) in store.messages"
|
||||
:key="i"
|
||||
:class="['message-wrap', msg.role]"
|
||||
:class="['message-wrap', msg.role, { archived: msg.archived }]"
|
||||
:title="msg.archived ? '要約済み: 内容は長期メモリに引き継がれ、この発言自体はモデルに渡りません' : undefined"
|
||||
>
|
||||
<ToolActivityList v-if="msg.tools && msg.tools.length > 0" :tools="msg.tools" />
|
||||
<div :class="['message', msg.role, { error: msg.error }]">
|
||||
<div class="bubble">
|
||||
<!-- ユーザーメッセージはプレーンテキスト -->
|
||||
@@ -65,6 +75,10 @@ watch(
|
||||
>{{ src.title || src.url }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="i === lastArchivedIndex" class="archive-divider">
|
||||
<span>ここまでは要約済み — 履歴として残っていますが、モデルには要約だけが渡ります</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -85,6 +99,23 @@ watch(
|
||||
.message-wrap { display: flex; flex-direction: column; gap: 4px; }
|
||||
.message-wrap.user { align-items: flex-end; }
|
||||
.message-wrap.assistant { align-items: flex-start; }
|
||||
/* 要約済み(モデルには渡らない)メッセージ。読めるが現役の発言と区別できる程度に落とす */
|
||||
.message-wrap.archived { opacity: 0.45; }
|
||||
.message-wrap.archived:hover { opacity: 0.75; }
|
||||
.archive-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 4px 0;
|
||||
color: #777;
|
||||
font-size: 11px;
|
||||
}
|
||||
.archive-divider::before,
|
||||
.archive-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
border-top: 1px dashed #3a3a3a;
|
||||
}
|
||||
.message { display: flex; width: 100%; }
|
||||
.message.user { justify-content: flex-end; }
|
||||
.message.assistant { justify-content: flex-start; }
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import MarkdownContent from './MarkdownContent.vue'
|
||||
import { useKnowledgeBaseStore } from '../stores/knowledgeBase'
|
||||
|
||||
const store = useKnowledgeBaseStore()
|
||||
|
||||
onMounted(() => {
|
||||
store.load()
|
||||
store.loadEmbedding()
|
||||
})
|
||||
// パネルを閉じている間もポーリングが回り続けないように止める
|
||||
onUnmounted(() => store.stopPolling())
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
const e = store.embedding
|
||||
if (!e || e.total === 0) return 0
|
||||
return Math.round(((e.done + e.failed) / e.total) * 100)
|
||||
})
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
return bytes < 1024 ? `${bytes} B` : `${Math.round(bytes / 1024)} KB`
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('ja-JP', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kb-panel">
|
||||
<div class="toolbar">
|
||||
<button v-if="store.openFile" class="back-btn" title="一覧に戻る" @click="store.close()">←</button>
|
||||
<span class="title">{{ store.openFile ? store.openFile.title : 'ナレッジベース' }}</span>
|
||||
<button class="reload-btn" :disabled="store.loading" title="再読み込み" @click="store.load()">⟳</button>
|
||||
</div>
|
||||
|
||||
<div v-if="store.error" class="error">⚠ {{ store.error }}</div>
|
||||
|
||||
<!-- ファイル表示 -->
|
||||
<template v-if="store.openFile">
|
||||
<div class="file-meta">
|
||||
<code class="path">{{ store.openFile.path }}</code>
|
||||
<span>{{ formatSize(store.openFile.size) }} / {{ formatDate(store.openFile.updated_at) }}</span>
|
||||
</div>
|
||||
<div class="content">
|
||||
<MarkdownContent :content="store.openFile.content" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 一覧 -->
|
||||
<template v-else>
|
||||
<div v-if="store.embedding" class="embedding">
|
||||
<div class="embedding-head">
|
||||
<span class="embedding-model" :title="`埋め込みモデル: ${store.embedding.model}`">
|
||||
🧠 {{ store.embedding.model }}
|
||||
</span>
|
||||
<button class="mini-btn" :disabled="store.testing" title="このモデルに実際に1回リクエストして応答を確かめる"
|
||||
@click="store.testEmbedding()">
|
||||
{{ store.testing ? 'テスト中…' : '接続テスト' }}
|
||||
</button>
|
||||
<button class="mini-btn" :disabled="store.embedding.running" title="全ファイルを現在のモデルで埋め込み直す"
|
||||
@click="store.reindex()">
|
||||
埋め直し
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="embedding-stats">
|
||||
<span :class="{ warn: store.embedding.indexed < store.embedding.file_count }">
|
||||
埋め込み済み {{ store.embedding.indexed }} / {{ store.embedding.file_count }}
|
||||
</span>
|
||||
<span v-if="store.embedding.stale > 0" class="warn">
|
||||
・別モデルのまま {{ store.embedding.stale }}
|
||||
</span>
|
||||
<span v-if="store.embedding.dimensions">・{{ store.embedding.dimensions }} 次元</span>
|
||||
</div>
|
||||
|
||||
<template v-if="store.embedding.running">
|
||||
<div class="progress"><div class="progress-bar" :style="{ width: `${progressPercent}%` }" /></div>
|
||||
<div class="embedding-note">
|
||||
<template v-if="store.embedding.total === 0">準備中…</template>
|
||||
<template v-else>
|
||||
埋め込み中 {{ store.embedding.done + store.embedding.failed }} / {{ store.embedding.total }}
|
||||
<span v-if="store.embedding.failed > 0" class="ng">(失敗 {{ store.embedding.failed }})</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="store.probe" class="probe" :class="store.probe.ok ? 'ok' : 'ng'">
|
||||
<template v-if="store.probe.ok">
|
||||
✅ 応答あり — {{ store.probe.dimensions }} 次元 / {{ Math.round(store.probe.latency_ms) }} ms
|
||||
</template>
|
||||
<template v-else>❌ {{ store.probe.error }}</template>
|
||||
</div>
|
||||
|
||||
<div v-if="store.embedding.last_error" class="probe ng">
|
||||
直近の埋め込みエラー: {{ store.embedding.last_error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search">
|
||||
<input v-model="store.filter" type="search" placeholder="タイトル・パス・本文の先頭で絞り込み">
|
||||
<span class="count">{{ store.filteredFiles.length }} / {{ store.files.length }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="store.files.length === 0 && !store.loading" class="empty">
|
||||
ナレッジベースに .md ファイルがありません。<br>
|
||||
<code>backend/knowledge_base/</code> に置くと自動で読み込まれます。
|
||||
</div>
|
||||
|
||||
<div v-else class="list">
|
||||
<div v-for="group in store.groups" :key="group.directory" class="group">
|
||||
<button class="group-header" @click="store.toggleGroup(group.directory)">
|
||||
<span class="arrow">{{ store.collapsed.has(group.directory) ? '▸' : '▾' }}</span>
|
||||
<span class="dir">{{ group.directory || '(ルート)' }}</span>
|
||||
<span class="group-count">{{ group.files.length }}</span>
|
||||
</button>
|
||||
<template v-if="!store.collapsed.has(group.directory)">
|
||||
<button
|
||||
v-for="file in group.files"
|
||||
:key="file.path"
|
||||
class="file"
|
||||
@click="store.open(file.path)"
|
||||
>
|
||||
<div class="file-title">
|
||||
<span class="dot" :class="{ indexed: file.indexed }"
|
||||
:title="file.indexed ? '埋め込み済み(話題に応じて自動で参照される)' : '未埋め込み(次回のチャット時に取り込まれる)'" />
|
||||
<span class="name">{{ file.title }}</span>
|
||||
<span class="size">{{ formatSize(file.size) }}</span>
|
||||
</div>
|
||||
<div v-if="file.preview" class="file-preview">{{ file.preview }}</div>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hint">
|
||||
<code>backend/knowledge_base/</code> 配下の Markdown です。話題との類似度が高い上位数件が、
|
||||
システムプロンプトに<strong>全文</strong>そのまま注入されます。
|
||||
<code>web_search/</code> は AI の Web 検索結果が自動保存されたものです。
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kb-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #111;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 12px; border-bottom: 1px solid #2a2a2a; flex-shrink: 0;
|
||||
}
|
||||
.title {
|
||||
font-size: 13px; font-weight: 600; color: #aaa; flex: 1;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.back-btn, .reload-btn {
|
||||
background: none; border: 1px solid #444; color: #aaa;
|
||||
border-radius: 5px; padding: 2px 8px; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.back-btn:hover, .reload-btn:hover:not(:disabled) { color: #fff; }
|
||||
.error { padding: 8px 12px; color: #f87171; font-size: 12px; }
|
||||
|
||||
.embedding {
|
||||
padding: 8px 12px; border-bottom: 1px solid #1e1e1e; flex-shrink: 0;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.embedding-head { display: flex; align-items: center; gap: 6px; }
|
||||
.embedding-model {
|
||||
flex: 1; color: #cbd5e1; font-size: 12px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.mini-btn {
|
||||
background: #2a2a2a; color: #aaa; border: 1px solid #444;
|
||||
border-radius: 5px; padding: 2px 8px; font-size: 11px; cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.mini-btn:hover:not(:disabled) { color: #fff; }
|
||||
.mini-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.embedding-stats { color: #6b7280; font-size: 11px; }
|
||||
.embedding-stats .warn { color: #fbbf24; }
|
||||
.embedding-note { color: #6b7280; font-size: 11px; }
|
||||
.progress { height: 4px; background: #262626; border-radius: 2px; overflow: hidden; }
|
||||
.progress-bar { height: 100%; background: #2563eb; transition: width 0.3s; }
|
||||
.probe { font-size: 11px; line-height: 1.5; word-break: break-word; }
|
||||
.probe.ok { color: #4ade80; }
|
||||
.probe.ng, .ng { color: #f87171; }
|
||||
|
||||
.search {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 12px; border-bottom: 1px solid #1e1e1e; flex-shrink: 0;
|
||||
}
|
||||
.search input {
|
||||
flex: 1; background: #1a1a1a; color: #ddd; border: 1px solid #333;
|
||||
border-radius: 5px; padding: 4px 8px; font-size: 12px;
|
||||
}
|
||||
.search input:focus { outline: none; border-color: #2563eb; }
|
||||
.count { font-size: 11px; color: #555; white-space: nowrap; }
|
||||
|
||||
.list { flex: 1; overflow-y: auto; padding-bottom: 4px; }
|
||||
.group-header {
|
||||
display: flex; align-items: center; gap: 6px; width: 100%;
|
||||
background: #171717; border: none; border-bottom: 1px solid #1e1e1e;
|
||||
color: #888; font-size: 11px; padding: 5px 12px; cursor: pointer;
|
||||
position: sticky; top: 0;
|
||||
}
|
||||
.group-header:hover { color: #ccc; }
|
||||
.arrow { font-size: 9px; }
|
||||
.dir { flex: 1; text-align: left; font-family: 'Consolas', monospace; }
|
||||
.group-count { color: #555; }
|
||||
|
||||
.file {
|
||||
display: block; width: 100%; text-align: left;
|
||||
background: none; border: none; border-bottom: 1px solid #1a1a1a;
|
||||
padding: 8px 12px; cursor: pointer;
|
||||
}
|
||||
.file:hover { background: #1a1a1a; }
|
||||
.file-title { display: flex; align-items: center; gap: 7px; }
|
||||
.dot {
|
||||
width: 6px; height: 6px; border-radius: 50%; background: #444; flex-shrink: 0;
|
||||
}
|
||||
.dot.indexed { background: #22c55e; }
|
||||
.name {
|
||||
flex: 1; color: #ddd; font-size: 12px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.size { color: #555; font-size: 11px; white-space: nowrap; }
|
||||
.file-preview {
|
||||
margin-top: 3px; padding-left: 13px; color: #666; font-size: 11px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
display: flex; align-items: center; gap: 10px; justify-content: space-between;
|
||||
padding: 6px 12px; border-bottom: 1px solid #1e1e1e;
|
||||
color: #666; font-size: 11px; flex-shrink: 0;
|
||||
}
|
||||
.path {
|
||||
color: #7dd3fc; font-family: 'Consolas', monospace;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.content { flex: 1; overflow-y: auto; padding: 12px 14px; color: #ddd; font-size: 13px; }
|
||||
|
||||
.empty { padding: 20px; text-align: center; color: #555; font-size: 12px; line-height: 1.8; }
|
||||
.hint {
|
||||
padding: 8px 12px; border-top: 1px solid #1e1e1e;
|
||||
color: #666; font-size: 11px; line-height: 1.6; flex-shrink: 0;
|
||||
}
|
||||
.hint code { color: #9ca3af; font-family: 'Consolas', monospace; }
|
||||
</style>
|
||||
@@ -2,6 +2,8 @@
|
||||
import { computed } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import { mathExtension, mathSanitizeConfig } from '../utils/math'
|
||||
|
||||
const props = defineProps<{
|
||||
content: string
|
||||
@@ -9,6 +11,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
marked.setOptions({ breaks: true, gfm: true })
|
||||
marked.use(mathExtension)
|
||||
|
||||
function closeOpenFences(src: string): string {
|
||||
const fenceCount = (src.match(/^```/gm) ?? []).length
|
||||
@@ -18,7 +21,7 @@ function closeOpenFences(src: string): string {
|
||||
|
||||
const html = computed(() => {
|
||||
const raw = props.pending ? closeOpenFences(props.content) : props.content
|
||||
return DOMPurify.sanitize(marked.parse(raw) as string)
|
||||
return DOMPurify.sanitize(marked.parse(raw) as string, mathSanitizeConfig)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -104,6 +107,15 @@ const html = computed(() => {
|
||||
/* 水平線 */
|
||||
.md :deep(hr) { border-color: #333; margin: 10px 0; }
|
||||
|
||||
/* 数式 (KaTeX) */
|
||||
.md :deep(.katex) { font-size: 1.05em; }
|
||||
.md :deep(.katex-display) {
|
||||
margin: 10px 0;
|
||||
padding: 2px 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
/* リンク */
|
||||
.md :deep(a) { color: #60a5fa; text-decoration: none; }
|
||||
.md :deep(a:hover) { text-decoration: underline; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, nextTick, computed } from 'vue'
|
||||
import { useNotesStore } from '../stores/notes'
|
||||
import { useChatStore } from '../stores/chat'
|
||||
import type { NotePage } from '../api/client'
|
||||
|
||||
interface TabGroup {
|
||||
@@ -9,7 +10,11 @@ interface TabGroup {
|
||||
}
|
||||
|
||||
const store = useNotesStore()
|
||||
const chat = useChatStore()
|
||||
const renamingId = ref<number | null>(null)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
/** ファイルを本文エリアの上までドラッグしてきているか(取り込み用のハイライト表示) */
|
||||
const fileOver = ref(false)
|
||||
// ref="renameInput" は入れ子の v-for の中にあるため、Vue 3 では単一要素ではなく配列になる
|
||||
const renameInput = ref<HTMLInputElement[]>([])
|
||||
|
||||
@@ -34,6 +39,40 @@ const groups = computed<TabGroup[]>(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
/** ファイルをページとして取り込む。取り込めた件数はトーストで知らせる。 */
|
||||
async function importFiles(files: FileList | File[] | null) {
|
||||
const list = [...(files ?? [])]
|
||||
if (list.length === 0) return
|
||||
const created = await store.importFiles(list)
|
||||
if (created.length > 0) {
|
||||
chat.pushToast(`${created.length}ページを取り込みました: ${created.map((p) => p.title).join(', ')}`, 'success')
|
||||
}
|
||||
}
|
||||
|
||||
function onPickFiles(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
void importFiles(input.files)
|
||||
input.value = '' // 同じファイルを続けて選び直せるようにする
|
||||
}
|
||||
|
||||
/** タブのドラッグ&ドロップ(draggingId が立つ)とは別物。ファイルのドロップだけを拾う。 */
|
||||
function isFileDrag(e: DragEvent): boolean {
|
||||
return !!e.dataTransfer && [...e.dataTransfer.types].includes('Files')
|
||||
}
|
||||
|
||||
function onFileDragOver(e: DragEvent) {
|
||||
if (!isFileDrag(e)) return
|
||||
e.preventDefault()
|
||||
fileOver.value = true
|
||||
}
|
||||
|
||||
function onFileDrop(e: DragEvent) {
|
||||
if (!isFileDrag(e)) return
|
||||
e.preventDefault()
|
||||
fileOver.value = false
|
||||
void importFiles(e.dataTransfer!.files)
|
||||
}
|
||||
|
||||
async function startRename(id: number) {
|
||||
renamingId.value = id
|
||||
await nextTick()
|
||||
@@ -155,6 +194,7 @@ onUnmounted(() => {
|
||||
class="tab"
|
||||
:class="{
|
||||
active: page.id === store.activeId,
|
||||
attached: store.attachedIds.has(page.id),
|
||||
dragging: draggingId === page.id,
|
||||
'drop-before': dropTarget?.id === page.id && dropTarget.place === 'before',
|
||||
'drop-after': dropTarget?.id === page.id && dropTarget.place === 'after',
|
||||
@@ -181,6 +221,14 @@ onUnmounted(() => {
|
||||
<template v-else>
|
||||
<span class="tab-title" :title="page.title">{{ page.title }}</span>
|
||||
<span v-if="store.dirtyIds.has(page.id)" class="dot" title="未保存">●</span>
|
||||
<button
|
||||
class="tab-pin"
|
||||
:class="{ on: store.attachedIds.has(page.id) }"
|
||||
:title="store.attachedIds.has(page.id)
|
||||
? 'この会話のプロンプトに本文を載せている(クリックで解除)'
|
||||
: 'この会話のプロンプトに本文を載せる'"
|
||||
@click.stop="store.toggleAttached(page.id)"
|
||||
>📌</button>
|
||||
<button class="tab-tag" title="セクションに割り当て" @click.stop="assignSection(page)">🏷</button>
|
||||
<button class="tab-del" title="削除" @click.stop="confirmRemove(page.id, page.title)">×</button>
|
||||
</template>
|
||||
@@ -198,24 +246,68 @@ onUnmounted(() => {
|
||||
|
||||
<div v-if="store.error" class="error">⚠ {{ store.error }}</div>
|
||||
|
||||
<textarea
|
||||
v-if="store.activePage"
|
||||
class="editor"
|
||||
:value="store.activePage.content"
|
||||
placeholder="ここに書いた内容はAIと共有されます。AIも read_note / write_note でこのページを読み書きできます。"
|
||||
spellcheck="false"
|
||||
@input="onContentInput"
|
||||
@blur="store.flush()"
|
||||
/>
|
||||
<div v-else class="empty">
|
||||
<p>ページがありません。</p>
|
||||
<button class="create-btn" @click="store.addPage()">+ 最初のページを作成</button>
|
||||
<div
|
||||
class="body"
|
||||
:class="{ 'file-over': fileOver }"
|
||||
@dragover="onFileDragOver"
|
||||
@dragleave="fileOver = false"
|
||||
@drop="onFileDrop"
|
||||
>
|
||||
<textarea
|
||||
v-if="store.activePage"
|
||||
class="editor"
|
||||
:value="store.activePage.content"
|
||||
placeholder="ここに書いた内容はAIと共有されます。AIも read_note / write_note でこのページを読み書きできます。 .md / .txt / .zip をここにドロップすると新しいページとして取り込みます。"
|
||||
spellcheck="false"
|
||||
@input="onContentInput"
|
||||
@blur="store.flush()"
|
||||
/>
|
||||
<div v-else class="empty">
|
||||
<p>ページがありません。</p>
|
||||
<button class="create-btn" @click="store.addPage()">+ 最初のページを作成</button>
|
||||
<p class="empty-hint">.md / .txt / .zip をここにドロップしても取り込めます。</p>
|
||||
</div>
|
||||
<div v-if="fileOver" class="drop-overlay">ドロップしてページとして取り込む</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="file-input"
|
||||
multiple
|
||||
accept=".md,.markdown,.txt,.text,.zip"
|
||||
@change="onPickFiles"
|
||||
>
|
||||
<button
|
||||
class="action-btn"
|
||||
title=".md / .txt / .zip をページとして取り込む"
|
||||
@click="fileInput?.click()"
|
||||
>⬆ 取り込み</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
:disabled="store.activeId === null"
|
||||
title="開いているページを Markdown ファイルとして保存"
|
||||
@click="store.activeId !== null && store.downloadPage(store.activeId)"
|
||||
>⬇ このページ (.md)</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
:disabled="store.pages.length === 0"
|
||||
title="全ページを ZIP(1ページ1 Markdown、セクションはフォルダ)として保存"
|
||||
@click="store.downloadAll()"
|
||||
>⬇ すべて (.zip)</button>
|
||||
</div>
|
||||
|
||||
<div class="hint">
|
||||
ページのタイトル一覧はAIのシステムプロンプトに常に含まれます。本文はAIが必要に応じて読み出します。
|
||||
🏷 でページをセクションにまとめられます(セクション名をクリックすると変更・解除)。
|
||||
タブはドラッグで並べ替え・他のセクションへの移動ができます。
|
||||
⬇ でノートを手元にダウンロードできます(未保存の編集は保存してから書き出します)。
|
||||
⬆ または本文エリアへのドロップで .md / .txt / .zip を取り込めます(⬇ で出した ZIP を戻せばセクションごと復元されます)。
|
||||
<span class="hint-pin">
|
||||
📌 を付けたページは、<strong>今開いているチャット</strong>のプロンプトに本文がそのまま載ります
|
||||
(AIが read_note で読みに行くのを待たずに確実に参照されます)。指定はチャットごとに保存されます。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -279,6 +371,8 @@ onUnmounted(() => {
|
||||
max-width: 160px;
|
||||
}
|
||||
.tab.active { background: #2a2a2a; color: #fff; border-color: #444; }
|
||||
/* プロンプトに本文を載せているページ。タブ上端の線で「積んでいる」ことを示す */
|
||||
.tab.attached { box-shadow: inset 0 2px 0 #eab308; }
|
||||
.tab.dragging { opacity: 0.4; }
|
||||
.tab.drop-before { box-shadow: inset 2px 0 0 #93c5fd; }
|
||||
.tab.drop-after { box-shadow: inset -2px 0 0 #93c5fd; }
|
||||
@@ -292,7 +386,7 @@ onUnmounted(() => {
|
||||
.drop-end.active { background: #93c5fd; opacity: 0.5; }
|
||||
.tab-title { overflow: hidden; text-overflow: ellipsis; }
|
||||
.dot { color: #eab308; font-size: 8px; }
|
||||
.tab-tag, .tab-del, .tab-add {
|
||||
.tab-pin, .tab-tag, .tab-del, .tab-add {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
@@ -301,9 +395,13 @@ onUnmounted(() => {
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
.tab-tag { font-size: 11px; opacity: 0; transition: opacity 0.15s; }
|
||||
.tab-pin, .tab-tag { font-size: 11px; opacity: 0; transition: opacity 0.15s; }
|
||||
.tab:hover .tab-pin, .tab.active .tab-pin,
|
||||
.tab:hover .tab-tag, .tab.active .tab-tag { opacity: 0.8; }
|
||||
.tab-tag:hover { opacity: 1 !important; }
|
||||
.tab-pin:hover, .tab-tag:hover { opacity: 1 !important; }
|
||||
/* 載せているページのピンは、タブが非アクティブでも常に見えるようにする */
|
||||
.tab-pin.on { opacity: 1; filter: none; }
|
||||
.tab-pin:not(.on) { filter: grayscale(1); }
|
||||
.tab-del:hover, .tab-add:hover { color: #f87171; }
|
||||
.tab-add { color: #888; padding: 0 8px; align-self: center; }
|
||||
.tab-add:hover { color: #93c5fd; }
|
||||
@@ -323,6 +421,25 @@ onUnmounted(() => {
|
||||
background: #2a1414;
|
||||
border-bottom: 1px solid #7f1d1d;
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
.body.file-over { outline: 2px dashed #93c5fd; outline-offset: -6px; }
|
||||
.drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(22, 22, 22, 0.85);
|
||||
color: #93c5fd;
|
||||
font-size: 13px;
|
||||
pointer-events: none; /* dragleave が誤発火しないように、当たり判定を持たせない */
|
||||
}
|
||||
.file-input { display: none; }
|
||||
.editor {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
@@ -336,6 +453,7 @@ onUnmounted(() => {
|
||||
line-height: 1.6;
|
||||
}
|
||||
.editor::placeholder { color: #555; }
|
||||
.empty-hint { color: #555; font-size: 11px; }
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -355,6 +473,28 @@ onUnmounted(() => {
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-top: 1px solid #2a2a2a;
|
||||
}
|
||||
.action-btn {
|
||||
background: #1f1f1f;
|
||||
color: #aaa;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.action-btn:hover:not(:disabled) { background: #2a2a2a; color: #93c5fd; border-color: #444; }
|
||||
.action-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.hint-pin { display: block; margin-top: 4px; color: #8a7a3a; }
|
||||
.hint-pin strong { color: #eab308; font-weight: 600; }
|
||||
.hint {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 10px;
|
||||
|
||||
@@ -37,6 +37,8 @@ async function addModelPreset() {
|
||||
|
||||
const KEY_LABELS: Record<string, string> = {
|
||||
OPENAI_API_KEY: 'OpenAI APIキー',
|
||||
OPENAI_BASE_URL: 'OpenAI 互換エンドポイント (OPENAI_BASE_URL)',
|
||||
OPENAI_API_BASE: 'OpenAI 互換エンドポイント (OPENAI_API_BASE)',
|
||||
ANTHROPIC_API_KEY: 'Anthropic APIキー',
|
||||
AWS_ACCESS_KEY_ID: 'AWS Access Key ID',
|
||||
AWS_SECRET_ACCESS_KEY: 'AWS Secret Access Key',
|
||||
@@ -46,11 +48,9 @@ const KEY_LABELS: Record<string, string> = {
|
||||
OLLAMA_API_BASE: 'Ollama API Base URL',
|
||||
TAVILY_API_KEY: 'Tavily APIキー',
|
||||
LOCAL_KB_EMBEDDING_MODEL: '埋め込みモデル(ローカルナレッジベース検索用)',
|
||||
SEARCH_SUMMARY_MODEL: '要約モデル(Web検索結果のまとめ用)',
|
||||
}
|
||||
|
||||
// 秘匿情報ではないため、マスクせず通常のテキスト入力として表示するキー
|
||||
const PLAIN_TEXT_KEYS = new Set(['LOCAL_KB_EMBEDDING_MODEL', 'OLLAMA_API_BASE', 'AWS_REGION_NAME'])
|
||||
|
||||
async function submitPassword() {
|
||||
const ok = await store.unlock(passwordInput.value)
|
||||
if (ok) passwordInput.value = ''
|
||||
@@ -106,8 +106,8 @@ onMounted(() => {
|
||||
<div class="row">
|
||||
<input
|
||||
v-model="drafts[entry.key]"
|
||||
:type="PLAIN_TEXT_KEYS.has(entry.key) ? 'text' : 'password'"
|
||||
:placeholder="entry.is_set ? `設定済み ${entry.masked}` : '未設定'"
|
||||
:type="entry.secret ? 'password' : 'text'"
|
||||
:placeholder="entry.is_set ? `現在: ${entry.display_value}` : '未設定'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { SIDE_PANELS, type PanelId } from '../panels'
|
||||
|
||||
const props = defineProps<{ active: PanelId | null }>()
|
||||
const emit = defineEmits<{ (e: 'select', panel: PanelId | null): void }>()
|
||||
|
||||
const open = ref(false)
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
|
||||
const activePanel = computed(() => SIDE_PANELS.find((p) => p.id === props.active) ?? null)
|
||||
|
||||
/** 項目を選ぶ。開いているパネルをもう一度選んだら閉じる(従来のトグルと同じ操作感)。 */
|
||||
function select(id: PanelId) {
|
||||
emit('select', props.active === id ? null : id)
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('select', null)
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function onDocumentPointerDown(e: PointerEvent) {
|
||||
if (open.value && !root.value?.contains(e.target as Node)) open.value = false
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') open.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown)
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown)
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="panel-menu">
|
||||
<button
|
||||
class="menu-btn"
|
||||
:class="{ active: activePanel !== null, open }"
|
||||
:title="activePanel ? `${activePanel.label}を表示中` : 'パネルを開く'"
|
||||
@click="open = !open"
|
||||
>
|
||||
<span class="menu-icon">{{ activePanel ? activePanel.icon : '☰' }}</span>
|
||||
<span class="menu-label">{{ activePanel ? activePanel.label : 'パネル' }}</span>
|
||||
<span class="caret">▾</span>
|
||||
</button>
|
||||
|
||||
<div v-if="open" class="dropdown">
|
||||
<button
|
||||
v-for="panel in SIDE_PANELS"
|
||||
:key="panel.id"
|
||||
class="item"
|
||||
:class="{ current: panel.id === active }"
|
||||
@click="select(panel.id)"
|
||||
>
|
||||
<span class="item-icon">{{ panel.icon }}</span>
|
||||
<span class="item-text">
|
||||
<span class="item-label">{{ panel.label }}</span>
|
||||
<span class="item-desc">{{ panel.description }}</span>
|
||||
</span>
|
||||
<span class="check">{{ panel.id === active ? '✓' : '' }}</span>
|
||||
</button>
|
||||
<button v-if="activePanel" class="item close-item" @click="close">
|
||||
<span class="item-icon">✕</span>
|
||||
<span class="item-text"><span class="item-label">パネルを閉じる</span></span>
|
||||
<span class="check" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel-menu { position: relative; }
|
||||
.menu-btn {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
background: #2a2a2a; color: #aaa; border: 1px solid #444;
|
||||
border-radius: 6px; padding: 4px 12px; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.menu-btn:hover { color: #ddd; }
|
||||
.menu-btn.active { background: #1d3557; color: #93c5fd; border-color: #2563eb; }
|
||||
.menu-btn.open { border-color: #666; }
|
||||
.menu-btn.active.open { border-color: #3b82f6; }
|
||||
.menu-icon { font-size: 13px; line-height: 1; }
|
||||
.menu-label { white-space: nowrap; }
|
||||
.caret { font-size: 9px; color: #777; }
|
||||
|
||||
.dropdown {
|
||||
position: absolute; top: calc(100% + 6px); right: 0; z-index: 50;
|
||||
min-width: 260px;
|
||||
background: #1c1c1c; border: 1px solid #3a3a3a; border-radius: 8px;
|
||||
padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.item {
|
||||
display: flex; align-items: center; gap: 10px; width: 100%;
|
||||
background: none; border: none; border-radius: 6px;
|
||||
padding: 7px 9px; cursor: pointer; text-align: left;
|
||||
}
|
||||
.item:hover { background: #262626; }
|
||||
.item.current { background: #1d3557; }
|
||||
.item-icon { font-size: 14px; width: 18px; text-align: center; flex-shrink: 0; }
|
||||
.item-text { flex: 1; display: flex; flex-direction: column; gap: 1px; overflow: hidden; }
|
||||
.item-label { color: #ddd; font-size: 13px; }
|
||||
.item.current .item-label { color: #93c5fd; }
|
||||
.item-desc {
|
||||
color: #6b7280; font-size: 11px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.check { color: #93c5fd; font-size: 11px; width: 10px; flex-shrink: 0; }
|
||||
.close-item { border-top: 1px solid #2a2a2a; border-radius: 0 0 6px 6px; margin-top: 2px; }
|
||||
.close-item .item-label { color: #888; }
|
||||
</style>
|
||||
@@ -9,10 +9,10 @@ const store = useChatStore()
|
||||
<div
|
||||
v-for="toast in store.toasts"
|
||||
:key="toast.id"
|
||||
class="toast"
|
||||
:class="['toast', toast.kind]"
|
||||
@click="store.dismissToast(toast.id)"
|
||||
>
|
||||
<span class="icon">⚠</span>
|
||||
<span class="icon">{{ toast.kind === 'success' ? '✓' : '⚠' }}</span>
|
||||
<span class="msg">{{ toast.message }}</span>
|
||||
<button class="close">✕</button>
|
||||
</div>
|
||||
@@ -46,9 +46,10 @@ const store = useChatStore()
|
||||
animation: slide-in 0.2s ease;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
}
|
||||
.toast.success { background: #0a1f14; border-color: #166534; color: #6ee7b7; }
|
||||
.icon { flex-shrink: 0; }
|
||||
.msg { flex: 1; word-break: break-word; }
|
||||
.close { background: none; border: none; color: #f87171; cursor: pointer; font-size: 11px; padding: 0; }
|
||||
.close { background: none; border: none; color: inherit; cursor: pointer; font-size: 11px; padding: 0; }
|
||||
@keyframes slide-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
/** アシスタント応答の生成中に実行したツール(Web検索・ノート読み書きなど)を明示的に見せるリスト。 */
|
||||
import type { ToolActivity } from '../api/client'
|
||||
|
||||
defineProps<{ tools: ToolActivity[] }>()
|
||||
|
||||
const ICONS: Record<string, string> = {
|
||||
web_search: '🔍',
|
||||
read_note: '📖',
|
||||
write_note: '✍️',
|
||||
read_knowledge: '🧠',
|
||||
save_knowledge: '💾',
|
||||
}
|
||||
|
||||
function icon(tool: ToolActivity): string {
|
||||
return ICONS[tool.name ?? ''] ?? '🛠'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tool-activity">
|
||||
<div v-for="tool in tools" :key="tool.id" :class="['tool-row', tool.status]">
|
||||
<span class="icon">{{ icon(tool) }}</span>
|
||||
<span class="label">{{ tool.label || tool.name }}</span>
|
||||
<span v-if="tool.detail" class="detail" :title="tool.detail">{{ tool.detail }}</span>
|
||||
<span v-if="tool.status === 'running'" class="spinner" aria-label="実行中" />
|
||||
<span v-else class="result">{{ tool.status === 'error' ? '⚠' : '✓' }} {{ tool.result }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tool-activity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-width: 72%;
|
||||
padding: 6px 10px;
|
||||
border-left: 2px solid #3f6212;
|
||||
background: #1b2114;
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
.tool-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
.tool-row.running .label { color: #d9f99d; }
|
||||
.icon { flex: none; }
|
||||
.label { flex: none; color: #bef264; }
|
||||
.detail {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #d4d4d4;
|
||||
background: #262f1a;
|
||||
border-radius: 8px;
|
||||
padding: 1px 7px;
|
||||
}
|
||||
.result { flex: none; color: #86efac; }
|
||||
.tool-row.error .result { color: #f87171; }
|
||||
.spinner {
|
||||
flex: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 2px solid #3f6212;
|
||||
border-top-color: #bef264;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Component } from 'vue'
|
||||
import KnowledgeBasePanel from './components/KnowledgeBasePanel.vue'
|
||||
import LogViewer from './components/LogViewer.vue'
|
||||
import NotePanel from './components/NotePanel.vue'
|
||||
import SettingsPanel from './components/SettingsPanel.vue'
|
||||
|
||||
/** 右サイドに開けるパネル。'none' はどれも開いていない状態。 */
|
||||
export type PanelId = 'notes' | 'kb' | 'settings' | 'logs'
|
||||
|
||||
export interface SidePanelDef {
|
||||
id: PanelId
|
||||
label: string
|
||||
icon: string
|
||||
/** メニューに添える一行説明 */
|
||||
description: string
|
||||
component: Component
|
||||
}
|
||||
|
||||
/**
|
||||
* 右サイドパネルの一覧。ヘッダーのメニューの並びと、開いたときに描画されるコンポーネントを兼ねる。
|
||||
* パネルを増やすときはここに1行足すだけでよい。
|
||||
*/
|
||||
export const SIDE_PANELS: SidePanelDef[] = [
|
||||
{ id: 'notes', label: 'ノート', icon: '📝', description: 'AIと共有するノートの編集', component: NotePanel },
|
||||
{ id: 'kb', label: 'ナレッジベース', icon: '📚', description: 'knowledge_base/ の Markdown を閲覧', component: KnowledgeBasePanel },
|
||||
{ id: 'settings', label: '設定', icon: '⚙️', description: 'APIキー・モデルプリセット', component: SettingsPanel },
|
||||
{ id: 'logs', label: 'ログ', icon: '📜', description: 'LLM API 呼び出しの履歴', component: LogViewer },
|
||||
]
|
||||
@@ -1,20 +1,24 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { useNotesStore } from './notes'
|
||||
import { ref, computed } from 'vue'
|
||||
import { fetchSessions, fetchMessages, deleteSession, exportSession, importSession, streamChat, type SessionInfo, type Source, type ImportPayload } from '../api/client'
|
||||
import { fetchSessions, fetchMessages, deleteSession, exportSession, importSession, streamChat, type SessionInfo, type Source, type ToolActivity, type ImportPayload } from '../api/client'
|
||||
|
||||
export interface Message {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
pending?: boolean
|
||||
error?: boolean
|
||||
archived?: boolean
|
||||
tags?: string[]
|
||||
sources?: Source[]
|
||||
/** この応答を作る際に実行したツール(Web検索・ノート読み書きなど)。表示専用で永続化はしない。 */
|
||||
tools?: ToolActivity[]
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
message: string
|
||||
kind: 'error' | 'success'
|
||||
}
|
||||
|
||||
let toastSeq = 0
|
||||
@@ -31,9 +35,9 @@ export const useChatStore = defineStore('chat', () => {
|
||||
sessions.value.find((s) => s.id === currentSessionId.value) ?? null,
|
||||
)
|
||||
|
||||
function pushToast(message: string, durationMs = 5000) {
|
||||
function pushToast(message: string, kind: Toast['kind'] = 'error', durationMs = 5000) {
|
||||
const id = ++toastSeq
|
||||
toasts.value.push({ id, message })
|
||||
toasts.value.push({ id, message, kind })
|
||||
setTimeout(() => {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id)
|
||||
}, durationMs)
|
||||
@@ -50,12 +54,19 @@ export const useChatStore = defineStore('chat', () => {
|
||||
async function selectSession(sessionId: string | null) {
|
||||
currentSessionId.value = sessionId
|
||||
messages.value = []
|
||||
// プロンプトに載せるノートはセッションごとの設定なので、切り替えのたびに読み直す
|
||||
useNotesStore().loadAttached(sessionId).catch(() => {})
|
||||
if (!sessionId) return
|
||||
try {
|
||||
const records = await fetchMessages(sessionId)
|
||||
messages.value = records
|
||||
.filter((r) => r.role === 'user' || r.role === 'assistant')
|
||||
.map((r) => ({ role: r.role as 'user' | 'assistant', content: r.content, tags: r.tags }))
|
||||
.map((r) => ({
|
||||
role: r.role as 'user' | 'assistant',
|
||||
content: r.content,
|
||||
tags: r.tags,
|
||||
archived: r.archived_at !== null,
|
||||
}))
|
||||
} catch (e) {
|
||||
pushToast(`メッセージ取得失敗: ${e instanceof Error ? e.message : e}`)
|
||||
}
|
||||
@@ -90,12 +101,32 @@ export const useChatStore = defineStore('chat', () => {
|
||||
const session = await importSession(payload)
|
||||
await loadSessions()
|
||||
await selectSession(session.id)
|
||||
pushToast(`「${session.title}」をインポートしました`)
|
||||
pushToast(`「${session.title}」をインポートしました`, 'success')
|
||||
} catch (e) {
|
||||
pushToast(`インポート失敗: ${e instanceof Error ? e.message : e}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** tool イベントを同じ id の行にマージする(開始時に追加し、完了時に上書きする)。 */
|
||||
function mergeTool(msgIdx: number, activity: ToolActivity) {
|
||||
const msg = messages.value[msgIdx]
|
||||
if (!msg) return
|
||||
const tools = msg.tools ?? (msg.tools = [])
|
||||
const existing = tools.find((t) => t.id === activity.id)
|
||||
if (existing) Object.assign(existing, activity)
|
||||
else tools.push({ ...activity })
|
||||
}
|
||||
|
||||
/** ストリームが終わった時点で running のまま残っているツールを中断扱いにする。 */
|
||||
function settleTools(msgIdx: number) {
|
||||
for (const tool of messages.value[msgIdx]?.tools ?? []) {
|
||||
if (tool.status === 'running') {
|
||||
tool.status = 'error'
|
||||
tool.result = '中断されました'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sendMessage(text: string): void {
|
||||
if (isStreaming.value || !text.trim()) return
|
||||
|
||||
@@ -104,26 +135,35 @@ export const useChatStore = defineStore('chat', () => {
|
||||
const msgIdx = messages.value.length - 1 // reactive proxy 経由で更新するためインデックスを保持
|
||||
isStreaming.value = true
|
||||
|
||||
const notesStore = useNotesStore()
|
||||
streamChat(
|
||||
text,
|
||||
currentSessionId.value,
|
||||
selectedModel.value,
|
||||
[...notesStore.attachedIds],
|
||||
(chunk) => {
|
||||
if (chunk.type === 'session_id' && chunk.value) {
|
||||
currentSessionId.value = chunk.value as string
|
||||
// 新規セッションはこの時点で作られる。送信時に渡したノートの紐付けが
|
||||
// サーバー側に保存されているので、その保存先を控え直す
|
||||
notesStore.loadAttached(chunk.value as string).catch(() => {})
|
||||
loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`))
|
||||
} else if (chunk.type === 'chunk' && chunk.value) {
|
||||
messages.value[msgIdx].content += chunk.value as string
|
||||
} else if (chunk.type === 'sources' && chunk.value) {
|
||||
messages.value[msgIdx].sources = chunk.value as Source[]
|
||||
} else if (chunk.type === 'tool' && chunk.value) {
|
||||
mergeTool(msgIdx, chunk.value as ToolActivity)
|
||||
} else if (chunk.type === 'notes_updated') {
|
||||
// AIが共有ノートを書き換えたので、開いているノートパネルに反映する
|
||||
useNotesStore().load().catch((e) => pushToast(`ノート更新失敗: ${e.message}`))
|
||||
} else if (chunk.type === 'done') {
|
||||
settleTools(msgIdx)
|
||||
messages.value[msgIdx].pending = false
|
||||
isStreaming.value = false
|
||||
loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`))
|
||||
} else if (chunk.type === 'error') {
|
||||
settleTools(msgIdx)
|
||||
messages.value[msgIdx].content = (chunk.value as string) ?? 'バックエンドでエラーが発生しました。'
|
||||
messages.value[msgIdx].pending = false
|
||||
messages.value[msgIdx].error = true
|
||||
@@ -131,6 +171,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
settleTools(msgIdx)
|
||||
// 途中まで届いていた内容は残しつつ、エラーメッセージを付記する
|
||||
const partial = messages.value[msgIdx].content
|
||||
messages.value[msgIdx].content = partial ? `${partial}\n\n⚠️ ${err.message}` : err.message
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import {
|
||||
fetchKbEmbeddingStatus,
|
||||
fetchKbFile,
|
||||
fetchKbFiles,
|
||||
reindexKbEmbeddings,
|
||||
testKbEmbedding,
|
||||
type KbEmbeddingProbe,
|
||||
type KbEmbeddingStatus,
|
||||
type KbFileContent,
|
||||
type KbFileInfo,
|
||||
} from '../api/client'
|
||||
|
||||
export interface KbGroup {
|
||||
/** ナレッジベース直下は空文字。 */
|
||||
directory: string
|
||||
files: KbFileInfo[]
|
||||
}
|
||||
|
||||
export const useKnowledgeBaseStore = defineStore('knowledgeBase', () => {
|
||||
const files = ref<KbFileInfo[]>([])
|
||||
/** 開いているファイルの全文。一覧に戻ると null に戻す。 */
|
||||
const openFile = ref<KbFileContent | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
/** タイトル・パス・プレビューに対する絞り込み文字列。 */
|
||||
const filter = ref('')
|
||||
/** 折りたたまれているフォルダ名。web_search のようにファイル数が多いフォルダを畳むために使う。 */
|
||||
const collapsed = ref<Set<string>>(new Set())
|
||||
/** 埋め込みモデルの状態と進捗。 */
|
||||
const embedding = ref<KbEmbeddingStatus | null>(null)
|
||||
/** 「接続テスト」の結果。 */
|
||||
const probe = ref<KbEmbeddingProbe | null>(null)
|
||||
const testing = ref(false)
|
||||
|
||||
/** 埋め込み実行中だけ状態をポーリングするタイマー。 */
|
||||
let pollTimer: number | null = null
|
||||
const POLL_INTERVAL_MS = 1500
|
||||
|
||||
const filteredFiles = computed(() => {
|
||||
const q = filter.value.trim().toLowerCase()
|
||||
if (!q) return files.value
|
||||
return files.value.filter((f) =>
|
||||
`${f.title} ${f.path} ${f.preview}`.toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
/** ディレクトリごとにまとめる。一覧はサーバー側でパス順に並んでいるので、そのままの順序でグループ化できる。 */
|
||||
const groups = computed<KbGroup[]>(() => {
|
||||
const result: KbGroup[] = []
|
||||
for (const file of filteredFiles.value) {
|
||||
const last = result[result.length - 1]
|
||||
if (last && last.directory === file.directory) last.files.push(file)
|
||||
else result.push({ directory: file.directory, files: [file] })
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
files.value = await fetchKbFiles()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'ナレッジベースの取得に失敗しました'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function open(path: string): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
openFile.value = await fetchKbFile(path)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'ファイルの読み込みに失敗しました'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
openFile.value = null
|
||||
}
|
||||
|
||||
/** 埋め込みの状態を取り直す。実行中ならポーリングを始め、終わっていれば止める。 */
|
||||
async function loadEmbedding(): Promise<void> {
|
||||
try {
|
||||
embedding.value = await fetchKbEmbeddingStatus()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '埋め込み状態の取得に失敗しました'
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
if (embedding.value.running && pollTimer === null) {
|
||||
pollTimer = window.setInterval(() => void loadEmbedding(), POLL_INTERVAL_MS)
|
||||
} else if (!embedding.value.running && pollTimer !== null) {
|
||||
stopPolling()
|
||||
// 埋め込みが終わったので、一覧の「埋め込み済み」表示も更新する
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
function stopPolling(): void {
|
||||
if (pollTimer !== null) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 埋め込みモデルに実際にリクエストして、応答するか確かめる。 */
|
||||
async function testEmbedding(): Promise<void> {
|
||||
testing.value = true
|
||||
probe.value = null
|
||||
try {
|
||||
probe.value = await testKbEmbedding()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '埋め込みテストに失敗しました'
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 全ファイルの再埋め込みをバックグラウンドで開始する。 */
|
||||
async function reindex(): Promise<void> {
|
||||
error.value = null
|
||||
try {
|
||||
embedding.value = await reindexKbEmbeddings()
|
||||
if (embedding.value.running && pollTimer === null) {
|
||||
pollTimer = window.setInterval(() => void loadEmbedding(), POLL_INTERVAL_MS)
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '再埋め込みの開始に失敗しました'
|
||||
}
|
||||
}
|
||||
|
||||
function toggleGroup(directory: string): void {
|
||||
const next = new Set(collapsed.value)
|
||||
if (next.has(directory)) next.delete(directory)
|
||||
else next.add(directory)
|
||||
collapsed.value = next
|
||||
}
|
||||
|
||||
return {
|
||||
files,
|
||||
openFile,
|
||||
loading,
|
||||
error,
|
||||
filter,
|
||||
collapsed,
|
||||
embedding,
|
||||
probe,
|
||||
testing,
|
||||
filteredFiles,
|
||||
groups,
|
||||
load,
|
||||
open,
|
||||
close,
|
||||
toggleGroup,
|
||||
loadEmbedding,
|
||||
stopPolling,
|
||||
testEmbedding,
|
||||
reindex,
|
||||
}
|
||||
})
|
||||
@@ -5,8 +5,13 @@ import {
|
||||
createNotePage,
|
||||
updateNotePage,
|
||||
deleteNotePage,
|
||||
exportNotePage,
|
||||
exportAllNotePages,
|
||||
renameNoteSection,
|
||||
reorderNotePages,
|
||||
fetchSessionNotes,
|
||||
updateSessionNotes,
|
||||
importNotePages,
|
||||
type NotePage,
|
||||
} from '../api/client'
|
||||
|
||||
@@ -18,6 +23,13 @@ export const useNotesStore = defineStore('notes', () => {
|
||||
const error = ref<string | null>(null)
|
||||
/** 未保存の編集があるページID。AIによる更新で上書きしないために使う。 */
|
||||
const dirtyIds = ref<Set<number>>(new Set())
|
||||
/**
|
||||
* 本文をシステムプロンプトに載せるページID。チャットセッションごとの設定で、
|
||||
* セッションを切り替えるたびに loadAttached() で読み直す。
|
||||
*/
|
||||
const attachedIds = ref<Set<number>>(new Set())
|
||||
/** attachedIds がどのセッションのものか。null は「まだ保存先が無い新規チャット」。 */
|
||||
const attachedSessionId = ref<string | null>(null)
|
||||
|
||||
let saveTimer: number | null = null
|
||||
|
||||
@@ -97,11 +109,50 @@ export const useNotesStore = defineStore('notes', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** セッションに紐付いたページを読み込む。sessionId が null(新規チャット)なら選択を空にする。 */
|
||||
async function loadAttached(sessionId: string | null): Promise<void> {
|
||||
attachedSessionId.value = sessionId
|
||||
if (sessionId === null) {
|
||||
attachedIds.value = new Set()
|
||||
return
|
||||
}
|
||||
try {
|
||||
attachedIds.value = new Set(await fetchSessionNotes(sessionId))
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '参照ノートの取得に失敗しました'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ページをプロンプトに載せる/載せないを切り替える。
|
||||
* まだセッションが無い新規チャットではローカルに保持するだけで、最初の送信時にまとめて保存される。
|
||||
*/
|
||||
async function toggleAttached(id: number): Promise<void> {
|
||||
const next = new Set(attachedIds.value)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
attachedIds.value = next
|
||||
|
||||
const sessionId = attachedSessionId.value
|
||||
if (sessionId === null) return
|
||||
try {
|
||||
attachedIds.value = new Set(await updateSessionNotes(sessionId, [...next]))
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '参照ノートの更新に失敗しました'
|
||||
}
|
||||
}
|
||||
|
||||
async function removePage(id: number): Promise<void> {
|
||||
error.value = null
|
||||
try {
|
||||
await deleteNotePage(id)
|
||||
dirtyIds.value.delete(id)
|
||||
// 紐付けはサーバー側でもカスケード削除される
|
||||
if (attachedIds.value.has(id)) {
|
||||
const next = new Set(attachedIds.value)
|
||||
next.delete(id)
|
||||
attachedIds.value = next
|
||||
}
|
||||
pages.value = pages.value.filter((p) => p.id !== id)
|
||||
if (activeId.value === id) activeId.value = pages.value[0]?.id ?? null
|
||||
} catch (e) {
|
||||
@@ -140,13 +191,46 @@ export const useNotesStore = defineStore('notes', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 保留中の保存を直ちに実行する(タブ切替・パネルを閉じる時など)。 */
|
||||
function flush(): void {
|
||||
/**
|
||||
* 保留中の保存を直ちに実行する(タブ切替・パネルを閉じる時など)。
|
||||
* await すれば保存の完了まで待てる(save() は自分でエラーを握るので reject はしない)。
|
||||
*/
|
||||
function flush(): Promise<void> {
|
||||
if (saveTimer !== null) {
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = null
|
||||
}
|
||||
for (const id of [...dirtyIds.value]) void save(id)
|
||||
return Promise.all([...dirtyIds.value].map((id) => save(id))).then(() => undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* ファイルをページとして取り込み、最初に作られたページを開く。
|
||||
* 戻り値は作成されたページ(失敗時は空配列)。
|
||||
*/
|
||||
async function importFiles(files: File[]): Promise<NotePage[]> {
|
||||
error.value = null
|
||||
await flush()
|
||||
try {
|
||||
const created = await importNotePages(files)
|
||||
await load()
|
||||
if (created.length > 0) activeId.value = created[0].id
|
||||
return created
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'ファイルの取り込みに失敗しました'
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** 編集中の内容を保存してから、そのページを Markdown としてダウンロードする。 */
|
||||
async function downloadPage(id: number): Promise<void> {
|
||||
await flush()
|
||||
exportNotePage(id)
|
||||
}
|
||||
|
||||
/** 編集中の内容を保存してから、全ページを ZIP としてダウンロードする。 */
|
||||
async function downloadAll(): Promise<void> {
|
||||
await flush()
|
||||
exportAllNotePages()
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -155,6 +239,10 @@ export const useNotesStore = defineStore('notes', () => {
|
||||
activePage,
|
||||
error,
|
||||
dirtyIds,
|
||||
attachedIds,
|
||||
attachedSessionId,
|
||||
loadAttached,
|
||||
toggleAttached,
|
||||
load,
|
||||
selectPage,
|
||||
addPage,
|
||||
@@ -164,5 +252,8 @@ export const useNotesStore = defineStore('notes', () => {
|
||||
renameSection,
|
||||
reorder,
|
||||
flush,
|
||||
importFiles,
|
||||
downloadPage,
|
||||
downloadAll,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import katex from 'katex'
|
||||
import type { MarkedExtension, TokenizerAndRendererExtension, Tokens } from 'marked'
|
||||
|
||||
/**
|
||||
* marked 拡張として数式 (KaTeX) を描画する。
|
||||
*
|
||||
* 対応する区切り記号:
|
||||
* - ブロック: `$$ ... $$` / `\[ ... \]`(単独行)
|
||||
* - インライン: `$ ... $` / `\( ... \)` / 行中の `$$ ... $$`
|
||||
*
|
||||
* `$` はドル記号としても書かれるため、インラインの `$ ... $` だけは
|
||||
* 誤爆を避けるための条件を付けている(下記 INLINE_DOLLAR 参照)。
|
||||
* 明示的にドル記号を書きたい場合は Markdown のエスケープ `\$` が使える。
|
||||
*/
|
||||
|
||||
// ブロック: 行頭(インデント 3 まで)から始まる $$ ... $$ / \[ ... \]
|
||||
const BLOCK_DOLLAR = /^ {0,3}\$\$([\s\S]+?)\$\$[ \t]*(?:\n+|$)/
|
||||
const BLOCK_BRACKET = /^ {0,3}\\\[([\s\S]+?)\\\][ \t]*(?:\n+|$)/
|
||||
|
||||
// インライン: \( ... \) と、段落の途中に置かれた $$ ... $$
|
||||
const INLINE_PAREN = /^\\\(((?:\\[^]|[^\\])+?)\\\)/
|
||||
const INLINE_DOUBLE = /^\$\$((?:\\[^]|[^\\])+?)\$\$/
|
||||
// インライン: $ ... $
|
||||
// - 開き `$` の直後に空白・`$` が来るものは対象外
|
||||
// - 中身に改行と裸の `$` を含まない
|
||||
// - 閉じ `$` の直後が数字のものは対象外("$100から$200" のような金額表記の除外)
|
||||
// - 中身が空白で終わるものは対象外(tokenizer 側で判定)
|
||||
const INLINE_DOLLAR = /^\$(?![\s$])((?:\\.|[^\\$\n])+?)\$(?!\d)/
|
||||
|
||||
function escapeHtml(src: string): string {
|
||||
return src
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function renderMath(tex: string, displayMode: boolean, raw: string): string {
|
||||
try {
|
||||
return katex.renderToString(tex.trim(), {
|
||||
displayMode,
|
||||
throwOnError: false, // 構文エラーは例外にせず赤字で表示する
|
||||
errorColor: '#f87171', // ダーク背景で読める色にする
|
||||
strict: false, // 日本語混じりなどの警告を出さない
|
||||
output: 'htmlAndMathml',
|
||||
})
|
||||
} catch {
|
||||
// KaTeX が想定外の失敗をした場合は元の文字列をそのまま見せる
|
||||
return escapeHtml(raw)
|
||||
}
|
||||
}
|
||||
|
||||
const blockMath: TokenizerAndRendererExtension = {
|
||||
name: 'blockMath',
|
||||
level: 'block',
|
||||
start(src: string) {
|
||||
return src.match(/(?:^|\n) {0,3}(?:\$\$|\\\[)/)?.index
|
||||
},
|
||||
tokenizer(src: string) {
|
||||
const match = BLOCK_DOLLAR.exec(src) ?? BLOCK_BRACKET.exec(src)
|
||||
if (!match) return undefined
|
||||
return { type: 'blockMath', raw: match[0], text: match[1] }
|
||||
},
|
||||
renderer(token: Tokens.Generic) {
|
||||
return renderMath(token.text as string, true, token.raw as string)
|
||||
},
|
||||
}
|
||||
|
||||
const inlineMath: TokenizerAndRendererExtension = {
|
||||
name: 'inlineMath',
|
||||
level: 'inline',
|
||||
start(src: string) {
|
||||
// エスケープされた \$ は数式の開始とみなさない
|
||||
const match = /(^|[^\\])(\$|\\\()/.exec(src)
|
||||
return match ? match.index + match[1].length : undefined
|
||||
},
|
||||
tokenizer(src: string) {
|
||||
const paren = INLINE_PAREN.exec(src)
|
||||
if (paren) return { type: 'inlineMath', raw: paren[0], text: paren[1], display: false }
|
||||
|
||||
const double = INLINE_DOUBLE.exec(src)
|
||||
if (double) return { type: 'inlineMath', raw: double[0], text: double[1], display: true }
|
||||
|
||||
const single = INLINE_DOLLAR.exec(src)
|
||||
if (single && !/[\s\\]$/.test(single[1])) {
|
||||
return { type: 'inlineMath', raw: single[0], text: single[1], display: false }
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
renderer(token: Tokens.Generic) {
|
||||
return renderMath(token.text as string, token.display === true, token.raw as string)
|
||||
},
|
||||
}
|
||||
|
||||
export const mathExtension: MarkedExtension = {
|
||||
extensions: [blockMath, inlineMath],
|
||||
}
|
||||
|
||||
/** KaTeX が出力する MathML 用に DOMPurify へ追加で許可するタグ・属性 */
|
||||
export const mathSanitizeConfig = {
|
||||
ADD_TAGS: ['semantics', 'annotation'],
|
||||
ADD_ATTR: ['encoding'],
|
||||
}
|
||||
Reference in New Issue
Block a user