Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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` 全件(要約で削除された分を除く)
|
||||
|
||||
@@ -111,6 +111,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,11 +136,11 @@ 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 オリジン |
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -25,10 +25,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ナレッジベース(話題に応じて関連ファイルをシステムプロンプトに注入)
|
||||
|
||||
@@ -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:
|
||||
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.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=content_hash,
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -16,8 +16,18 @@ CONFIGURABLE_KEYS = [
|
||||
"OLLAMA_API_BASE",
|
||||
"TAVILY_API_KEY",
|
||||
"LOCAL_KB_EMBEDDING_MODEL",
|
||||
"SEARCH_SUMMARY_MODEL",
|
||||
]
|
||||
|
||||
# 秘匿情報ではないキー。値をマスクせずそのまま返し、GUIでも通常のテキスト入力として扱う。
|
||||
# モデル名やURLは「今どれが効いているか」が見えないと設定しづらいため。
|
||||
NON_SECRET_KEYS = {
|
||||
"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 +57,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))
|
||||
|
||||
|
||||
|
||||
+2
-1
@@ -5,7 +5,7 @@ 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
|
||||
@@ -94,6 +94,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
|
||||
|
||||
@@ -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・ワードプール参照などの運用系
|
||||
@@ -64,7 +65,7 @@ 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 設定画面から編集した接続情報 |
|
||||
@@ -217,6 +218,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 プロキシ設定
|
||||
|
||||
|
||||
+30
-4
@@ -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. 共有ノート
|
||||
|
||||
@@ -154,9 +165,16 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
|
||||
設定画面からラベル/モデル文字列を追加・編集・削除できる(重複は 400 で弾く)。
|
||||
- 設定画面では API キー類を**再起動なしで**変更できる。
|
||||
- 変更できるのは許可リストにあるキーだけ(`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / AWS 系 /
|
||||
`OLLAMA_API_BASE` / `TAVILY_API_KEY` / `LOCAL_KB_EMBEDDING_MODEL`)
|
||||
- 値は末尾 4 文字だけを見せるマスク表示。空欄で保存すると「変更なし」扱い
|
||||
`OLLAMA_API_BASE` / `TAVILY_API_KEY` / `LOCAL_KB_EMBEDDING_MODEL` / `SEARCH_SUMMARY_MODEL`)
|
||||
- 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 +197,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",
|
||||
|
||||
+12
-28
@@ -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>
|
||||
<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; }
|
||||
|
||||
@@ -46,11 +46,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 +104,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