From f085db421c3fabd80ec1cc29d4702fe06c054aec Mon Sep 17 00:00:00 2001 From: zukki259 Date: Sun, 23 Aug 2026 21:38:54 +0900 Subject: [PATCH] Browse the knowledge base from a single side panel menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , 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 --- CLAUDE.md | 4 +- backend/app/api/knowledge_base.py | 61 ++++++ backend/app/core/memory_kb/local_knowledge.py | 77 ++++++++ backend/app/main.py | 3 +- docs/api.md | 54 ++++++ docs/architecture.md | 1 + docs/features.md | 13 ++ frontend/src/App.vue | 46 ++--- frontend/src/api/client.ts | 33 ++++ .../src/components/KnowledgeBasePanel.vue | 173 ++++++++++++++++++ frontend/src/components/SidePanelMenu.vue | 118 ++++++++++++ frontend/src/panels.ts | 28 +++ frontend/src/stores/knowledgeBase.ts | 77 ++++++++ 13 files changed, 654 insertions(+), 34 deletions(-) create mode 100644 backend/app/api/knowledge_base.py create mode 100644 frontend/src/components/KnowledgeBasePanel.vue create mode 100644 frontend/src/components/SidePanelMenu.vue create mode 100644 frontend/src/panels.ts create mode 100644 frontend/src/stores/knowledgeBase.ts diff --git a/CLAUDE.md b/CLAUDE.md index d6f580d..0b1d2f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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=`)から閲覧できる(読み取り専用) 4. **長期メモリ**: `memories` の要約を全件連結。上限なし 5. **短期メモリ**: セッション内の `messages` 全件(要約で削除された分を除く) diff --git a/backend/app/api/knowledge_base.py b/backend/app/api/knowledge_base.py new file mode 100644 index 0000000..b93dab7 --- /dev/null +++ b/backend/app/api/knowledge_base.py @@ -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) diff --git a/backend/app/core/memory_kb/local_knowledge.py b/backend/app/core/memory_kb/local_knowledge.py index de06c82..dd94be2 100644 --- a/backend/app/core/memory_kb/local_knowledge.py +++ b/backend/app/core/memory_kb/local_knowledge.py @@ -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), + } diff --git a/backend/app/main.py b/backend/app/main.py index 07b5116..66aa601 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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)]) diff --git a/docs/api.md b/docs/api.md index d4f44b6..ac312fb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -252,6 +252,60 @@ 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 /logs diff --git a/docs/architecture.md b/docs/architecture.md index 243c8ad..705f38f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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・ワードプール参照などの運用系 diff --git a/docs/features.md b/docs/features.md index 3ea568c..c725d1b 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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` | --- @@ -103,6 +105,9 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は - 直近のユーザー発言との埋め込みコサイン類似度で選ぶ。埋め込みは `local_knowledge_docs` に 内容ハッシュ付きでキャッシュされ、ファイルが変わったときだけ取り直す。 - プロンプト上は「Web 検索結果より優先。これで答えられるなら検索するな」と明示している。 +- ヘッダーのパネルメニュー →「ナレッジベース」で開くサイドパネル(`KnowledgeBasePanel.vue`)から中身を閲覧できる。 + フォルダ単位のグループ表示・折りたたみ、タイトル/パス/本文先頭での絞り込み、Markdown 描画に対応。 + 各ファイルの緑の点は埋め込み済み(=自動注入の対象)であることを示す。閲覧専用で、編集はできない。 ## 7. 共有ノート @@ -179,6 +184,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 リバースプロキシ)/ diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e58b925..a261ce6 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,27 +1,23 @@ + + + + diff --git a/frontend/src/components/SidePanelMenu.vue b/frontend/src/components/SidePanelMenu.vue new file mode 100644 index 0000000..176e952 --- /dev/null +++ b/frontend/src/components/SidePanelMenu.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/frontend/src/panels.ts b/frontend/src/panels.ts new file mode 100644 index 0000000..87d83e7 --- /dev/null +++ b/frontend/src/panels.ts @@ -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 }, +] diff --git a/frontend/src/stores/knowledgeBase.ts b/frontend/src/stores/knowledgeBase.ts new file mode 100644 index 0000000..25b2f54 --- /dev/null +++ b/frontend/src/stores/knowledgeBase.ts @@ -0,0 +1,77 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import { fetchKbFile, fetchKbFiles, type KbFileContent, type KbFileInfo } from '../api/client' + +export interface KbGroup { + /** ナレッジベース直下は空文字。 */ + directory: string + files: KbFileInfo[] +} + +export const useKnowledgeBaseStore = defineStore('knowledgeBase', () => { + const files = ref([]) + /** 開いているファイルの全文。一覧に戻ると null に戻す。 */ + const openFile = ref(null) + const loading = ref(false) + const error = ref(null) + /** タイトル・パス・プレビューに対する絞り込み文字列。 */ + const filter = ref('') + /** 折りたたまれているフォルダ名。web_search のようにファイル数が多いフォルダを畳むために使う。 */ + const collapsed = ref>(new Set()) + + 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(() => { + 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 { + 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 { + 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 + } + + 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, filteredFiles, groups, load, open, close, toggleGroup } +}) -- 2.54.0