Compare commits
2
Commits
5d63a59b86
...
2ced2e3cd5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ced2e3cd5 | ||
|
|
f085db421c |
@@ -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` 全件(要約で削除された分を除く)
|
||||
|
||||
|
||||
@@ -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)])
|
||||
|
||||
+54
@@ -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
|
||||
|
||||
@@ -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・ワードプール参照などの運用系
|
||||
|
||||
@@ -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 リバースプロキシ)/
|
||||
|
||||
+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; }
|
||||
|
||||
@@ -395,3 +395,36 @@ 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, 'ナレッジベースファイル取得')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import MarkdownContent from './MarkdownContent.vue'
|
||||
import { useKnowledgeBaseStore } from '../stores/knowledgeBase'
|
||||
|
||||
const store = useKnowledgeBaseStore()
|
||||
|
||||
onMounted(() => store.load())
|
||||
|
||||
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 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; }
|
||||
|
||||
.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>
|
||||
@@ -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,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<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 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
|
||||
}
|
||||
|
||||
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 }
|
||||
})
|
||||
Reference in New Issue
Block a user