Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b30dab650 | ||
|
|
1b88d23f21 | ||
|
|
904458e7df | ||
|
|
97568ba90e | ||
|
|
52dfc0c349 | ||
|
|
cd38cbf19d | ||
|
|
2ced2e3cd5 |
@@ -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 検索結果より優先する扱い。中身は GUI ヘッダーのパネルメニュー →「ナレッジベース」で開くサイドパネル(`api/knowledge_base.py` + `KnowledgeBasePanel.vue`、`GET /api/kb/files` と `GET /api/kb/file?path=`)から閲覧できる(読み取り専用)
|
||||
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=`)から閲覧できる(読み取り専用)。同パネルで埋め込みの状態確認・接続テスト・再埋め込みもできる(`GET /api/kb/embedding`、`POST /api/kb/embedding/test`、`POST /api/kb/embedding/reindex`)。埋め込みは `local_knowledge_docs.embedding_model` に使用モデルを持ち、モデルを変えた行は次の同期で埋め直される
|
||||
4. **長期メモリ**: `memories` の要約を全件連結。上限なし
|
||||
5. **短期メモリ**: セッション内の `messages` 全件(要約で削除された分を除く)
|
||||
|
||||
@@ -136,16 +136,17 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
|
||||
| `DATABASE_URL` | `sqlite:///./playchat.db` | DB 接続先 |
|
||||
| `MEMORY_SUMMARIZE_THRESHOLD` | `20` | 長期メモリ化するメッセージ数の閾値 |
|
||||
| `KNOWLEDGE_CONTEXT_LIMIT` | `15` | 蓄積知識をシステムプロンプトに注入する最大件数 |
|
||||
| `TAVILY_API_KEY` | — | Tavily Web 検索の API キー(未設定なら `web_search` は空を返す) |
|
||||
| `SEARCH_SUMMARY_MODEL` | — | Web 検索結果の要約に使うモデル(未設定なら `DEFAULT_MODEL`) |
|
||||
| `TAVILY_API_KEY` | — | Tavily Web 検索の API キー(未設定なら `web_search` は空を返す。GUI 設定画面から実行時変更可) |
|
||||
| `SEARCH_SUMMARY_MODEL` | — | Web 検索結果の要約に使うモデル(未設定なら `DEFAULT_MODEL`。GUI 設定画面から実行時変更可) |
|
||||
| `TAGGING_MODEL` | — | メッセージの自動タグ付けに使うモデル(未設定なら `DEFAULT_MODEL`) |
|
||||
| `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 設定画面から実行時変更可。変更すると全件が自動で埋め直される) |
|
||||
| `LOCAL_KB_TOP_K` | `3` | ローカル KB から注入する最大ファイル数 |
|
||||
| `LOCAL_KB_MIN_SCORE` | `0.75` | ローカル KB を注入するコサイン類似度の下限 |
|
||||
| `CORS_ORIGINS` | `http://localhost:5173` | 許可する CORS オリジン |
|
||||
| `DEBUG` | `false` | LiteLLM デバッグログの有効化 |
|
||||
| `OPENAI_API_KEY` | — | OpenAI API キー |
|
||||
| `OPENAI_API_BASE` / `OPENAI_BASE_URL` | — | OpenAI 互換エンドポイントの接続先(ローカルの llama-server / Ollama 用)。設定したままだと `gpt-*` を選んでも本家 OpenAI に繋がらない。GUI 設定画面から実行時変更可 |
|
||||
| `ANTHROPIC_API_KEY` | — | Anthropic API キー |
|
||||
| `OLLAMA_API_BASE` | `http://localhost:11434` | Ollama エンドポイント |
|
||||
| `NOTES_TOOL_CONTENT_LIMIT` | `4000` | `read_note` が 1 回に返す本文の最大文字数 |
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
DEFAULT_MODEL=gpt-5.4
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=sk-...
|
||||
# OpenAI互換エンドポイントに向ける場合だけ設定する(ローカルの llama-server / Ollama など)。
|
||||
# 設定したままにすると、gpt-* のモデルを選んでも接続先がそちらのままになり、
|
||||
# 本家OpenAIには繋がらない(litellm は OPENAI_API_BASE、OpenAI SDK は OPENAI_BASE_URL を見る)。
|
||||
# サーバーにデプロイする際はこの2行を消し忘れないこと。GUIの設定画面からも変更できる。
|
||||
# OPENAI_API_BASE=http://localhost:11434/v1
|
||||
# OPENAI_BASE_URL=http://localhost:11434/v1
|
||||
# Anthropic
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
# Ollama (ローカル) — APIキー不要、モデル名は ollama/llama3 形式で指定
|
||||
|
||||
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.core.memory_kb import local_knowledge
|
||||
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
|
||||
@@ -41,10 +42,14 @@ class KbFileContent(BaseModel):
|
||||
@router.get("/files", response_model=list[KbFileInfo])
|
||||
def list_files(db: DBSession = Depends(get_db)):
|
||||
"""ナレッジベース配下の Markdown を一覧で返す(本文は含めず、先頭のプレビューのみ)。"""
|
||||
# 「埋め込み済み」は現在のモデルで埋め込まれている行だけを指す(別モデルの行は埋め直し待ち)
|
||||
current_model = local_knowledge.current_embedding_model()
|
||||
indexed_paths = {
|
||||
# DB 側は OS 依存の区切り文字で保存されているため、比較用に posix 形式へ寄せる
|
||||
path.replace("\\", "/")
|
||||
for (path,) in db.query(LocalKnowledgeDoc.path).all()
|
||||
for (path,) in db.query(LocalKnowledgeDoc.path)
|
||||
.filter(LocalKnowledgeDoc.embedding_model == current_model)
|
||||
.all()
|
||||
}
|
||||
return [
|
||||
KbFileInfo(**info, indexed=info["path"] in indexed_paths)
|
||||
@@ -59,3 +64,49 @@ def get_file(path: str = Query(..., description="ナレッジベースディレ
|
||||
if file is None:
|
||||
raise HTTPException(status_code=404, detail="Knowledge base file not found")
|
||||
return KbFileContent(**file)
|
||||
|
||||
|
||||
class EmbeddingStatus(BaseModel):
|
||||
"""埋め込みモデルの現状。GUI の状態表示・進捗バーに使う。"""
|
||||
model: str
|
||||
# 現在のモデルで埋め込まれたベクトルの次元数(1件も無ければ None)
|
||||
dimensions: int | None
|
||||
file_count: int
|
||||
indexed: int
|
||||
# 別のモデルで埋め込まれていて埋め直しが必要な件数
|
||||
stale: int
|
||||
running: bool
|
||||
total: int
|
||||
done: int
|
||||
failed: int
|
||||
started_at: datetime | None
|
||||
finished_at: datetime | None
|
||||
last_error: str | None
|
||||
|
||||
|
||||
class EmbeddingProbe(BaseModel):
|
||||
"""短いテキストを実際に埋め込んでみた結果。"""
|
||||
ok: bool
|
||||
model: str
|
||||
dimensions: int | None
|
||||
latency_ms: float
|
||||
error: str | None
|
||||
|
||||
|
||||
@router.get("/embedding", response_model=EmbeddingStatus)
|
||||
def get_embedding_status(db: DBSession = Depends(get_db)):
|
||||
"""現在の埋め込みモデルと、埋め込み済み件数・実行中の進捗を返す。"""
|
||||
return local_knowledge.embedding_status(db)
|
||||
|
||||
|
||||
@router.post("/embedding/test", response_model=EmbeddingProbe)
|
||||
async def test_embedding():
|
||||
"""埋め込みモデルに実際に1回リクエストして、応答するかどうかを確かめる。"""
|
||||
return await local_knowledge.probe_embedding()
|
||||
|
||||
|
||||
@router.post("/embedding/reindex", response_model=EmbeddingStatus)
|
||||
async def reindex_embeddings(db: DBSession = Depends(get_db)):
|
||||
"""全ファイルの再埋め込みをバックグラウンドで開始する。実行中なら何もしない(進捗をそのまま返す)。"""
|
||||
local_knowledge.start_reembed_all()
|
||||
return local_knowledge.embedding_status(db)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.core import settings_store
|
||||
from app.core.auth import require_settings_password
|
||||
from app.core.memory_kb import local_knowledge
|
||||
from app.db.database import get_db
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"], dependencies=[Depends(require_settings_password)])
|
||||
@@ -12,7 +15,10 @@ router = APIRouter(prefix="/api/settings", tags=["settings"], dependencies=[Depe
|
||||
class SettingEntry(BaseModel):
|
||||
key: str
|
||||
is_set: bool
|
||||
masked: str | None
|
||||
# 秘匿キーはマスク済みの値、それ以外は現在値そのもの。未設定なら None
|
||||
display_value: str | None
|
||||
# 入力欄をパスワード扱いにするか(APIキー等は True)
|
||||
secret: bool
|
||||
overridden: bool
|
||||
|
||||
|
||||
@@ -26,7 +32,8 @@ def list_settings(db: DBSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.put("", response_model=list[SettingEntry])
|
||||
def save_settings(req: SaveSettingsRequest, db: DBSession = Depends(get_db)):
|
||||
async def save_settings(req: SaveSettingsRequest, db: DBSession = Depends(get_db)):
|
||||
before_embedding_model = os.environ.get("LOCAL_KB_EMBEDDING_MODEL")
|
||||
try:
|
||||
for key, value in req.values.items():
|
||||
if not value:
|
||||
@@ -34,13 +41,29 @@ def save_settings(req: SaveSettingsRequest, db: DBSession = Depends(get_db)):
|
||||
settings_store.save(db, key, value)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
_reembed_if_model_changed(before_embedding_model)
|
||||
return settings_store.list_masked(db)
|
||||
|
||||
|
||||
@router.delete("/{key}", response_model=list[SettingEntry])
|
||||
def clear_setting(key: str, db: DBSession = Depends(get_db)):
|
||||
async def clear_setting(key: str, db: DBSession = Depends(get_db)):
|
||||
before_embedding_model = os.environ.get("LOCAL_KB_EMBEDDING_MODEL")
|
||||
try:
|
||||
settings_store.clear(db, key)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
_reembed_if_model_changed(before_embedding_model)
|
||||
return settings_store.list_masked(db)
|
||||
|
||||
|
||||
def _reembed_if_model_changed(before: str | None) -> None:
|
||||
"""埋め込みモデルが変わったら、ローカルKBの再埋め込みをバックグラウンドで走らせる。
|
||||
|
||||
モデルが違うと次元数も意味空間も変わり、古いベクトルは比較しても無意味なので、
|
||||
設定を変えた時点で全件やり直す(完了を待たずに応答は返す)。
|
||||
"""
|
||||
if os.environ.get("LOCAL_KB_EMBEDDING_MODEL") == before:
|
||||
return
|
||||
local_knowledge.start_reembed_all()
|
||||
|
||||
@@ -25,10 +25,12 @@ SESSION_MAX_AGE_DAYS: int = int(os.getenv("SESSION_MAX_AGE_DAYS", "30"))
|
||||
TAGGING_MODEL: str | None = os.getenv("TAGGING_MODEL")
|
||||
|
||||
# Tavily Web検索
|
||||
# GUI設定画面から実行時に変更できるため、実際の参照は search/websearch._api_key() が os.environ から行う。
|
||||
TAVILY_API_KEY: str | None = os.getenv("TAVILY_API_KEY")
|
||||
# 蓄積した知識をシステムプロンプトに注入する際の最大件数
|
||||
KNOWLEDGE_CONTEXT_LIMIT: int = int(os.getenv("KNOWLEDGE_CONTEXT_LIMIT", "15"))
|
||||
# Web検索結果を構造的な要約にまとめる際に使うモデル(未設定ならDEFAULT_MODELを使用)
|
||||
# GUI設定画面から実行時に変更できるため、実際の参照は memory_kb/knowledge._summary_model() が os.environ から行う。
|
||||
SEARCH_SUMMARY_MODEL: str | None = os.getenv("SEARCH_SUMMARY_MODEL")
|
||||
|
||||
# ローカルMarkdownナレッジベース(話題に応じて関連ファイルをシステムプロンプトに注入)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import httpx
|
||||
@@ -10,7 +11,6 @@ from app.core import llm
|
||||
from app.core.config import (
|
||||
KNOWLEDGE_CONTEXT_LIMIT,
|
||||
KNOWLEDGE_TOOL_CONTENT_LIMIT,
|
||||
SEARCH_SUMMARY_MODEL,
|
||||
)
|
||||
from app.core.memory_kb import local_knowledge
|
||||
from app.db.models import KnowledgeEntry, KnowledgeSource
|
||||
@@ -37,6 +37,14 @@ _SUMMARY_PROMPT = (
|
||||
_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]:
|
||||
"""LLMの出力を (一行要約, 本文) に分ける。1行目が想定の形式でなければ見出しは空で返す。"""
|
||||
lines = text.strip().splitlines()
|
||||
@@ -87,7 +95,7 @@ async def summarize_results(query: str, results: list[dict], model: str | None =
|
||||
try:
|
||||
text = await llm.complete(
|
||||
[{"role": "user", "content": prompt}],
|
||||
model=model or SEARCH_SUMMARY_MODEL,
|
||||
model=model or _summary_model(),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to summarize search results for query=%r", query)
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
- sync_local_kb(): ディレクトリをスキャンし、新規/変更されたファイルだけ埋め込みを計算してDBに保存する
|
||||
- get_local_kb_context(): 直近のユーザー発言に埋め込み類似度の高いファイルを探し、システムプロンプト用テキストにする
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -19,6 +21,46 @@ from app.db.models import KnowledgeEntry, LocalKnowledgeDoc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 埋め込みの進行状況。GUIに出すためだけのプロセス内の状態で、永続化はしない
|
||||
# (uvicornを複数ワーカーで動かすとワーカーごとに別の値になる点に注意)。
|
||||
_progress: dict = {
|
||||
"running": False,
|
||||
"total": 0,
|
||||
"done": 0,
|
||||
"failed": 0,
|
||||
"started_at": None,
|
||||
"finished_at": None,
|
||||
"last_error": None,
|
||||
"last_model": None,
|
||||
}
|
||||
|
||||
# create_task の戻り値。GCで途中終了しないように保持しておく
|
||||
_reembed_task: "asyncio.Task | None" = None
|
||||
|
||||
|
||||
def _start_progress(model: str, total: int) -> None:
|
||||
_progress.update(
|
||||
running=True, total=total, done=0, failed=0,
|
||||
started_at=datetime.now(timezone.utc), finished_at=None,
|
||||
last_error=None, last_model=model,
|
||||
)
|
||||
|
||||
|
||||
def _finish_progress() -> None:
|
||||
_progress.update(running=False, finished_at=datetime.now(timezone.utc))
|
||||
|
||||
|
||||
def _progress_snapshot() -> dict:
|
||||
return {
|
||||
"running": _progress["running"],
|
||||
"total": _progress["total"],
|
||||
"done": _progress["done"],
|
||||
"failed": _progress["failed"],
|
||||
"started_at": _progress["started_at"],
|
||||
"finished_at": _progress["finished_at"],
|
||||
"last_error": _progress["last_error"],
|
||||
}
|
||||
|
||||
|
||||
def _kb_dir() -> Path:
|
||||
return Path(LOCAL_KB_DIR)
|
||||
@@ -29,6 +71,11 @@ def _embedding_model() -> str:
|
||||
return os.environ.get("LOCAL_KB_EMBEDDING_MODEL", "text-embedding-3-small")
|
||||
|
||||
|
||||
def current_embedding_model() -> str:
|
||||
"""いま使われている埋め込みモデル名。"""
|
||||
return _embedding_model()
|
||||
|
||||
|
||||
def _hash(content: str) -> str:
|
||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
|
||||
@@ -89,46 +136,75 @@ def backfill_search_results(db: DBSession) -> int:
|
||||
return count
|
||||
|
||||
|
||||
async def sync_local_kb(db: DBSession) -> None:
|
||||
"""ナレッジベースディレクトリをスキャンし、新規/変更ファイルの埋め込みを更新する。
|
||||
削除されたファイルに対応するレコードも削除する。
|
||||
async def sync_local_kb(db: DBSession, force: bool = False) -> None:
|
||||
"""ナレッジベースディレクトリをスキャンし、埋め込みが古いファイルだけ計算し直す。
|
||||
|
||||
埋め込みを取り直すのは、ファイル内容が変わったか、埋め込みモデルが変わったとき
|
||||
(次元数の違うベクトルを比べても意味のあるスコアにならないため)。force=True なら全件やり直す。
|
||||
削除されたファイルに対応するレコードも消す。
|
||||
進捗は _progress に載せてGUIから見えるようにする。
|
||||
"""
|
||||
kb_dir = _kb_dir()
|
||||
if not kb_dir.is_dir():
|
||||
return
|
||||
|
||||
# 再埋め込みが走っている最中は、チャット毎の同期は譲って何もしない
|
||||
# (同じファイルを二重に埋め込むのを避けるため。ここまでawaitを挟まないので取りこぼしはない)。
|
||||
# force=True で呼ばれるのは start_reembed_all() が実行中フラグを立てた本人だけなので素通しする。
|
||||
if _progress["running"] and not force:
|
||||
return
|
||||
|
||||
model = _embedding_model()
|
||||
md_files = sorted(kb_dir.rglob("*.md"))
|
||||
seen_paths: set[str] = set()
|
||||
|
||||
# 先に対象を洗い出す。1件も無ければ進捗を触らない(毎リクエストの同期で履歴が消えないように)
|
||||
pending: list[tuple[Path, str, str, LocalKnowledgeDoc | None]] = []
|
||||
for file_path in md_files:
|
||||
rel_path = str(file_path.relative_to(kb_dir))
|
||||
seen_paths.add(rel_path)
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
content = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
content_hash = _hash(content)
|
||||
|
||||
existing = db.query(LocalKnowledgeDoc).filter(LocalKnowledgeDoc.path == rel_path).first()
|
||||
if existing and existing.content_hash == content_hash:
|
||||
up_to_date = (
|
||||
existing is not None
|
||||
and existing.content_hash == content_hash
|
||||
and existing.embedding_model == model
|
||||
)
|
||||
if up_to_date and not force:
|
||||
continue
|
||||
pending.append((file_path, rel_path, content, existing))
|
||||
|
||||
if pending:
|
||||
_start_progress(model, len(pending))
|
||||
try:
|
||||
[embedding] = await llm.embed([content], model=_embedding_model())
|
||||
except Exception:
|
||||
logger.exception("Failed to embed local knowledge file: %s", rel_path)
|
||||
continue
|
||||
for file_path, rel_path, content, existing in pending:
|
||||
try:
|
||||
[embedding] = await llm.embed([content], model=model)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to embed local knowledge file: %s", rel_path)
|
||||
_progress["failed"] += 1
|
||||
_progress["last_error"] = f"{rel_path}: {e}"[:500]
|
||||
continue
|
||||
|
||||
title = _title_from_content(content, fallback=file_path.stem)
|
||||
if existing:
|
||||
existing.content_hash = content_hash
|
||||
existing.title = title
|
||||
existing.embedding_json = json.dumps(embedding)
|
||||
else:
|
||||
db.add(LocalKnowledgeDoc(
|
||||
path=rel_path,
|
||||
title=title,
|
||||
content_hash=content_hash,
|
||||
embedding_json=json.dumps(embedding),
|
||||
))
|
||||
db.commit()
|
||||
title = _title_from_content(content, fallback=file_path.stem)
|
||||
if existing:
|
||||
existing.content_hash = _hash(content)
|
||||
existing.title = title
|
||||
existing.embedding_json = json.dumps(embedding)
|
||||
existing.embedding_model = model
|
||||
else:
|
||||
db.add(LocalKnowledgeDoc(
|
||||
path=rel_path,
|
||||
title=title,
|
||||
content_hash=_hash(content),
|
||||
embedding_json=json.dumps(embedding),
|
||||
embedding_model=model,
|
||||
))
|
||||
db.commit()
|
||||
_progress["done"] += 1
|
||||
finally:
|
||||
_finish_progress()
|
||||
|
||||
stale = db.query(LocalKnowledgeDoc).filter(LocalKnowledgeDoc.path.notin_(seen_paths)).all()
|
||||
for doc in stale:
|
||||
@@ -137,6 +213,85 @@ async def sync_local_kb(db: DBSession) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
async def reembed_all() -> None:
|
||||
"""全ファイルを現在のモデルで埋め込み直す。バックグラウンドタスクから呼ぶ想定で、DBセッションは自前で持つ。"""
|
||||
from app.db.database import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
await sync_local_kb(db, force=True)
|
||||
except Exception as e:
|
||||
logger.exception("Re-embedding of the local knowledge base failed")
|
||||
_progress["last_error"] = str(e)[:500]
|
||||
finally:
|
||||
# 対象0件で sync 側が進捗を触らなかった場合でも、実行中フラグは必ず下ろす
|
||||
_finish_progress()
|
||||
db.close()
|
||||
|
||||
|
||||
def start_reembed_all() -> bool:
|
||||
"""再埋め込みをバックグラウンドで開始する。すでに走っていれば False を返して何もしない。"""
|
||||
global _reembed_task
|
||||
if _progress["running"]:
|
||||
return False
|
||||
# GUIは開始リクエストの応答をそのまま状態表示に使うので、タスクが動き出すのを待たず
|
||||
# ここで「実行中」にしておく(総数は実際にスキャンした時点で入る)。
|
||||
_start_progress(_embedding_model(), 0)
|
||||
# create_task の戻り値を保持しないとGCで途中終了することがあるためモジュールに持たせる
|
||||
_reembed_task = asyncio.create_task(reembed_all())
|
||||
return True
|
||||
|
||||
|
||||
def embedding_status(db: DBSession) -> dict:
|
||||
"""GUIの「埋め込み」表示用。現在のモデルと、そのモデルで埋め込み済みの件数を返す。"""
|
||||
model = _embedding_model()
|
||||
kb_dir = _kb_dir()
|
||||
file_count = len(list(kb_dir.rglob("*.md"))) if kb_dir.is_dir() else 0
|
||||
|
||||
rows = db.query(LocalKnowledgeDoc.embedding_model, LocalKnowledgeDoc.embedding_json).all()
|
||||
indexed = 0
|
||||
dimensions = None
|
||||
for row_model, embedding_json in rows:
|
||||
if row_model != model:
|
||||
continue
|
||||
indexed += 1
|
||||
if dimensions is None:
|
||||
dimensions = len(json.loads(embedding_json))
|
||||
|
||||
return {
|
||||
"model": model,
|
||||
"dimensions": dimensions,
|
||||
"file_count": file_count,
|
||||
"indexed": indexed,
|
||||
# 別のモデルで埋め込まれた(=埋め直しが要る)行。モデル不明の古い行も含む
|
||||
"stale": len(rows) - indexed,
|
||||
**_progress_snapshot(),
|
||||
}
|
||||
|
||||
|
||||
async def probe_embedding() -> dict:
|
||||
"""埋め込みモデルが実際に応答するかを短いテキストで確認する。GUIの「接続テスト」用。"""
|
||||
model = _embedding_model()
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
[vector] = await llm.embed(["これは埋め込みモデルの疎通確認です。"], model=model)
|
||||
except Exception as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"model": model,
|
||||
"dimensions": None,
|
||||
"latency_ms": round((time.perf_counter() - start) * 1000, 1),
|
||||
"error": f"{type(e).__name__}: {e}"[:500],
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"model": model,
|
||||
"dimensions": len(vector),
|
||||
"latency_ms": round((time.perf_counter() - start) * 1000, 1),
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
async def get_local_kb_context(db: DBSession, query_text: str) -> str | None:
|
||||
"""直近のユーザー発言に関連するローカルMarkdown知識をシステムプロンプト用テキストにして返す。"""
|
||||
if not query_text.strip():
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import TAVILY_API_KEY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TAVILY_SEARCH_URL = "https://api.tavily.com/search"
|
||||
|
||||
|
||||
def _api_key() -> str | None:
|
||||
"""GUIの設定画面から実行時に変更できるよう、起動時定数ではなく都度 os.environ を見に行く。
|
||||
|
||||
app.core.config の定数は import 時に評価されるため、DBに保存した値を
|
||||
settings_store.load_into_env() が os.environ に入れても反映されない。
|
||||
"""
|
||||
return os.environ.get("TAVILY_API_KEY") or None
|
||||
|
||||
|
||||
WEB_SEARCH_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -27,7 +36,8 @@ WEB_SEARCH_TOOL = {
|
||||
|
||||
async def search_web(query: str, max_results: int = 5) -> list[dict]:
|
||||
"""Tavily APIでWeb検索する。結果は [{title, url, content}, ...]。失敗時は空リスト。"""
|
||||
if not TAVILY_API_KEY:
|
||||
api_key = _api_key()
|
||||
if not api_key:
|
||||
logger.warning("TAVILY_API_KEY is not set; skipping web search")
|
||||
return []
|
||||
|
||||
@@ -36,7 +46,7 @@ async def search_web(query: str, max_results: int = 5) -> list[dict]:
|
||||
response = await client.post(
|
||||
TAVILY_SEARCH_URL,
|
||||
json={
|
||||
"api_key": TAVILY_API_KEY,
|
||||
"api_key": api_key,
|
||||
"query": query,
|
||||
"max_results": max_results,
|
||||
},
|
||||
|
||||
@@ -7,6 +7,11 @@ from app.db.models import AppSetting
|
||||
# GUIから編集可能な環境変数の許可リスト。ここに無いキーは save()/clear() で拒否する。
|
||||
CONFIGURABLE_KEYS = [
|
||||
"OPENAI_API_KEY",
|
||||
# OpenAI 互換エンドポイント。ローカルの llama-server / Ollama を向けたまま忘れると、
|
||||
# OpenAI のモデルを選んでも接続先がそちらのままになる(litellm は OPENAI_API_BASE、
|
||||
# OpenAI SDK は OPENAI_BASE_URL を見るので両方を出しておく)。
|
||||
"OPENAI_BASE_URL",
|
||||
"OPENAI_API_BASE",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
@@ -16,8 +21,20 @@ CONFIGURABLE_KEYS = [
|
||||
"OLLAMA_API_BASE",
|
||||
"TAVILY_API_KEY",
|
||||
"LOCAL_KB_EMBEDDING_MODEL",
|
||||
"SEARCH_SUMMARY_MODEL",
|
||||
]
|
||||
|
||||
# 秘匿情報ではないキー。値をマスクせずそのまま返し、GUIでも通常のテキスト入力として扱う。
|
||||
# モデル名やURLは「今どれが効いているか」が見えないと設定しづらいため。
|
||||
NON_SECRET_KEYS = {
|
||||
"OPENAI_BASE_URL",
|
||||
"OPENAI_API_BASE",
|
||||
"AWS_REGION_NAME",
|
||||
"OLLAMA_API_BASE",
|
||||
"LOCAL_KB_EMBEDDING_MODEL",
|
||||
"SEARCH_SUMMARY_MODEL",
|
||||
}
|
||||
|
||||
# .env由来の起動時の値のスナップショット。clear() で戻す先として使う。
|
||||
_env_defaults: dict[str, str | None] = {}
|
||||
_snapshot_taken = False
|
||||
@@ -47,15 +64,18 @@ def load_into_env(db: DBSession) -> None:
|
||||
|
||||
|
||||
def list_masked(db: DBSession) -> list[dict]:
|
||||
"""GUI設定画面に出す一覧。秘匿キーの値はマスクし、そうでないキーは現在値をそのまま見せる。"""
|
||||
_ensure_snapshot()
|
||||
saved = {row.key: row.value for row in db.query(AppSetting).all()}
|
||||
result = []
|
||||
for key in CONFIGURABLE_KEYS:
|
||||
current = os.environ.get(key)
|
||||
secret = key not in NON_SECRET_KEYS
|
||||
result.append({
|
||||
"key": key,
|
||||
"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,
|
||||
})
|
||||
return result
|
||||
|
||||
@@ -24,6 +24,7 @@ _ADDED_COLUMNS = [
|
||||
("knowledge_entries", "headline", "VARCHAR"),
|
||||
("messages", "archived_at", "DATETIME"),
|
||||
("messages", "memory_id", "INTEGER"),
|
||||
("local_knowledge_docs", "embedding_model", "VARCHAR"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ class LocalKnowledgeDoc(Base):
|
||||
"""backend/knowledge_base/ 配下のMarkdownファイルとその埋め込みベクトル。
|
||||
|
||||
話題に応じて関連ファイルをシステムプロンプトに注入するために使う。
|
||||
ファイル内容のハッシュが変わった時だけ埋め込みを再計算する。
|
||||
ファイル内容のハッシュか埋め込みモデルが変わった時だけ埋め込みを再計算する。
|
||||
"""
|
||||
__tablename__ = "local_knowledge_docs"
|
||||
|
||||
@@ -113,6 +113,10 @@ class LocalKnowledgeDoc(Base):
|
||||
title: Mapped[str] = mapped_column(String)
|
||||
content_hash: Mapped[str] = mapped_column(String)
|
||||
embedding_json: Mapped[str] = mapped_column(Text)
|
||||
# この埋め込みを計算したモデル名。モデルを切り替えた行を見分けて埋め直すために持つ
|
||||
# (次元数の違うベクトル同士を比較しても意味のないスコアしか出ないため)。
|
||||
# このカラムより前に作られた行は NULL で、モデル不明として埋め直し対象になる。
|
||||
embedding_model: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
|
||||
+40
@@ -306,6 +306,46 @@ attachment; filename="______.md"; filename*=UTF-8''%E8%B2%B7%E3%81%84%E7%89%A9%E
|
||||
|
||||
---
|
||||
|
||||
### GET /api/kb/embedding
|
||||
埋め込みモデルの現状と、再埋め込みの進捗を返す。
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "ollama/nomic-embed-text",
|
||||
"dimensions": 768,
|
||||
"file_count": 36,
|
||||
"indexed": 36,
|
||||
"stale": 0,
|
||||
"running": false,
|
||||
"total": 36,
|
||||
"done": 36,
|
||||
"failed": 0,
|
||||
"started_at": "2026-08-23T13:50:23.196274Z",
|
||||
"finished_at": "2026-08-23T13:50:29.951099Z",
|
||||
"last_error": null
|
||||
}
|
||||
```
|
||||
|
||||
`indexed` は**現在のモデルで**埋め込まれている件数、`stale` は別モデル(またはモデル不明)のまま残っている件数。
|
||||
`running` 以下は実行中の進捗で、プロセス内のメモリにしか無い(再起動で消える。ワーカーを複数立てるとワーカーごとに別の値になる)。
|
||||
|
||||
---
|
||||
|
||||
### POST /api/kb/embedding/test
|
||||
短いテキストを実際に 1 回埋め込んでみて、モデルが応答するかを確かめる。失敗しても 200 で返し、`error` に理由が入る。
|
||||
|
||||
```json
|
||||
{ "ok": true, "model": "ollama/nomic-embed-text", "dimensions": 768, "latency_ms": 412.3, "error": null }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /api/kb/embedding/reindex
|
||||
全ファイルの再埋め込みをバックグラウンドで開始し、開始直後の状態(`running: true`)を返す。
|
||||
すでに実行中なら何も起こさず現在の進捗を返す。
|
||||
|
||||
---
|
||||
|
||||
## ログ
|
||||
|
||||
### GET /logs
|
||||
|
||||
@@ -65,7 +65,7 @@ backend/
|
||||
| `memory_tags` | id, memory_id, tag, created_at | 要約への自動タグ |
|
||||
| `knowledge_entries` | id, query, summary, created_at | Web 検索で得た知識(セッション横断) |
|
||||
| `knowledge_sources` | id, entry_id, title, url | 上記知識の出典 URL |
|
||||
| `local_knowledge_docs` | id, path, title, content_hash, embedding_json, updated_at | ローカル Markdown KB の埋め込みキャッシュ |
|
||||
| `local_knowledge_docs` | id, path, title, content_hash, embedding_json, embedding_model, updated_at | ローカル Markdown KB の埋め込みキャッシュ(`embedding_model` が現在の設定と違う行は埋め直す) |
|
||||
| `note_pages` | id, title, content, position, section, created_at, updated_at | ユーザーと AI が共同編集する共有ノートのページ(`section` でタブをグループ化) |
|
||||
| `session_note_pages` | id, session_id, page_id, created_at | 「このセッションではこのページの本文をプロンプトに載せる」指定(session_id × page_id は一意) |
|
||||
| `app_settings` | key, value, updated_at | GUI 設定画面から編集した接続情報 |
|
||||
|
||||
@@ -113,6 +113,11 @@ nano backend/.env # APIキーやモデル名など本番用の値を設定
|
||||
|
||||
`backend/.env` はコンテナに `env_file` として渡され、Git 管理対象外(`.gitignore` 済み)なので直接編集してよい。
|
||||
|
||||
**ローカルの `.env` をそのまま持ち込むときの注意:** `OPENAI_BASE_URL` / `OPENAI_API_BASE` を
|
||||
ローカルの llama-server や Ollama に向けていると、サーバー上では存在しない宛先に接続しに行くため、
|
||||
`gpt-*` のモデルを選んでも litellm が「Connection Error」になる。サーバーの `.env` からは消しておくこと
|
||||
(設定画面の「OpenAI 互換エンドポイント」からも上書きできる)。
|
||||
|
||||
### GUIからのAPIキー管理
|
||||
|
||||
初回起動後は、`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / AWS認証情報などをSSHで`.env`を編集し直さなくても、
|
||||
|
||||
+23
-6
@@ -28,7 +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` |
|
||||
| 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` |
|
||||
@@ -94,7 +94,7 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
|
||||
## 5. Web 検索と知識の蓄積
|
||||
|
||||
- LLM が必要と判断したときだけ `web_search` ツールが走る(Tavily。`TAVILY_API_KEY` 未設定なら空振り)。
|
||||
- 結果は `SEARCH_SUMMARY_MODEL` で構造化要約にされ、**2 か所**に保存される。
|
||||
- 結果は `SEARCH_SUMMARY_MODEL`(未設定なら `DEFAULT_MODEL`。設定画面から変更可)で構造化要約にされ、**2 か所**に保存される。
|
||||
- `knowledge_entries` / `knowledge_sources` テーブル → 次回以降、層 1 として全セッションに効く
|
||||
- `knowledge_base/web_search/*.md` → 層 3 の類似度検索の対象になる
|
||||
- `POST /chat/knowledge/regenerate` で、既存の知識エントリを出典 URL から取り直して要約し直せる。
|
||||
@@ -103,9 +103,15 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
|
||||
|
||||
- `backend/knowledge_base/**.md` を置いておくだけで、話題が近いときに自動で全文が注入される。
|
||||
- 直近のユーザー発言との埋め込みコサイン類似度で選ぶ。埋め込みは `local_knowledge_docs` に
|
||||
内容ハッシュ付きでキャッシュされ、ファイルが変わったときだけ取り直す。
|
||||
内容ハッシュと**使用した埋め込みモデル名**付きでキャッシュされ、
|
||||
ファイルが変わったときと、埋め込みモデルが変わったときだけ取り直す。
|
||||
- 設定画面で `LOCAL_KB_EMBEDDING_MODEL` を変更すると、保存した時点でバックグラウンドの再埋め込みが走る
|
||||
(モデルが違えば次元も意味空間も変わり、古いベクトルは比較しても無意味なため)。
|
||||
パネルの「埋め直し」ボタンからも手動で開始できる。
|
||||
- プロンプト上は「Web 検索結果より優先。これで答えられるなら検索するな」と明示している。
|
||||
- ヘッダーのパネルメニュー →「ナレッジベース」で開くサイドパネル(`KnowledgeBasePanel.vue`)から中身を閲覧できる。
|
||||
パネル上部には埋め込みの状態(モデル名・次元数・埋め込み済み件数・進捗バー)が出る。
|
||||
「接続テスト」は短いテキストを 1 回埋め込んでみて、モデルが応答するかその場で確かめる。
|
||||
フォルダ単位のグループ表示・折りたたみ、タイトル/パス/本文先頭での絞り込み、Markdown 描画に対応。
|
||||
各ファイルの緑の点は埋め込み済み(=自動注入の対象)であることを示す。閲覧専用で、編集はできない。
|
||||
|
||||
@@ -158,10 +164,21 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
|
||||
- モデルのプルダウンは `model_presets` テーブル由来で、初回起動時に既定の一覧がシードされる。
|
||||
設定画面からラベル/モデル文字列を追加・編集・削除できる(重複は 400 で弾く)。
|
||||
- 設定画面では API キー類を**再起動なしで**変更できる。
|
||||
- 変更できるのは許可リストにあるキーだけ(`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / AWS 系 /
|
||||
`OLLAMA_API_BASE` / `TAVILY_API_KEY` / `LOCAL_KB_EMBEDDING_MODEL`)
|
||||
- 値は末尾 4 文字だけを見せるマスク表示。空欄で保存すると「変更なし」扱い
|
||||
- 変更できるのは許可リストにあるキーだけ(`OPENAI_API_KEY` / `OPENAI_BASE_URL` / `OPENAI_API_BASE` /
|
||||
`ANTHROPIC_API_KEY` / AWS 系 / `OLLAMA_API_BASE` / `TAVILY_API_KEY` /
|
||||
`LOCAL_KB_EMBEDDING_MODEL` / `SEARCH_SUMMARY_MODEL`)
|
||||
- `OPENAI_BASE_URL` / `OPENAI_API_BASE` は OpenAI 互換エンドポイント(ローカルの llama-server や Ollama)
|
||||
に向けるためのもの。向けたままだと `gpt-*` のモデルを選んでも本家 OpenAI には繋がらず、
|
||||
litellm からは「Connection Error」に見える
|
||||
- API キー類は末尾 4 文字だけを見せるマスク表示。モデル名や URL(`settings_store.NON_SECRET_KEYS`)は
|
||||
現在値をそのまま表示し、入力欄も伏せ字にしない(今どれが効いているか分からないと設定しづらいため)
|
||||
- 空欄で保存すると「変更なし」扱い
|
||||
- 保存すると `app_settings` テーブルに入り `os.environ` に反映される。削除すると `.env` 由来の起動時の値に戻る
|
||||
- 保存した値は `backend_data` ボリュームに残るため、コンテナを作り直しても消えない。
|
||||
起動時に `load_into_env()` が `os.environ` へ流し込むので、`.env` の同名の値より優先される
|
||||
- **アプリ側は `config.py` の定数ではなく `os.environ` を都度読むこと**。定数は import 時に評価され、
|
||||
`load_into_env()` より前に確定してしまうため、GUI で保存した値が反映されない
|
||||
(`websearch._api_key()` / `knowledge._summary_model()` / `local_knowledge._embedding_model()` がその形)
|
||||
|
||||
## 15-16. ログビューアとシステムモニタ
|
||||
|
||||
|
||||
@@ -176,7 +176,10 @@ export async function logout(): Promise<void> {
|
||||
export interface SettingEntry {
|
||||
key: string
|
||||
is_set: boolean
|
||||
masked: string | null
|
||||
/** 秘匿キーはマスク済みの値、それ以外は現在値そのもの。未設定なら null。 */
|
||||
display_value: string | null
|
||||
/** APIキーなど、入力欄をパスワード扱いにするキーか。 */
|
||||
secret: boolean
|
||||
overridden: boolean
|
||||
}
|
||||
|
||||
@@ -428,3 +431,46 @@ export async function fetchKbFile(path: string): Promise<KbFileContent> {
|
||||
const res = await fetch(`${BASE}/api/kb/file?path=${encodeURIComponent(path)}`)
|
||||
return parseJson<KbFileContent>(res, 'ナレッジベースファイル取得')
|
||||
}
|
||||
|
||||
/** ローカルナレッジベースの埋め込み状況。GUI の状態表示・進捗バーに使う。 */
|
||||
export interface KbEmbeddingStatus {
|
||||
model: string
|
||||
/** 現在のモデルで埋め込まれたベクトルの次元数(1件も無ければ null)。 */
|
||||
dimensions: number | null
|
||||
file_count: number
|
||||
indexed: number
|
||||
/** 別のモデルで埋め込まれていて埋め直しが必要な件数。 */
|
||||
stale: number
|
||||
running: boolean
|
||||
total: number
|
||||
done: number
|
||||
failed: number
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
last_error: string | null
|
||||
}
|
||||
|
||||
/** 埋め込みモデルに実際に1回リクエストしてみた結果。 */
|
||||
export interface KbEmbeddingProbe {
|
||||
ok: boolean
|
||||
model: string
|
||||
dimensions: number | null
|
||||
latency_ms: number
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export async function fetchKbEmbeddingStatus(): Promise<KbEmbeddingStatus> {
|
||||
const res = await fetch(`${BASE}/api/kb/embedding`)
|
||||
return parseJson<KbEmbeddingStatus>(res, '埋め込み状態取得')
|
||||
}
|
||||
|
||||
export async function testKbEmbedding(): Promise<KbEmbeddingProbe> {
|
||||
const res = await fetch(`${BASE}/api/kb/embedding/test`, { method: 'POST' })
|
||||
return parseJson<KbEmbeddingProbe>(res, '埋め込みテスト')
|
||||
}
|
||||
|
||||
/** 全ファイルの再埋め込みをバックグラウンドで開始する。戻り値は開始直後の状態。 */
|
||||
export async function reindexKbEmbeddings(): Promise<KbEmbeddingStatus> {
|
||||
const res = await fetch(`${BASE}/api/kb/embedding/reindex`, { method: 'POST' })
|
||||
return parseJson<KbEmbeddingStatus>(res, '再埋め込み開始')
|
||||
}
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import MarkdownContent from './MarkdownContent.vue'
|
||||
import { useKnowledgeBaseStore } from '../stores/knowledgeBase'
|
||||
|
||||
const store = useKnowledgeBaseStore()
|
||||
|
||||
onMounted(() => store.load())
|
||||
onMounted(() => {
|
||||
store.load()
|
||||
store.loadEmbedding()
|
||||
})
|
||||
// パネルを閉じている間もポーリングが回り続けないように止める
|
||||
onUnmounted(() => store.stopPolling())
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
const e = store.embedding
|
||||
if (!e || e.total === 0) return 0
|
||||
return Math.round(((e.done + e.failed) / e.total) * 100)
|
||||
})
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
return bytes < 1024 ? `${bytes} B` : `${Math.round(bytes / 1024)} KB`
|
||||
@@ -41,6 +52,54 @@ function formatDate(iso: string): string {
|
||||
|
||||
<!-- 一覧 -->
|
||||
<template v-else>
|
||||
<div v-if="store.embedding" class="embedding">
|
||||
<div class="embedding-head">
|
||||
<span class="embedding-model" :title="`埋め込みモデル: ${store.embedding.model}`">
|
||||
🧠 {{ store.embedding.model }}
|
||||
</span>
|
||||
<button class="mini-btn" :disabled="store.testing" title="このモデルに実際に1回リクエストして応答を確かめる"
|
||||
@click="store.testEmbedding()">
|
||||
{{ store.testing ? 'テスト中…' : '接続テスト' }}
|
||||
</button>
|
||||
<button class="mini-btn" :disabled="store.embedding.running" title="全ファイルを現在のモデルで埋め込み直す"
|
||||
@click="store.reindex()">
|
||||
埋め直し
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="embedding-stats">
|
||||
<span :class="{ warn: store.embedding.indexed < store.embedding.file_count }">
|
||||
埋め込み済み {{ store.embedding.indexed }} / {{ store.embedding.file_count }}
|
||||
</span>
|
||||
<span v-if="store.embedding.stale > 0" class="warn">
|
||||
・別モデルのまま {{ store.embedding.stale }}
|
||||
</span>
|
||||
<span v-if="store.embedding.dimensions">・{{ store.embedding.dimensions }} 次元</span>
|
||||
</div>
|
||||
|
||||
<template v-if="store.embedding.running">
|
||||
<div class="progress"><div class="progress-bar" :style="{ width: `${progressPercent}%` }" /></div>
|
||||
<div class="embedding-note">
|
||||
<template v-if="store.embedding.total === 0">準備中…</template>
|
||||
<template v-else>
|
||||
埋め込み中 {{ store.embedding.done + store.embedding.failed }} / {{ store.embedding.total }}
|
||||
<span v-if="store.embedding.failed > 0" class="ng">(失敗 {{ store.embedding.failed }})</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="store.probe" class="probe" :class="store.probe.ok ? 'ok' : 'ng'">
|
||||
<template v-if="store.probe.ok">
|
||||
✅ 応答あり — {{ store.probe.dimensions }} 次元 / {{ Math.round(store.probe.latency_ms) }} ms
|
||||
</template>
|
||||
<template v-else>❌ {{ store.probe.error }}</template>
|
||||
</div>
|
||||
|
||||
<div v-if="store.embedding.last_error" class="probe ng">
|
||||
直近の埋め込みエラー: {{ store.embedding.last_error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search">
|
||||
<input v-model="store.filter" type="search" placeholder="タイトル・パス・本文の先頭で絞り込み">
|
||||
<span class="count">{{ store.filteredFiles.length }} / {{ store.files.length }}</span>
|
||||
@@ -109,6 +168,30 @@ function formatDate(iso: string): string {
|
||||
.back-btn:hover, .reload-btn:hover:not(:disabled) { color: #fff; }
|
||||
.error { padding: 8px 12px; color: #f87171; font-size: 12px; }
|
||||
|
||||
.embedding {
|
||||
padding: 8px 12px; border-bottom: 1px solid #1e1e1e; flex-shrink: 0;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.embedding-head { display: flex; align-items: center; gap: 6px; }
|
||||
.embedding-model {
|
||||
flex: 1; color: #cbd5e1; font-size: 12px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.mini-btn {
|
||||
background: #2a2a2a; color: #aaa; border: 1px solid #444;
|
||||
border-radius: 5px; padding: 2px 8px; font-size: 11px; cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.mini-btn:hover:not(:disabled) { color: #fff; }
|
||||
.mini-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.embedding-stats { color: #6b7280; font-size: 11px; }
|
||||
.embedding-stats .warn { color: #fbbf24; }
|
||||
.embedding-note { color: #6b7280; font-size: 11px; }
|
||||
.progress { height: 4px; background: #262626; border-radius: 2px; overflow: hidden; }
|
||||
.progress-bar { height: 100%; background: #2563eb; transition: width 0.3s; }
|
||||
.probe { font-size: 11px; line-height: 1.5; word-break: break-word; }
|
||||
.probe.ok { color: #4ade80; }
|
||||
.probe.ng, .ng { color: #f87171; }
|
||||
|
||||
.search {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 12px; border-bottom: 1px solid #1e1e1e; flex-shrink: 0;
|
||||
|
||||
@@ -37,6 +37,8 @@ async function addModelPreset() {
|
||||
|
||||
const KEY_LABELS: Record<string, string> = {
|
||||
OPENAI_API_KEY: 'OpenAI APIキー',
|
||||
OPENAI_BASE_URL: 'OpenAI 互換エンドポイント (OPENAI_BASE_URL)',
|
||||
OPENAI_API_BASE: 'OpenAI 互換エンドポイント (OPENAI_API_BASE)',
|
||||
ANTHROPIC_API_KEY: 'Anthropic APIキー',
|
||||
AWS_ACCESS_KEY_ID: 'AWS Access Key ID',
|
||||
AWS_SECRET_ACCESS_KEY: 'AWS Secret Access Key',
|
||||
@@ -46,11 +48,9 @@ const KEY_LABELS: Record<string, string> = {
|
||||
OLLAMA_API_BASE: 'Ollama API Base URL',
|
||||
TAVILY_API_KEY: 'Tavily APIキー',
|
||||
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() {
|
||||
const ok = await store.unlock(passwordInput.value)
|
||||
if (ok) passwordInput.value = ''
|
||||
@@ -106,8 +106,8 @@ onMounted(() => {
|
||||
<div class="row">
|
||||
<input
|
||||
v-model="drafts[entry.key]"
|
||||
:type="PLAIN_TEXT_KEYS.has(entry.key) ? 'text' : 'password'"
|
||||
:placeholder="entry.is_set ? `設定済み ${entry.masked}` : '未設定'"
|
||||
:type="entry.secret ? 'password' : 'text'"
|
||||
:placeholder="entry.is_set ? `現在: ${entry.display_value}` : '未設定'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { fetchKbFile, fetchKbFiles, type KbFileContent, type KbFileInfo } from '../api/client'
|
||||
import {
|
||||
fetchKbEmbeddingStatus,
|
||||
fetchKbFile,
|
||||
fetchKbFiles,
|
||||
reindexKbEmbeddings,
|
||||
testKbEmbedding,
|
||||
type KbEmbeddingProbe,
|
||||
type KbEmbeddingStatus,
|
||||
type KbFileContent,
|
||||
type KbFileInfo,
|
||||
} from '../api/client'
|
||||
|
||||
export interface KbGroup {
|
||||
/** ナレッジベース直下は空文字。 */
|
||||
@@ -18,6 +28,15 @@ export const useKnowledgeBaseStore = defineStore('knowledgeBase', () => {
|
||||
const filter = ref('')
|
||||
/** 折りたたまれているフォルダ名。web_search のようにファイル数が多いフォルダを畳むために使う。 */
|
||||
const collapsed = ref<Set<string>>(new Set())
|
||||
/** 埋め込みモデルの状態と進捗。 */
|
||||
const embedding = ref<KbEmbeddingStatus | null>(null)
|
||||
/** 「接続テスト」の結果。 */
|
||||
const probe = ref<KbEmbeddingProbe | null>(null)
|
||||
const testing = ref(false)
|
||||
|
||||
/** 埋め込み実行中だけ状態をポーリングするタイマー。 */
|
||||
let pollTimer: number | null = null
|
||||
const POLL_INTERVAL_MS = 1500
|
||||
|
||||
const filteredFiles = computed(() => {
|
||||
const q = filter.value.trim().toLowerCase()
|
||||
@@ -66,6 +85,57 @@ export const useKnowledgeBaseStore = defineStore('knowledgeBase', () => {
|
||||
openFile.value = null
|
||||
}
|
||||
|
||||
/** 埋め込みの状態を取り直す。実行中ならポーリングを始め、終わっていれば止める。 */
|
||||
async function loadEmbedding(): Promise<void> {
|
||||
try {
|
||||
embedding.value = await fetchKbEmbeddingStatus()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '埋め込み状態の取得に失敗しました'
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
if (embedding.value.running && pollTimer === null) {
|
||||
pollTimer = window.setInterval(() => void loadEmbedding(), POLL_INTERVAL_MS)
|
||||
} else if (!embedding.value.running && pollTimer !== null) {
|
||||
stopPolling()
|
||||
// 埋め込みが終わったので、一覧の「埋め込み済み」表示も更新する
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
function stopPolling(): void {
|
||||
if (pollTimer !== null) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 埋め込みモデルに実際にリクエストして、応答するか確かめる。 */
|
||||
async function testEmbedding(): Promise<void> {
|
||||
testing.value = true
|
||||
probe.value = null
|
||||
try {
|
||||
probe.value = await testKbEmbedding()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '埋め込みテストに失敗しました'
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 全ファイルの再埋め込みをバックグラウンドで開始する。 */
|
||||
async function reindex(): Promise<void> {
|
||||
error.value = null
|
||||
try {
|
||||
embedding.value = await reindexKbEmbeddings()
|
||||
if (embedding.value.running && pollTimer === null) {
|
||||
pollTimer = window.setInterval(() => void loadEmbedding(), POLL_INTERVAL_MS)
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '再埋め込みの開始に失敗しました'
|
||||
}
|
||||
}
|
||||
|
||||
function toggleGroup(directory: string): void {
|
||||
const next = new Set(collapsed.value)
|
||||
if (next.has(directory)) next.delete(directory)
|
||||
@@ -73,5 +143,25 @@ export const useKnowledgeBaseStore = defineStore('knowledgeBase', () => {
|
||||
collapsed.value = next
|
||||
}
|
||||
|
||||
return { files, openFile, loading, error, filter, collapsed, filteredFiles, groups, load, open, close, toggleGroup }
|
||||
return {
|
||||
files,
|
||||
openFile,
|
||||
loading,
|
||||
error,
|
||||
filter,
|
||||
collapsed,
|
||||
embedding,
|
||||
probe,
|
||||
testing,
|
||||
filteredFiles,
|
||||
groups,
|
||||
load,
|
||||
open,
|
||||
close,
|
||||
toggleGroup,
|
||||
loadEmbedding,
|
||||
stopPolling,
|
||||
testEmbedding,
|
||||
reindex,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user