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:
@@ -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
@@ -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)])
|
||||
|
||||
Reference in New Issue
Block a user