Author SHA1 Message Date
colcaroliandClaude Opus 5 cd38cbf19d Set the search summary model from the settings panel
Summarizing web search results falls back to DEFAULT_MODEL, so a local Ollama
model ends up doing it and the call can fail — which surfaces only as
"保存できる要約なし" in the chat. Make SEARCH_SUMMARY_MODEL settable at runtime:
add it to the settings allowlist and read it from os.environ on each call
(knowledge._summary_model), the way LOCAL_KB_EMBEDDING_MODEL already works.
Importing it as a config constant would keep the old value until a restart.

Show the current value of the settings that are not secrets. A model name
rendered as ••••qwen3 gives no way to tell what is in effect, so the store now
marks those keys (NON_SECRET_KEYS) and returns them unmasked; the API field is
renamed masked -> display_value and carries a secret flag that the panel uses
to pick the input type, replacing the list it kept on its own side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 22:10:36 +09:00
colcaroli 2ced2e3cd5 Merge pull request 'Browse the knowledge base from a single side panel menu' (#5) from feature/knowledge-base-panel into main
Reviewed-on: #5
2026-08-23 12:41:29 +00:00
colcaroliandClaude Opus 5 f085db421c 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>
2026-08-23 21:38:54 +09:00
colcaroli 5d63a59b86 Merge pull request 'Render math in chat messages with KaTeX' (#4) from feature/katex-math-rendering into main
Reviewed-on: #4
2026-08-23 11:13:00 +00:00
colcaroli 8f9458170e Merge branch 'main' into feature/katex-math-rendering 2026-08-23 11:12:14 +00:00
colcaroliandClaude Opus 5 4882d291be Render math in chat messages with KaTeX
Add a marked extension that tokenizes math delimiters and renders them
with KaTeX: `$$ ... $$` / `\[ ... \]` as block, `$ ... $` / `\( ... \)`
and mid-paragraph `$$ ... $$` as inline.

Inline `$ ... $` requires a non-space after the opening `$`, no newline
inside, and a non-digit after the closing `$`, so prose like "$100 から
$200" is left alone; `\$` still escapes a literal dollar sign. Parse
errors fall back to KaTeX's red inline message instead of throwing, and
math whose closing delimiter has not streamed in yet stays plain text.

DOMPurify strips `semantics` and `annotation` by default, which would
leak the raw TeX out of KaTeX's MathML output, so both are allowed
explicitly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:10:03 +09:00
22 changed files with 847 additions and 49 deletions
+4 -3
View File
@@ -59,7 +59,7 @@ Copy-Item .env.example .env
frontend (Vue3 + Pinia, :5173) frontend (Vue3 + Pinia, :5173)
↕ REST + SSE (Vite proxy) ↕ REST + SSE (Vite proxy)
backend (FastAPI, :8000) backend (FastAPI, :8000)
├── app/api/ チャット・メモリ・ノート・ログ・設定 のルーター ├── app/api/ チャット・メモリ・ノート・ナレッジベース閲覧・ログ・設定 のルーター
├── app/core/ LLM ラッパー・共有ノート・設定 ├── app/core/ LLM ラッパー・共有ノート・設定
│ ├── memory_kb/ コンテキスト管理(要約・蓄積知識・ローカルKB・タグ付け) │ ├── memory_kb/ コンテキスト管理(要約・蓄積知識・ローカルKB・タグ付け)
│ ├── search/ Tavily Web 検索 │ ├── 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)件。セッションを横断する 1. **蓄積知識** (`memory_kb/knowledge.py`): 過去の Web 検索で得た `knowledge_entries` の**見出し一覧のみ**(`- 「クエリ」: 一行要約`)。本文と出典は含めず、必要なら `read_knowledge` ツールで読み出す。最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件。セッションを横断する
2. **共有ノート** (`core/notes.py`): タイトル一覧+本文先頭 40 文字のプレビューのみ 2. **共有ノート** (`core/notes.py`): タイトル一覧+本文先頭 40 文字のプレビューのみ
2.5. **参照指定ノート** (`core/notes.py`): ユーザーがそのセッションに紐付けた(GUI のタブで 📌 を押した)ページの**本文全文**。`session_note_pages` テーブルで管理し、1 ページ `SESSION_NOTES_CONTENT_LIMIT`(既定 8000)文字で打ち切る。層 2 の一覧側には印が付き、同じ本文を `read_note` で読み直させない 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` の要約を全件連結。上限なし 4. **長期メモリ**: `memories` の要約を全件連結。上限なし
5. **短期メモリ**: セッション内の `messages` 全件(要約で削除された分を除く) 5. **短期メモリ**: セッション内の `messages` 全件(要約で削除された分を除く)
@@ -111,6 +111,7 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
### Markdown レンダリング ### Markdown レンダリング
アシスタントメッセージは `marked` + `DOMPurify` でレンダリング。ストリーミング中の未閉じコードフェンスは自動補完してから描画する(`MarkdownContent.vue`)。 アシスタントメッセージは `marked` + `DOMPurify` でレンダリング。ストリーミング中の未閉じコードフェンスは自動補完してから描画する(`MarkdownContent.vue`)。
数式は `src/utils/math.ts` の marked 拡張が KaTeX で描画する(ブロック `$$...$$` / `\[...\]`、インライン `$...$` / `\(...\)`)。詳細は `docs/architecture.md` を参照。
### モデル切替 ### モデル切替
@@ -136,7 +137,7 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
| `MEMORY_SUMMARIZE_THRESHOLD` | `20` | 長期メモリ化するメッセージ数の閾値 | | `MEMORY_SUMMARIZE_THRESHOLD` | `20` | 長期メモリ化するメッセージ数の閾値 |
| `KNOWLEDGE_CONTEXT_LIMIT` | `15` | 蓄積知識をシステムプロンプトに注入する最大件数 | | `KNOWLEDGE_CONTEXT_LIMIT` | `15` | 蓄積知識をシステムプロンプトに注入する最大件数 |
| `TAVILY_API_KEY` | — | Tavily Web 検索の API キー(未設定なら `web_search` は空を返す) | | `TAVILY_API_KEY` | — | Tavily Web 検索の API キー(未設定なら `web_search` は空を返す) |
| `SEARCH_SUMMARY_MODEL` | — | Web 検索結果の要約に使うモデル(未設定なら `DEFAULT_MODEL`) | | `SEARCH_SUMMARY_MODEL` | — | Web 検索結果の要約に使うモデル(未設定なら `DEFAULT_MODEL`。GUI 設定画面から実行時変更可) |
| `TAGGING_MODEL` | — | メッセージの自動タグ付けに使うモデル(未設定なら `DEFAULT_MODEL`) | | `TAGGING_MODEL` | — | メッセージの自動タグ付けに使うモデル(未設定なら `DEFAULT_MODEL`) |
| `LOCAL_KB_DIR` | `knowledge_base` | ローカル Markdown ナレッジベースのディレクトリ | | `LOCAL_KB_DIR` | `knowledge_base` | ローカル Markdown ナレッジベースのディレクトリ |
| `LOCAL_KB_EMBEDDING_MODEL` | `text-embedding-3-small` | ローカル KB の埋め込みモデル(GUI 設定画面から実行時変更可) | | `LOCAL_KB_EMBEDDING_MODEL` | `text-embedding-3-small` | ローカル KB の埋め込みモデル(GUI 設定画面から実行時変更可) |
+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)
+4 -1
View File
@@ -12,7 +12,10 @@ router = APIRouter(prefix="/api/settings", tags=["settings"], dependencies=[Depe
class SettingEntry(BaseModel): class SettingEntry(BaseModel):
key: str key: str
is_set: bool is_set: bool
masked: str | None # 秘匿キーはマスク済みの値、それ以外は現在値そのもの。未設定なら None
display_value: str | None
# 入力欄をパスワード扱いにするか(APIキー等は True)
secret: bool
overridden: bool overridden: bool
+1
View File
@@ -29,6 +29,7 @@ TAVILY_API_KEY: str | None = os.getenv("TAVILY_API_KEY")
# 蓄積した知識をシステムプロンプトに注入する際の最大件数 # 蓄積した知識をシステムプロンプトに注入する際の最大件数
KNOWLEDGE_CONTEXT_LIMIT: int = int(os.getenv("KNOWLEDGE_CONTEXT_LIMIT", "15")) KNOWLEDGE_CONTEXT_LIMIT: int = int(os.getenv("KNOWLEDGE_CONTEXT_LIMIT", "15"))
# Web検索結果を構造的な要約にまとめる際に使うモデル(未設定ならDEFAULT_MODELを使用) # Web検索結果を構造的な要約にまとめる際に使うモデル(未設定ならDEFAULT_MODELを使用)
# GUI設定画面から実行時に変更できるため、実際の参照は memory_kb/knowledge._summary_model() が os.environ から行う。
SEARCH_SUMMARY_MODEL: str | None = os.getenv("SEARCH_SUMMARY_MODEL") SEARCH_SUMMARY_MODEL: str | None = os.getenv("SEARCH_SUMMARY_MODEL")
# ローカルMarkdownナレッジベース(話題に応じて関連ファイルをシステムプロンプトに注入) # ローカルMarkdownナレッジベース(話題に応じて関連ファイルをシステムプロンプトに注入)
+10 -2
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import logging import logging
import os
import re import re
import httpx import httpx
@@ -10,7 +11,6 @@ from app.core import llm
from app.core.config import ( from app.core.config import (
KNOWLEDGE_CONTEXT_LIMIT, KNOWLEDGE_CONTEXT_LIMIT,
KNOWLEDGE_TOOL_CONTENT_LIMIT, KNOWLEDGE_TOOL_CONTENT_LIMIT,
SEARCH_SUMMARY_MODEL,
) )
from app.core.memory_kb import local_knowledge from app.core.memory_kb import local_knowledge
from app.db.models import KnowledgeEntry, KnowledgeSource from app.db.models import KnowledgeEntry, KnowledgeSource
@@ -37,6 +37,14 @@ _SUMMARY_PROMPT = (
_HEADLINE_MARKERS = ("要約:", "要約:") _HEADLINE_MARKERS = ("要約:", "要約:")
def _summary_model() -> str | None:
"""GUIの設定画面から実行時に変更できるよう、起動時定数ではなく都度 os.environ を見に行く。
未設定(空文字含む)なら None を返し、llm.complete() 側で DEFAULT_MODEL にフォールバックさせる。
"""
return os.environ.get("SEARCH_SUMMARY_MODEL") or None
def _split_headline(text: str) -> tuple[str, str]: def _split_headline(text: str) -> tuple[str, str]:
"""LLMの出力を (一行要約, 本文) に分ける。1行目が想定の形式でなければ見出しは空で返す。""" """LLMの出力を (一行要約, 本文) に分ける。1行目が想定の形式でなければ見出しは空で返す。"""
lines = text.strip().splitlines() lines = text.strip().splitlines()
@@ -87,7 +95,7 @@ async def summarize_results(query: str, results: list[dict], model: str | None =
try: try:
text = await llm.complete( text = await llm.complete(
[{"role": "user", "content": prompt}], [{"role": "user", "content": prompt}],
model=model or SEARCH_SUMMARY_MODEL, model=model or _summary_model(),
) )
except Exception: except Exception:
logger.exception("Failed to summarize search results for query=%r", query) logger.exception("Failed to summarize search results for query=%r", query)
@@ -8,6 +8,7 @@ import json
import logging import logging
import math import math
import os import os
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from sqlalchemy.orm import Session as DBSession 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検索を行わず、この内容のみで回答してください。"
"情報が不足する場合に限り、補足としてWeb検索を使ってください:\n\n" + "\n\n".join(blocks) "情報が不足する場合に限り、補足として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),
}
+14 -1
View File
@@ -16,8 +16,18 @@ CONFIGURABLE_KEYS = [
"OLLAMA_API_BASE", "OLLAMA_API_BASE",
"TAVILY_API_KEY", "TAVILY_API_KEY",
"LOCAL_KB_EMBEDDING_MODEL", "LOCAL_KB_EMBEDDING_MODEL",
"SEARCH_SUMMARY_MODEL",
] ]
# 秘匿情報ではないキー。値をマスクせずそのまま返し、GUIでも通常のテキスト入力として扱う。
# モデル名やURLは「今どれが効いているか」が見えないと設定しづらいため。
NON_SECRET_KEYS = {
"AWS_REGION_NAME",
"OLLAMA_API_BASE",
"LOCAL_KB_EMBEDDING_MODEL",
"SEARCH_SUMMARY_MODEL",
}
# .env由来の起動時の値のスナップショット。clear() で戻す先として使う。 # .env由来の起動時の値のスナップショット。clear() で戻す先として使う。
_env_defaults: dict[str, str | None] = {} _env_defaults: dict[str, str | None] = {}
_snapshot_taken = False _snapshot_taken = False
@@ -47,15 +57,18 @@ def load_into_env(db: DBSession) -> None:
def list_masked(db: DBSession) -> list[dict]: def list_masked(db: DBSession) -> list[dict]:
"""GUI設定画面に出す一覧。秘匿キーの値はマスクし、そうでないキーは現在値をそのまま見せる。"""
_ensure_snapshot() _ensure_snapshot()
saved = {row.key: row.value for row in db.query(AppSetting).all()} saved = {row.key: row.value for row in db.query(AppSetting).all()}
result = [] result = []
for key in CONFIGURABLE_KEYS: for key in CONFIGURABLE_KEYS:
current = os.environ.get(key) current = os.environ.get(key)
secret = key not in NON_SECRET_KEYS
result.append({ result.append({
"key": key, "key": key,
"is_set": bool(current), "is_set": bool(current),
"masked": _mask(current) if current else None, "display_value": (_mask(current) if secret else current) if current else None,
"secret": secret,
"overridden": key in saved, "overridden": key in saved,
}) })
return result return result
+2 -1
View File
@@ -5,7 +5,7 @@ from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api import auth as auth_api 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 models as models_api
from app.api import settings as settings_api from app.api import settings as settings_api
from app.core import settings_store 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(chat.router, dependencies=[Depends(require_site_session)])
app.include_router(memory.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(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(logs.router, dependencies=[Depends(require_site_session)])
app.include_router(system.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)]) app.include_router(settings_api.router, dependencies=[Depends(require_site_session)])
+54
View File
@@ -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 ### GET /logs
+8
View File
@@ -26,6 +26,7 @@ backend/
│ │ ├── chat.py # チャット・セッション関連エンドポイント(SSE / export / import) │ │ ├── chat.py # チャット・セッション関連エンドポイント(SSE / export / import)
│ │ ├── memory.py # 長期メモリ参照・削除 │ │ ├── memory.py # 長期メモリ参照・削除
│ │ ├── notes.py # 共有ノート CRUD │ │ ├── notes.py # 共有ノート CRUD
│ │ ├── knowledge_base.py # ローカル KB の Markdown を GUI から閲覧(読み取り専用)
│ │ ├── models.py # モデルプリセット CRUD │ │ ├── models.py # モデルプリセット CRUD
│ │ ├── settings.py # GUI 設定画面(APIキー等) │ │ ├── settings.py # GUI 設定画面(APIキー等)
│ │ ├── system.py # タグ backfill・ワードプール参照などの運用系 │ │ ├── system.py # タグ backfill・ワードプール参照などの運用系
@@ -217,6 +218,13 @@ frontend/src/
- アシスタントメッセージのみ `marked` で HTML に変換後 `DOMPurify` でサニタイズ - アシスタントメッセージのみ `marked` で HTML に変換後 `DOMPurify` でサニタイズ
- ストリーミング中(`pending=true`)は未閉じのコードフェンス(` ``` `)を自動補完してから描画 - ストリーミング中(`pending=true`)は未閉じのコードフェンス(` ``` `)を自動補完してから描画
- 数式は `src/utils/math.ts` の marked 拡張が KaTeX で描画する(`mathExtension` / `mathSanitizeConfig`)
- ブロック: 行頭の `$$ ... $$` と `\[ ... \]`。インライン: `$ ... $`、`\( ... \)`、行中の `$$ ... $$`
- `$ ... $` は誤爆防止のため「開き `$` の直後が空白でない」「中身に改行を含まない」「閉じ `$` の直後が数字でない」を満たすものだけを数式にする(`$100 から $200` のような金額表記は素通しする)。リテラルのドル記号は `\$` でエスケープできる
- インラインコード・コードフェンス内の `$` は marked のトークナイザ順により数式にならない
- 構文エラーは例外にせず KaTeX の赤字表示にフォールバックする(`throwOnError: false`)
- KaTeX の MathML 出力に含まれる `semantics` / `annotation` は DOMPurify の既定では許可されないため、`mathSanitizeConfig` で明示的に許可している
- ストリーミング途中で閉じ記号が未到達の数式は、閉じるまでプレーンテキストとして表示される
### Vite プロキシ設定 ### Vite プロキシ設定
+18 -3
View File
@@ -28,6 +28,7 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
| 4 | 自動要約による長期記憶 | 一定件数を超えたら古い会話を要約して圧縮 | `memory_kb/memory.py` | | 4 | 自動要約による長期記憶 | 一定件数を超えたら古い会話を要約して圧縮 | `memory_kb/memory.py` |
| 5 | Web 検索と知識の蓄積 | Tavily で検索 →要約 → DB とローカル KB に保存 | `core/search/` / `memory_kb/knowledge.py` | | 5 | Web 検索と知識の蓄積 | Tavily で検索 →要約 → DB とローカル KB に保存 | `core/search/` / `memory_kb/knowledge.py` |
| 6 | ローカル Markdown ナレッジベース | 話題に近いファイルを埋め込み類似度で選んで全文注入 | `memory_kb/local_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` | | 7 | 共有ノート | タブ/セクション付きノートを人と AI の両方が編集 | `core/notes.py` / `NotePanel.vue` |
| 8 | 自動タグ付け | 発言・要約にタグを 3 つ付与(話題選びに使う) | `memory_kb/tagging.py` | | 8 | 自動タグ付け | 発言・要約にタグを 3 つ付与(話題選びに使う) | `memory_kb/tagging.py` |
| 9 | ワードプール | 直近の名詞をインメモリに保持しタグクラウド表示 | `memory_kb/word_memory.py` / `WordPool.vue` | | 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` | | 15 | API ログビューア | LLM 呼び出しの成功/失敗・レイテンシ・リクエスト全文 | `api/logs.py` / `LogViewer.vue` |
| 16 | システムモニタ | CPU・メモリ・GPU 使用率を 10 秒ごとに表示 | `api/system.py` / `MemoryMonitor.vue` | | 16 | システムモニタ | CPU・メモリ・GPU 使用率を 10 秒ごとに表示 | `api/system.py` / `MemoryMonitor.vue` |
| 17 | サイト認証 | 共有パスワード 1 つでサイト全体を保護 | `core/site_auth.py` / `LoginGate.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` | | 18 | Docker 一式 | Caddy + nginx + FastAPI で HTTPS 公開 | `docker-compose.yml` / `Caddyfile` |
--- ---
@@ -92,7 +94,7 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
## 5. Web 検索と知識の蓄積 ## 5. Web 検索と知識の蓄積
- LLM が必要と判断したときだけ `web_search` ツールが走る(Tavily。`TAVILY_API_KEY` 未設定なら空振り)。 - LLM が必要と判断したときだけ `web_search` ツールが走る(Tavily。`TAVILY_API_KEY` 未設定なら空振り)。
- 結果は `SEARCH_SUMMARY_MODEL` で構造化要約にされ、**2 か所**に保存される。 - 結果は `SEARCH_SUMMARY_MODEL`(未設定なら `DEFAULT_MODEL`。設定画面から変更可)で構造化要約にされ、**2 か所**に保存される。
- `knowledge_entries` / `knowledge_sources` テーブル → 次回以降、層 1 として全セッションに効く - `knowledge_entries` / `knowledge_sources` テーブル → 次回以降、層 1 として全セッションに効く
- `knowledge_base/web_search/*.md` → 層 3 の類似度検索の対象になる - `knowledge_base/web_search/*.md` → 層 3 の類似度検索の対象になる
- `POST /chat/knowledge/regenerate` で、既存の知識エントリを出典 URL から取り直して要約し直せる。 - `POST /chat/knowledge/regenerate` で、既存の知識エントリを出典 URL から取り直して要約し直せる。
@@ -103,6 +105,9 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
- 直近のユーザー発言との埋め込みコサイン類似度で選ぶ。埋め込みは `local_knowledge_docs` に - 直近のユーザー発言との埋め込みコサイン類似度で選ぶ。埋め込みは `local_knowledge_docs` に
内容ハッシュ付きでキャッシュされ、ファイルが変わったときだけ取り直す。 内容ハッシュ付きでキャッシュされ、ファイルが変わったときだけ取り直す。
- プロンプト上は「Web 検索結果より優先。これで答えられるなら検索するな」と明示している。 - プロンプト上は「Web 検索結果より優先。これで答えられるなら検索するな」と明示している。
- ヘッダーのパネルメニュー →「ナレッジベース」で開くサイドパネル(`KnowledgeBasePanel.vue`)から中身を閲覧できる。
フォルダ単位のグループ表示・折りたたみ、タイトル/パス/本文先頭での絞り込み、Markdown 描画に対応。
各ファイルの緑の点は埋め込み済み(=自動注入の対象)であることを示す。閲覧専用で、編集はできない。
## 7. 共有ノート ## 7. 共有ノート
@@ -154,8 +159,10 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
設定画面からラベル/モデル文字列を追加・編集・削除できる(重複は 400 で弾く)。 設定画面からラベル/モデル文字列を追加・編集・削除できる(重複は 400 で弾く)。
- 設定画面では API キー類を**再起動なしで**変更できる。 - 設定画面では API キー類を**再起動なしで**変更できる。
- 変更できるのは許可リストにあるキーだけ(`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / AWS 系 / - 変更できるのは許可リストにあるキーだけ(`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / AWS 系 /
`OLLAMA_API_BASE` / `TAVILY_API_KEY` / `LOCAL_KB_EMBEDDING_MODEL`) `OLLAMA_API_BASE` / `TAVILY_API_KEY` / `LOCAL_KB_EMBEDDING_MODEL` / `SEARCH_SUMMARY_MODEL`)
- 値は末尾 4 文字だけを見せるマスク表示。空欄で保存すると「変更なし」扱い - API キー類は末尾 4 文字だけを見せるマスク表示。モデル名や URL(`settings_store.NON_SECRET_KEYS`)は
現在値をそのまま表示し、入力欄も伏せ字にしない(今どれが効いているか分からないと設定しづらいため)
- 空欄で保存すると「変更なし」扱い
- 保存すると `app_settings` テーブルに入り `os.environ` に反映される。削除すると `.env` 由来の起動時の値に戻る - 保存すると `app_settings` テーブルに入り `os.environ` に反映される。削除すると `.env` 由来の起動時の値に戻る
## 15-16. ログビューアとシステムモニタ ## 15-16. ログビューアとシステムモニタ
@@ -179,6 +186,14 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
- `SESSION_SECRET_KEY` を設定しないと起動ごとに鍵が作り直され、再起動のたびに全員ログインし直しになる。 - `SESSION_SECRET_KEY` を設定しないと起動ごとに鍵が作り直され、再起動のたびに全員ログインし直しになる。
- ローカルの http 環境でログインが効かない場合のみ `SESSION_COOKIE_SECURE=false` にする。 - ローカルの http 環境でログインが効かない場合のみ `SESSION_COOKIE_SECURE=false` にする。
## 17.5. 右サイドパネル(1 つのメニューに集約)
- ノート / ナレッジベース / 設定 / ログは、ヘッダー右端のメニュー 1 つから切り替える。
- 開いているパネルのアイコンと名前がボタンに出る。同じ項目をもう一度選ぶか「パネルを閉じる」で閉じる。
- メニューの外のクリックと `Esc` でメニューだけを閉じられる(パネルは開いたまま)。
- パネルを増やすときは `frontend/src/panels.ts` に 1 行足すだけ。
メニューの項目と、開いたときに描画するコンポーネントをこの配列が兼ねている。
## 18. デプロイ ## 18. デプロイ
- `docker-compose.yml` に backend(FastAPI)/ frontend(nginx で静的配信+API リバースプロキシ)/ - `docker-compose.yml` に backend(FastAPI)/ frontend(nginx で静的配信+API リバースプロキシ)/
+26
View File
@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"dompurify": "^3.1.0", "dompurify": "^3.1.0",
"katex": "^0.16.47",
"marked": "^12.0.0", "marked": "^12.0.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "^3.4.0", "vue": "^3.4.0",
@@ -1117,6 +1118,15 @@
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
} }
}, },
"node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1242,6 +1252,22 @@
"he": "bin/he" "he": "bin/he"
} }
}, },
"node_modules/katex": {
"version": "0.16.47",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
"integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"license": "MIT",
"dependencies": {
"commander": "^8.3.0"
},
"bin": {
"katex": "cli.js"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+1
View File
@@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"dompurify": "^3.1.0", "dompurify": "^3.1.0",
"katex": "^0.16.47",
"marked": "^12.0.0", "marked": "^12.0.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "^3.4.0", "vue": "^3.4.0",
+12 -28
View File
@@ -1,27 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import SessionList from './components/SessionList.vue' import SessionList from './components/SessionList.vue'
import ChatWindow from './components/ChatWindow.vue' import ChatWindow from './components/ChatWindow.vue'
import MessageInput from './components/MessageInput.vue' import MessageInput from './components/MessageInput.vue'
import ModelSelector from './components/ModelSelector.vue' import ModelSelector from './components/ModelSelector.vue'
import LogViewer from './components/LogViewer.vue' import SidePanelMenu from './components/SidePanelMenu.vue'
import SettingsPanel from './components/SettingsPanel.vue'
import NotePanel from './components/NotePanel.vue'
import ToastList from './components/ToastList.vue' import ToastList from './components/ToastList.vue'
import MemoryMonitor from './components/MemoryMonitor.vue' import MemoryMonitor from './components/MemoryMonitor.vue'
import Clock from './components/Clock.vue' import Clock from './components/Clock.vue'
import WordPool from './components/WordPool.vue' import WordPool from './components/WordPool.vue'
import LoginGate from './components/LoginGate.vue' import LoginGate from './components/LoginGate.vue'
import { SIDE_PANELS, type PanelId } from './panels'
import { useAuthStore } from './stores/auth' import { useAuthStore } from './stores/auth'
const authStore = useAuthStore() const authStore = useAuthStore()
type SidePanel = 'none' | 'logs' | 'settings' | 'notes' /** 右サイドに開いているパネル。null はどれも開いていない状態。 */
const activePanel = ref<SidePanel>('none') const activePanel = ref<PanelId | null>(null)
const activePanelDef = computed(() => SIDE_PANELS.find((p) => p.id === activePanel.value) ?? null)
function togglePanel(panel: SidePanel) {
activePanel.value = activePanel.value === panel ? 'none' : panel
}
function handleUnauthorized() { function handleUnauthorized() {
authStore.markUnauthenticated() authStore.markUnauthenticated()
@@ -51,32 +47,20 @@ onUnmounted(() => {
<span class="title">PlayChat</span> <span class="title">PlayChat</span>
<ModelSelector /> <ModelSelector />
<Clock /> <Clock />
<button class="log-toggle" :class="{ active: activePanel === 'notes' }" @click="togglePanel('notes')"> <div class="header-actions">
ノート <SidePanelMenu :active="activePanel" @select="activePanel = $event" />
</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>
<button v-if="authStore.required" class="log-toggle" @click="authStore.logout()"> <button v-if="authStore.required" class="log-toggle" @click="authStore.logout()">
ログアウト ログアウト
</button> </button>
</div>
</header> </header>
<div class="body"> <div class="body">
<div class="chat-pane"> <div class="chat-pane">
<ChatWindow /> <ChatWindow />
<MessageInput /> <MessageInput />
</div> </div>
<div v-if="activePanel === 'logs'" class="log-pane"> <div v-if="activePanelDef" class="log-pane">
<LogViewer /> <component :is="activePanelDef.component" />
</div>
<div v-else-if="activePanel === 'settings'" class="log-pane">
<SettingsPanel />
</div>
<div v-else-if="activePanel === 'notes'" class="log-pane">
<NotePanel />
</div> </div>
</div> </div>
</div> </div>
@@ -101,11 +85,11 @@ header {
border-bottom: 1px solid #333; background: #1a1a1a; flex-shrink: 0; border-bottom: 1px solid #333; background: #1a1a1a; flex-shrink: 0;
} }
.title { font-size: 16px; font-weight: 600; color: #fff; } .title { font-size: 16px; font-weight: 600; color: #fff; }
.header-actions { display: flex; align-items: center; gap: 8px; }
.log-toggle { .log-toggle {
background: #2a2a2a; color: #aaa; border: 1px solid #444; background: #2a2a2a; color: #aaa; border: 1px solid #444;
border-radius: 6px; padding: 4px 14px; font-size: 13px; cursor: pointer; 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; } .body { flex: 1; display: flex; overflow: hidden; }
.chat-pane { flex: 1; display: flex; flex-direction: column; 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; } .log-pane { width: 400px; min-width: 300px; border-left: 1px solid #2a2a2a; overflow: hidden; }
+37 -1
View File
@@ -176,7 +176,10 @@ export async function logout(): Promise<void> {
export interface SettingEntry { export interface SettingEntry {
key: string key: string
is_set: boolean is_set: boolean
masked: string | null /** 秘匿キーはマスク済みの値、それ以外は現在値そのもの。未設定なら null。 */
display_value: string | null
/** APIキーなど、入力欄をパスワード扱いにするキーか。 */
secret: boolean
overridden: boolean overridden: boolean
} }
@@ -395,3 +398,36 @@ export async function reorderNotePages(
}) })
return parseJson<{ reordered: number }>(res, 'ノート並べ替え') 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>
+13 -1
View File
@@ -2,6 +2,8 @@
import { computed } from 'vue' import { computed } from 'vue'
import { marked } from 'marked' import { marked } from 'marked'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import 'katex/dist/katex.min.css'
import { mathExtension, mathSanitizeConfig } from '../utils/math'
const props = defineProps<{ const props = defineProps<{
content: string content: string
@@ -9,6 +11,7 @@ const props = defineProps<{
}>() }>()
marked.setOptions({ breaks: true, gfm: true }) marked.setOptions({ breaks: true, gfm: true })
marked.use(mathExtension)
function closeOpenFences(src: string): string { function closeOpenFences(src: string): string {
const fenceCount = (src.match(/^```/gm) ?? []).length const fenceCount = (src.match(/^```/gm) ?? []).length
@@ -18,7 +21,7 @@ function closeOpenFences(src: string): string {
const html = computed(() => { const html = computed(() => {
const raw = props.pending ? closeOpenFences(props.content) : props.content const raw = props.pending ? closeOpenFences(props.content) : props.content
return DOMPurify.sanitize(marked.parse(raw) as string) return DOMPurify.sanitize(marked.parse(raw) as string, mathSanitizeConfig)
}) })
</script> </script>
@@ -104,6 +107,15 @@ const html = computed(() => {
/* 水平線 */ /* 水平線 */
.md :deep(hr) { border-color: #333; margin: 10px 0; } .md :deep(hr) { border-color: #333; margin: 10px 0; }
/* 数式 (KaTeX) */
.md :deep(.katex) { font-size: 1.05em; }
.md :deep(.katex-display) {
margin: 10px 0;
padding: 2px 0;
overflow-x: auto;
overflow-y: hidden;
}
/* リンク */ /* リンク */
.md :deep(a) { color: #60a5fa; text-decoration: none; } .md :deep(a) { color: #60a5fa; text-decoration: none; }
.md :deep(a:hover) { text-decoration: underline; } .md :deep(a:hover) { text-decoration: underline; }
+3 -5
View File
@@ -46,11 +46,9 @@ const KEY_LABELS: Record<string, string> = {
OLLAMA_API_BASE: 'Ollama API Base URL', OLLAMA_API_BASE: 'Ollama API Base URL',
TAVILY_API_KEY: 'Tavily APIキー', TAVILY_API_KEY: 'Tavily APIキー',
LOCAL_KB_EMBEDDING_MODEL: '埋め込みモデル(ローカルナレッジベース検索用)', LOCAL_KB_EMBEDDING_MODEL: '埋め込みモデル(ローカルナレッジベース検索用)',
SEARCH_SUMMARY_MODEL: '要約モデル(Web検索結果のまとめ用)',
} }
// 秘匿情報ではないため、マスクせず通常のテキスト入力として表示するキー
const PLAIN_TEXT_KEYS = new Set(['LOCAL_KB_EMBEDDING_MODEL', 'OLLAMA_API_BASE', 'AWS_REGION_NAME'])
async function submitPassword() { async function submitPassword() {
const ok = await store.unlock(passwordInput.value) const ok = await store.unlock(passwordInput.value)
if (ok) passwordInput.value = '' if (ok) passwordInput.value = ''
@@ -106,8 +104,8 @@ onMounted(() => {
<div class="row"> <div class="row">
<input <input
v-model="drafts[entry.key]" v-model="drafts[entry.key]"
:type="PLAIN_TEXT_KEYS.has(entry.key) ? 'text' : 'password'" :type="entry.secret ? 'password' : 'text'"
:placeholder="entry.is_set ? `設定済み ${entry.masked}` : '未設定'" :placeholder="entry.is_set ? `現在: ${entry.display_value}` : '未設定'"
autocomplete="off" autocomplete="off"
/> />
<button <button
+118
View File
@@ -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>
+28
View File
@@ -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 },
]
+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 }
})
+103
View File
@@ -0,0 +1,103 @@
import katex from 'katex'
import type { MarkedExtension, TokenizerAndRendererExtension, Tokens } from 'marked'
/**
* marked 拡張として数式 (KaTeX) を描画する。
*
* 対応する区切り記号:
* - ブロック: `$$ ... $$` / `\[ ... \]`(単独行)
* - インライン: `$ ... $` / `\( ... \)` / 行中の `$$ ... $$`
*
* `$` はドル記号としても書かれるため、インラインの `$ ... $` だけは
* 誤爆を避けるための条件を付けている(下記 INLINE_DOLLAR 参照)。
* 明示的にドル記号を書きたい場合は Markdown のエスケープ `\$` が使える。
*/
// ブロック: 行頭(インデント 3 まで)から始まる $$ ... $$ / \[ ... \]
const BLOCK_DOLLAR = /^ {0,3}\$\$([\s\S]+?)\$\$[ \t]*(?:\n+|$)/
const BLOCK_BRACKET = /^ {0,3}\\\[([\s\S]+?)\\\][ \t]*(?:\n+|$)/
// インライン: \( ... \) と、段落の途中に置かれた $$ ... $$
const INLINE_PAREN = /^\\\(((?:\\[^]|[^\\])+?)\\\)/
const INLINE_DOUBLE = /^\$\$((?:\\[^]|[^\\])+?)\$\$/
// インライン: $ ... $
// - 開き `$` の直後に空白・`$` が来るものは対象外
// - 中身に改行と裸の `$` を含まない
// - 閉じ `$` の直後が数字のものは対象外("$100から$200" のような金額表記の除外)
// - 中身が空白で終わるものは対象外(tokenizer 側で判定)
const INLINE_DOLLAR = /^\$(?![\s$])((?:\\.|[^\\$\n])+?)\$(?!\d)/
function escapeHtml(src: string): string {
return src
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function renderMath(tex: string, displayMode: boolean, raw: string): string {
try {
return katex.renderToString(tex.trim(), {
displayMode,
throwOnError: false, // 構文エラーは例外にせず赤字で表示する
errorColor: '#f87171', // ダーク背景で読める色にする
strict: false, // 日本語混じりなどの警告を出さない
output: 'htmlAndMathml',
})
} catch {
// KaTeX が想定外の失敗をした場合は元の文字列をそのまま見せる
return escapeHtml(raw)
}
}
const blockMath: TokenizerAndRendererExtension = {
name: 'blockMath',
level: 'block',
start(src: string) {
return src.match(/(?:^|\n) {0,3}(?:\$\$|\\\[)/)?.index
},
tokenizer(src: string) {
const match = BLOCK_DOLLAR.exec(src) ?? BLOCK_BRACKET.exec(src)
if (!match) return undefined
return { type: 'blockMath', raw: match[0], text: match[1] }
},
renderer(token: Tokens.Generic) {
return renderMath(token.text as string, true, token.raw as string)
},
}
const inlineMath: TokenizerAndRendererExtension = {
name: 'inlineMath',
level: 'inline',
start(src: string) {
// エスケープされた \$ は数式の開始とみなさない
const match = /(^|[^\\])(\$|\\\()/.exec(src)
return match ? match.index + match[1].length : undefined
},
tokenizer(src: string) {
const paren = INLINE_PAREN.exec(src)
if (paren) return { type: 'inlineMath', raw: paren[0], text: paren[1], display: false }
const double = INLINE_DOUBLE.exec(src)
if (double) return { type: 'inlineMath', raw: double[0], text: double[1], display: true }
const single = INLINE_DOLLAR.exec(src)
if (single && !/[\s\\]$/.test(single[1])) {
return { type: 'inlineMath', raw: single[0], text: single[1], display: false }
}
return undefined
},
renderer(token: Tokens.Generic) {
return renderMath(token.text as string, token.display === true, token.raw as string)
},
}
export const mathExtension: MarkedExtension = {
extensions: [blockMath, inlineMath],
}
/** KaTeX が出力する MathML 用に DOMPurify へ追加で許可するタグ・属性 */
export const mathSanitizeConfig = {
ADD_TAGS: ['semantics', 'annotation'],
ADD_ATTR: ['encoding'],
}