Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24268cb453 | ||
|
|
858dbf4004 | ||
|
|
70735567f2 | ||
|
|
1cd18c4bc5 | ||
|
|
c6108a1b29 | ||
|
|
0ee9fac3f7 | ||
|
|
ae997914c2 | ||
|
|
caeeb5e921 | ||
|
|
c1aba42cf8 | ||
|
|
3b30dab650 | ||
|
|
1b88d23f21 | ||
|
|
904458e7df | ||
|
|
97568ba90e | ||
|
|
52dfc0c349 | ||
|
|
cd38cbf19d | ||
|
|
2ced2e3cd5 | ||
|
|
f085db421c | ||
|
|
5d63a59b86 | ||
|
|
8f9458170e | ||
|
|
4882d291be | ||
|
|
6f9fc10fa1 |
@@ -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 検索
|
||||
@@ -77,7 +77,7 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
|
||||
1. **蓄積知識** (`memory_kb/knowledge.py`): 過去の Web 検索で得た `knowledge_entries` の**見出し一覧のみ**(`- 「クエリ」: 一行要約`)。本文と出典は含めず、必要なら `read_knowledge` ツールで読み出す。最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件。セッションを横断する
|
||||
2. **共有ノート** (`core/notes.py`): タイトル一覧+本文先頭 40 文字のプレビューのみ
|
||||
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 検索結果より優先する扱い
|
||||
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` 全件(要約で削除された分を除く)
|
||||
|
||||
@@ -103,6 +103,15 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
|
||||
- **ダウンロード**: `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`): 本文のキーワードで **雑談 → 天気** の順に分岐し、どちらにも当てはまらなければ通常応答。リマインドはキーワードで振り分けず、雑談以外の全メンションで `create_reminder` / `list_reminders` / `cancel_reminder` の3ツールを常に同席させ、使うかどうかは LLM 自身に判断させる(「モーニングコール」のようにキーワード一覧に無い言い回しも拾うため)。そのため通常応答に使う `WEATHER_NOTIFY_MODEL`(未設定なら `DEFAULT_MODEL`)は**tool calling 対応が必須**。非対応モデルが設定されていた場合はツールなしの通常応答にフォールバックする
|
||||
- **スレッド化** (`discord_bot._ensure_thread()`): 全メンションへの返信をスレッドに集約する。元メッセージからスレッドを新規作成し(既にスレッド内でのメンションなら流用)、以後の返信・タイピング表示・そのメンションから登録したリマインドの⏰通知はすべてそのスレッドに届く。DM や権限不足でスレッドを作れない場合は元のチャンネルへフォールバックする
|
||||
- **定期ジョブ** (`discord/scheduler.py`): 天気通知(Cron)・どうでもいいTips(Interval)・リマインド配信(Interval)の3つ
|
||||
- **リマインド** (`discord/reminders.py`): 自然言語登録する**一回きり**の通知(繰り返し非対応)。`resolve_reminder_tools()` が system メッセージに入れた現在時刻を基準に相対表現を絶対日時へ直させる。配信は Webhook ではなく Bot クライアントから `channel_id`(登録時のスレッドまたはチャンネル)へ依頼者をメンションして送るので、専用の Webhook URL 設定は不要。DB (`reminders`) は UTC で持ち、ユーザー・LLM とのやり取りはローカル時刻に直す。一覧・取り消しは依頼者の user_id で絞るため他人のリマインドには触れられない
|
||||
|
||||
### SSE ストリーミング
|
||||
|
||||
`POST /chat/stream` が SSE を返す。イベント種別: `session_id`(新規セッション ID)/ `chunk`(テキスト断片)/ `tool`(ツール実行の開始・完了)/ `sources`(Web 検索の出典)/ `notes_updated`(AI が共有ノートを更新)/ `done`(完了)/ `error`(エラー)。
|
||||
@@ -111,6 +120,7 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
|
||||
### Markdown レンダリング
|
||||
|
||||
アシスタントメッセージは `marked` + `DOMPurify` でレンダリング。ストリーミング中の未閉じコードフェンスは自動補完してから描画する(`MarkdownContent.vue`)。
|
||||
数式は `src/utils/math.ts` の marked 拡張が KaTeX で描画する(ブロック `$$...$$` / `\[...\]`、インライン `$...$` / `\(...\)`)。詳細は `docs/architecture.md` を参照。
|
||||
|
||||
### モデル切替
|
||||
|
||||
@@ -135,16 +145,18 @@ 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 回に返す本文の最大文字数 |
|
||||
@@ -152,5 +164,16 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
|
||||
| `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` が一度に返す件数 |
|
||||
| `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,18 @@ USELESS_TIPS_MODEL=
|
||||
# 未設定ならDISCORD_WEBHOOK_URL(天気通知と同じ)を使う
|
||||
DISCORD_WEBHOOK_URL_TIPS=
|
||||
|
||||
# Discord Bot のリマインド機能(メンションで登録し、同じチャンネルへ通知する)
|
||||
# 全メンションでツールを常に同席させ、使うかどうかはLLMが判断する(キーワード判定なし)ため、
|
||||
# WEATHER_NOTIFY_MODEL(未設定ならDEFAULT_MODEL)は tool calling 対応モデルである必要がある
|
||||
# 期限切れのリマインドを探しにいく間隔(秒)
|
||||
REMINDER_CHECK_INTERVAL_SECONDS=30
|
||||
# これより先の日時は登録を拒否する(日数)
|
||||
REMINDER_MAX_AHEAD_DAYS=365
|
||||
REMINDER_LIST_LIMIT=20
|
||||
|
||||
# アプリ設定
|
||||
DATABASE_URL=sqlite:///./playchat.db
|
||||
MEMORY_SUMMARIZE_THRESHOLD=20
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
# アプリ側ログの出力レベル(DEBUG / INFO / WARNING / ERROR)
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
@@ -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,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ナレッジベース(話題に応じて関連ファイルをシステムプロンプトに注入)
|
||||
@@ -86,3 +91,11 @@ 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"))
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.core.config import (
|
||||
WEATHER_NOTIFY_MODEL,
|
||||
)
|
||||
from app.core.llm import complete
|
||||
from app.core.discord.reminders import ReminderContext, build_tool_hint, resolve_reminder_tools
|
||||
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
|
||||
@@ -27,6 +28,48 @@ client = discord.Client(intents=intents)
|
||||
WEATHER_KEYWORDS = ("天気", "天候", "洗濯", "雨", "気温", "降水")
|
||||
USELESS_TALK_KEYWORDS = ("どうでもいい", "雑談", "暇", "つまんない", "退屈")
|
||||
|
||||
# スレッドの自動アーカイブまでの時間(分)。サーバーのブースト状況に関係なく常に選べる値。
|
||||
_THREAD_AUTO_ARCHIVE_MINUTES = 1440
|
||||
|
||||
|
||||
def _thread_name(content: str) -> str:
|
||||
name = content.replace("\n", " ").strip() or "会話"
|
||||
return name[:90] + "…" if len(name) > 90 else name
|
||||
|
||||
|
||||
async def _ensure_thread(message: discord.Message, content: str) -> "discord.abc.MessageableChannel":
|
||||
"""返信先を決める。
|
||||
|
||||
既にスレッド内でのメンションならそのスレッドをそのまま使う。DMにはスレッドが
|
||||
存在しないのでチャンネルのまま。それ以外は元メッセージからスレッドを新規作成し、
|
||||
以後の会話をそこに集約する。作成に失敗した場合(権限不足など)は例外を握りつぶし、
|
||||
元のチャンネルへフォールバックする(Botの応答自体は止めない)。
|
||||
"""
|
||||
channel = message.channel
|
||||
if isinstance(channel, discord.Thread) or message.guild is None:
|
||||
return channel
|
||||
try:
|
||||
return await message.create_thread(
|
||||
name=_thread_name(content), auto_archive_duration=_THREAD_AUTO_ARCHIVE_MINUTES,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to create thread for message %s; replying in channel instead", message.id, exc_info=True,
|
||||
)
|
||||
return channel
|
||||
|
||||
|
||||
async def _reply(message: discord.Message, reply_channel: "discord.abc.MessageableChannel", text: str) -> None:
|
||||
"""reply_channel が元のチャンネルのままなら通常どおり message.reply()(引用UI付き)で返す。
|
||||
|
||||
スレッドを新規作成した場合、message.reply() は元メッセージのチャンネル(スレッドの親)へ
|
||||
送ってしまいスレッドには入らないため、reply_channel へ直接 send() する。
|
||||
"""
|
||||
if reply_channel is message.channel:
|
||||
await message.reply(text)
|
||||
else:
|
||||
await reply_channel.send(text)
|
||||
|
||||
|
||||
async def _build_weather_context() -> str:
|
||||
"""明日の天気データを取得し、LLMに渡すコンテキスト文字列を作る。失敗時は取得不可の旨を返す。"""
|
||||
@@ -65,11 +108,18 @@ async def on_message(message: discord.Message):
|
||||
|
||||
record_message(content)
|
||||
|
||||
async with message.channel.typing():
|
||||
try:
|
||||
# 以後の返信・タイピング表示・リマインドの通知先はすべてここで決めたチャンネル
|
||||
# (新規作成したスレッド、または元のチャンネル)に統一する。
|
||||
reply_channel = await _ensure_thread(message, content)
|
||||
|
||||
try:
|
||||
# typing() の開始(=タイピング表示のリクエスト送信)自体が権限不足で失敗しうる
|
||||
# (スレッド作成には成功しても "Send Messages in Threads" が無いケースなど)ため、
|
||||
# try の外に置くと on_message ごと落ちて何の返信もされない。try の内側に入れる。
|
||||
async with reply_channel.typing():
|
||||
if any(keyword in content for keyword in USELESS_TALK_KEYWORDS):
|
||||
redirect = await _generate_useless_talk_redirect(content)
|
||||
await message.reply(redirect)
|
||||
await _reply(message, reply_channel, redirect)
|
||||
|
||||
# 振られた話題のタグを生成し、それに近しい話題からTipsのネタを作る
|
||||
seed_tags = await generate_tags(content)
|
||||
@@ -83,15 +133,26 @@ async def on_message(message: discord.Message):
|
||||
weather_context = await _build_weather_context()
|
||||
messages.append({"role": "system", "content": weather_context})
|
||||
|
||||
# リマインドはキーワードでは振り分けない。「モーニングコール」のように
|
||||
# 決め打ちの言い回しから外れる依頼も拾えるよう、ツールを常に同席させて
|
||||
# 使うかどうかはLLM自身に判断させる。
|
||||
messages.append({"role": "system", "content": build_tool_hint()})
|
||||
messages.append({"role": "user", "content": content})
|
||||
|
||||
reply = await complete(messages, model=WEATHER_NOTIFY_MODEL)
|
||||
# channel_id にスレッドのIDを渡すことで、⏰通知もこの会話に届く。
|
||||
# スレッドが自動アーカイブされていても、送信時にDiscordが自動で展開する。
|
||||
ctx = ReminderContext(
|
||||
channel_id=str(reply_channel.id),
|
||||
user_id=str(message.author.id),
|
||||
user_name=message.author.display_name,
|
||||
)
|
||||
reply = await resolve_reminder_tools(messages, ctx, model=WEATHER_NOTIFY_MODEL)
|
||||
if reply:
|
||||
record_message(reply)
|
||||
await message.reply(reply or "(応答が空でした)")
|
||||
except Exception:
|
||||
logger.exception("Failed to generate reply for Discord mention")
|
||||
await message.reply("⚠️ 応答の生成に失敗しました")
|
||||
await _reply(message, reply_channel, reply or "(応答が空でした)")
|
||||
except Exception:
|
||||
logger.exception("Failed to generate reply for Discord mention")
|
||||
await _reply(message, reply_channel, "⚠️ 応答の生成に失敗しました")
|
||||
|
||||
|
||||
async def _generate_useless_talk_redirect(content: str) -> str:
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Discord Bot のリマインド機能。
|
||||
|
||||
キーワードでは振り分けない。discord_bot.on_message は全メンションで
|
||||
resolve_reminder_tools() を通し、create_reminder / list_reminders / cancel_reminder の
|
||||
3ツールを常に同席させる。頼まれたときだけLLM自身がツールを選んで使うため、
|
||||
「モーニングコール」のようにキーワード一覧に無い言い回しでも拾える
|
||||
(キーワード方式で「アラーム」等に決め打ちしていたときに取りこぼしがあった)。
|
||||
|
||||
保存したリマインドは 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,
|
||||
)
|
||||
from app.core.llm import complete
|
||||
from app.db.database import SessionLocal
|
||||
from app.db.models import Reminder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 同じリマインドの配信にこの回数失敗したら諦める。チャンネル削除などで永久に
|
||||
# 失敗し続けるものが、ポーリングのたびにエラーログを出すのを防ぐ。
|
||||
_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 _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 build_tool_hint() -> str:
|
||||
"""create_reminder 等を使わせるための時刻文脈。system メッセージとして差し込む。
|
||||
|
||||
discord_bot.on_message がキーワード判定なしに全メンションでこれを同席させるので、
|
||||
ここにはリマインドに関する指示だけを書く(お天気アプリとしての人格や口調は
|
||||
DISCORD_BOT_SYSTEM_PROMPT 側の責務のまま)。
|
||||
"""
|
||||
now = _local_now()
|
||||
weekday = "月火水木金土日"[now.weekday()]
|
||||
return (
|
||||
f"現在の日時は {now:%Y-%m-%d %H:%M}({weekday}曜日)です。\n"
|
||||
"ユーザーが『あとで知らせて』『◯時になったら教えて』『明日起こして』のように"
|
||||
"リマインドを頼んできたら、create_reminder / list_reminders / cancel_reminder "
|
||||
"ツールを使って対応してください。『明日』『30分後』などの相対表現は、この現在時刻を"
|
||||
"基準に絶対日時へ変換すること。時刻が指定されていない依頼は朝9時と解釈してかまいませんが、"
|
||||
"その解釈を返事の中で明示してください。\n"
|
||||
"リマインドと関係ない話題では、これらのツールは使わずいつも通り会話してください。"
|
||||
)
|
||||
|
||||
|
||||
async def resolve_reminder_tools(
|
||||
messages: list[dict],
|
||||
ctx: ReminderContext,
|
||||
model: str | None = None,
|
||||
max_rounds: int = 3,
|
||||
) -> str:
|
||||
"""会話にリマインドツールを差し込んで解決し、最終的な返信本文を返す。
|
||||
|
||||
discord_bot.on_message が全メンションでこれを通す。ツール呼び出しに対応していない
|
||||
モデルが設定されていた場合は、例外を握りつぶしてツールなしの通常応答にフォールバック
|
||||
する(リマインドと無関係な会話まで巻き添えで壊れないようにするため)。
|
||||
"""
|
||||
used_model = model or DEFAULT_MODEL
|
||||
working = list(messages)
|
||||
|
||||
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=working,
|
||||
tools=REMINDER_TOOLS,
|
||||
)
|
||||
except Exception:
|
||||
if round_no == 1:
|
||||
# 1ラウンド目からして失敗=モデルがそもそも tools 付き呼び出しを
|
||||
# 受け付けない可能性が高い。ツールなしの通常応答として救済する。
|
||||
logger.info(
|
||||
"Model %s rejected a tools-attached call; falling back to plain completion", used_model,
|
||||
)
|
||||
return await complete(messages, model=used_model)
|
||||
logger.exception("Reminder-aware completion failed mid-loop [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_calls = getattr(choice_message, "tool_calls", None)
|
||||
if not tool_calls:
|
||||
return (choice_message.content or "").strip()
|
||||
|
||||
working.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 "⚠️ リマインドの保存に失敗しました(データベースエラー)。サーバーのログを確認してください。"
|
||||
working.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()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import httpx
|
||||
@@ -10,7 +11,6 @@ from app.core import llm
|
||||
from app.core.config import (
|
||||
KNOWLEDGE_CONTEXT_LIMIT,
|
||||
KNOWLEDGE_TOOL_CONTENT_LIMIT,
|
||||
SEARCH_SUMMARY_MODEL,
|
||||
)
|
||||
from app.core.memory_kb import local_knowledge
|
||||
from app.db.models import KnowledgeEntry, KnowledgeSource
|
||||
@@ -37,6 +37,14 @@ _SUMMARY_PROMPT = (
|
||||
_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()
|
||||
@@ -87,7 +95,7 @@ async def summarize_results(query: str, results: list[dict], model: str | None =
|
||||
try:
|
||||
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)
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -24,6 +24,7 @@ _ADDED_COLUMNS = [
|
||||
("knowledge_entries", "headline", "VARCHAR"),
|
||||
("messages", "archived_at", "DATETIME"),
|
||||
("messages", "memory_id", "INTEGER"),
|
||||
("local_knowledge_docs", "embedding_model", "VARCHAR"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ class LocalKnowledgeDoc(Base):
|
||||
"""backend/knowledge_base/ 配下のMarkdownファイルとその埋め込みベクトル。
|
||||
|
||||
話題に応じて関連ファイルをシステムプロンプトに注入するために使う。
|
||||
ファイル内容のハッシュが変わった時だけ埋め込みを再計算する。
|
||||
ファイル内容のハッシュか埋め込みモデルが変わった時だけ埋め込みを再計算する。
|
||||
"""
|
||||
__tablename__ = "local_knowledge_docs"
|
||||
|
||||
@@ -113,6 +113,10 @@ class LocalKnowledgeDoc(Base):
|
||||
title: Mapped[str] = mapped_column(String)
|
||||
content_hash: Mapped[str] = mapped_column(String)
|
||||
embedding_json: Mapped[str] = mapped_column(Text)
|
||||
# この埋め込みを計算したモデル名。モデルを切り替えた行を見分けて埋め直すために持つ
|
||||
# (次元数の違うベクトル同士を比較しても意味のないスコアしか出ないため)。
|
||||
# このカラムより前に作られた行は 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))
|
||||
|
||||
|
||||
@@ -186,3 +190,24 @@ class ApiLog(Base):
|
||||
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(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)
|
||||
|
||||
+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)])
|
||||
|
||||
+94
@@ -252,6 +252,100 @@ attachment; filename="______.md"; filename*=UTF-8''%E8%B2%B7%E3%81%84%E7%89%A9%E
|
||||
|
||||
---
|
||||
|
||||
## ローカルナレッジベース(閲覧)
|
||||
|
||||
`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
|
||||
|
||||
+80
-1
@@ -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,12 +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/`)
|
||||
|
||||
@@ -171,6 +177,72 @@ data: {"type":"error","value":"エラー内容"} # 例外発生時
|
||||
|
||||
バッファリング防止のため `Cache-Control: no-cache` と `X-Accel-Buffering: no` を付与する。
|
||||
|
||||
### Discord Bot(`core/discord/`)
|
||||
|
||||
`main.py` の lifespan が `start_discord_bot()`(メンション応答)と `start_scheduler()`(定期ジョブ)を起動する。
|
||||
|
||||
`on_message` はメンション本文をキーワードで見て分岐する。判定順は **雑談 → 天気** で、
|
||||
先にマッチしたものが返信を返して終わる。どちらにも当てはまらなければ通常応答へ進む。
|
||||
|
||||
| 分岐 | キーワード例 | 動作 |
|
||||
|---|---|---|
|
||||
| 雑談 | どうでもいい / 雑談 / 暇 | 『どうでもいいTips』へ誘導しつつ、話題に近いタグで Tips を1本投稿 |
|
||||
| 天気 | 天気 / 洗濯 / 雨 | 気象庁の明日の予報を system メッセージに足してから返信 |
|
||||
| 通常応答 | (上記に当てはまらない全て) | `reminders.resolve_reminder_tools()` に渡す |
|
||||
|
||||
リマインドはキーワードで振り分けない。雑談以外の全メンション(天気キーワード込み)で
|
||||
`create_reminder` / `list_reminders` / `cancel_reminder` の 3 ツールを常に同席させ、使うかどうかは
|
||||
LLM 自身に判断させる。以前はキーワード一覧(リマインド / アラーム 等)で振り分けていたが、
|
||||
「モーニングコール」のように一覧に無い言い回しを取りこぼす実例があったため、この方式に変えた。
|
||||
|
||||
#### スレッド化(`discord_bot._ensure_thread()` / `_reply()`)
|
||||
|
||||
全メンションへの返信を Discord のスレッドに集約する。`on_message` の先頭、キーワード分岐より前に
|
||||
`reply_channel = await _ensure_thread(message, content)` を呼び、以後の返信・タイピング表示・
|
||||
`ReminderContext.channel_id` はすべて `reply_channel` を通す。
|
||||
|
||||
- 通常のチャンネルでのメンションは、元メッセージから `message.create_thread()` で新規スレッドを作る
|
||||
(名前は本文を 90 文字に切り詰めたもの、自動アーカイブ 1440 分=1日)
|
||||
- 既にスレッド内でのメンションはそのスレッドをそのまま使い、新規作成はしない
|
||||
- DM(`message.guild is None`)や、権限不足など何らかの理由でスレッド作成に失敗した場合は
|
||||
例外を握りつぶして元のチャンネルへフォールバックする(Bot の応答自体は止めない)
|
||||
- `_reply()` は `reply_channel` が元のチャンネルのままなら `message.reply()`(引用UI付き)、
|
||||
新規スレッドを作った場合は `reply_channel.send()` を使う。`message.reply()` は常に元メッセージの
|
||||
チャンネル(スレッドの親)へ送ってしまいスレッドには入らないため、明示的に使い分けが要る
|
||||
- そのメンションから登録したリマインドは `channel_id` にスレッドの ID が入るため、⏰通知も同じ
|
||||
スレッドに届く。スレッドが自動アーカイブされていても、送信すると Discord 側で自動的に展開される
|
||||
|
||||
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`)
|
||||
|
||||
一回きりのリマインドのみを扱う(繰り返しには非対応)。
|
||||
|
||||
- **登録**: `resolve_reminder_tools()` が `discord_bot.on_message` の全メンションで
|
||||
`create_reminder` / `list_reminders` / `cancel_reminder` の 3 ツールを差し込み、自然言語から
|
||||
日時と用件を取り出させる。`build_tool_hint()` が現在時刻と曜日を system メッセージに足すので、
|
||||
『明日』『30分後』といった相対表現はモデル側が絶対日時に直す。使うモデルは
|
||||
discord_bot 側が渡す `WEATHER_NOTIFY_MODEL`(未設定なら `DEFAULT_MODEL`)で、一般会話・天気応答と
|
||||
共通。**tool calling 対応モデルであることが望ましい** — 非対応モデルがツール付き呼び出し自体を
|
||||
拒否した場合は、1 ラウンド目の例外を検知してツールなしの通常応答にフォールバックする
|
||||
(リマインド機能は使えないが、一般会話・天気応答は道連れにならない)
|
||||
- **バリデーション**: 過去・`REMINDER_MAX_AHEAD_DAYS` より先・解釈不能な日時はツール結果として
|
||||
エラー文をモデルに返し、モデルに聞き直させる
|
||||
- **配信先**: Webhook ではなく Bot クライアントから、登録されたチャンネルへ依頼者をメンションして投稿する。
|
||||
そのため専用の Webhook URL 設定は要らない
|
||||
- **タイムゾーン**: DB は他テーブルと同じく UTC。ユーザーおよび LLM とのやり取りはすべて
|
||||
このマシンのローカル時刻で行い、`reminders.py` の境界で変換する
|
||||
- **配信の失敗**: チャンネル削除などで送れない場合はプロセス内カウンタで数え、5 回失敗したら
|
||||
`notified_at` を立てて諦める(毎回エラーログを出し続けるのを防ぐ)。カウンタは DB に持たないので
|
||||
バックエンドを再起動すればまた試す
|
||||
- **バックエンド停止中に期限を迎えた分**: 起動して Bot が ready になった直後にまとめて配信される
|
||||
|
||||
---
|
||||
|
||||
## フロントエンド
|
||||
@@ -217,6 +289,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`を編集し直さなくても、
|
||||
|
||||
+35
-5
@@ -28,6 +28,7 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
|
||||
| 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` |
|
||||
@@ -39,6 +40,7 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
|
||||
| 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` |
|
||||
|
||||
---
|
||||
@@ -92,7 +94,7 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
|
||||
## 5. Web 検索と知識の蓄積
|
||||
|
||||
- LLM が必要と判断したときだけ `web_search` ツールが走る(Tavily。`TAVILY_API_KEY` 未設定なら空振り)。
|
||||
- 結果は `SEARCH_SUMMARY_MODEL` で構造化要約にされ、**2 か所**に保存される。
|
||||
- 結果は `SEARCH_SUMMARY_MODEL`(未設定なら `DEFAULT_MODEL`。設定画面から変更可)で構造化要約にされ、**2 か所**に保存される。
|
||||
- `knowledge_entries` / `knowledge_sources` テーブル → 次回以降、層 1 として全セッションに効く
|
||||
- `knowledge_base/web_search/*.md` → 層 3 の類似度検索の対象になる
|
||||
- `POST /chat/knowledge/regenerate` で、既存の知識エントリを出典 URL から取り直して要約し直せる。
|
||||
@@ -101,8 +103,17 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
|
||||
|
||||
- `backend/knowledge_base/**.md` を置いておくだけで、話題が近いときに自動で全文が注入される。
|
||||
- 直近のユーザー発言との埋め込みコサイン類似度で選ぶ。埋め込みは `local_knowledge_docs` に
|
||||
内容ハッシュ付きでキャッシュされ、ファイルが変わったときだけ取り直す。
|
||||
内容ハッシュと**使用した埋め込みモデル名**付きでキャッシュされ、
|
||||
ファイルが変わったときと、埋め込みモデルが変わったときだけ取り直す。
|
||||
- 設定画面で `LOCAL_KB_EMBEDDING_MODEL` を変更すると、保存した時点でバックグラウンドの再埋め込みが走る
|
||||
(モデルが違えば次元も意味空間も変わり、古いベクトルは比較しても無意味なため)。
|
||||
パネルの「埋め直し」ボタンからも手動で開始できる。
|
||||
- プロンプト上は「Web 検索結果より優先。これで答えられるなら検索するな」と明示している。
|
||||
- ヘッダーのパネルメニュー →「ナレッジベース」で開くサイドパネル(`KnowledgeBasePanel.vue`)から中身を閲覧できる。
|
||||
パネル上部には埋め込みの状態(モデル名・次元数・埋め込み済み件数・進捗バー)が出る。
|
||||
「接続テスト」は短いテキストを 1 回埋め込んでみて、モデルが応答するかその場で確かめる。
|
||||
フォルダ単位のグループ表示・折りたたみ、タイトル/パス/本文先頭での絞り込み、Markdown 描画に対応。
|
||||
各ファイルの緑の点は埋め込み済み(=自動注入の対象)であることを示す。閲覧専用で、編集はできない。
|
||||
|
||||
## 7. 共有ノート
|
||||
|
||||
@@ -153,10 +164,21 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
|
||||
- モデルのプルダウンは `model_presets` テーブル由来で、初回起動時に既定の一覧がシードされる。
|
||||
設定画面からラベル/モデル文字列を追加・編集・削除できる(重複は 400 で弾く)。
|
||||
- 設定画面では API キー類を**再起動なしで**変更できる。
|
||||
- 変更できるのは許可リストにあるキーだけ(`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / AWS 系 /
|
||||
`OLLAMA_API_BASE` / `TAVILY_API_KEY` / `LOCAL_KB_EMBEDDING_MODEL`)
|
||||
- 値は末尾 4 文字だけを見せるマスク表示。空欄で保存すると「変更なし」扱い
|
||||
- 変更できるのは許可リストにあるキーだけ(`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. ログビューアとシステムモニタ
|
||||
|
||||
@@ -179,6 +201,14 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
|
||||
- `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 リバースプロキシ)/
|
||||
|
||||
Generated
+26
@@ -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",
|
||||
@@ -1242,6 +1252,22 @@
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"commander": "^8.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"katex": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -176,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
|
||||
}
|
||||
|
||||
@@ -395,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, '再埋め込み開始')
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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 },
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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