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
+77
View File
@@ -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 }
})