Browse the knowledge base from a single side panel menu

Add a read-only view of backend/knowledge_base/**.md so the files that get
injected into the system prompt can be inspected from the GUI:

- GET /api/kb/files lists the Markdown files (title, preview, size, mtime and
  whether the file is already embedded), GET /api/kb/file returns one in full.
  Paths are resolved against the knowledge base directory and rejected unless
  they stay inside it and end in .md.
- KnowledgeBasePanel.vue groups the files by folder (collapsible, so web_search
  can be folded away), filters by title/path/preview and renders the selected
  file with MarkdownContent.

The header had grown to four side panel toggles, so collapse them into one
menu: panels.ts now holds the list of panels along with the component each one
renders, App.vue picks it with <component :is>, and SidePanelMenu.vue shows the
active panel on its button. Re-picking the open panel (or "パネルを閉じる")
closes it; a click outside or Esc closes just the menu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 21:38:54 +09:00
co-authored by Claude Opus 5
parent 5d63a59b86
commit f085db421c
13 changed files with 654 additions and 34 deletions
+61
View File
@@ -0,0 +1,61 @@
"""ローカルナレッジベース(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.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 を一覧で返す(本文は含めず、先頭のプレビューのみ)。"""
indexed_paths = {
# DB 側は OS 依存の区切り文字で保存されているため、比較用に posix 形式へ寄せる
path.replace("\\", "/")
for (path,) in db.query(LocalKnowledgeDoc.path).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)
@@ -8,6 +8,7 @@ import json
import logging
import math
import os
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy.orm import Session as DBSession
@@ -174,3 +175,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),
}
+2 -1
View File
@@ -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)])