Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd38cbf19d | ||
|
|
2ced2e3cd5 | ||
|
|
f085db421c | ||
|
|
5d63a59b86 | ||
|
|
8f9458170e | ||
|
|
4882d291be | ||
|
|
6f9fc10fa1 | ||
|
|
0e58a07af4 | ||
|
|
216d38c7bd | ||
|
|
f89bc9bc9a | ||
|
|
366320c43a | ||
|
|
8cac5d0de0 |
@@ -15,6 +15,11 @@
|
|||||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:8000/health --max-time 5)",
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:8000/health --max-time 5)",
|
||||||
"Bash(curl -s -N -X POST http://localhost:8000/chat/stream -H \"Content-Type: application/json\" -d '{\"message\":\"latest news about python 3.14 release\",\"model\":\"bedrock/jp.anthropic.claude-sonnet-4-6\"}' --max-time 60)",
|
"Bash(curl -s -N -X POST http://localhost:8000/chat/stream -H \"Content-Type: application/json\" -d '{\"message\":\"latest news about python 3.14 release\",\"model\":\"bedrock/jp.anthropic.claude-sonnet-4-6\"}' --max-time 60)",
|
||||||
"Bash(curl -s -N -X POST http://localhost:8000/chat/stream -H \"Content-Type: application/json\" -d '{\"message\":\"latest news about python 3.14 release\",\"model\":\"bedrock/jp.anthropic.claude-sonnet-4-6\"}' --max-time 90)"
|
"Bash(curl -s -N -X POST http://localhost:8000/chat/stream -H \"Content-Type: application/json\" -d '{\"message\":\"latest news about python 3.14 release\",\"model\":\"bedrock/jp.anthropic.claude-sonnet-4-6\"}' --max-time 90)"
|
||||||
|
],
|
||||||
|
"deny": [
|
||||||
|
"Read(**/.env)",
|
||||||
|
"Edit(**/.env)",
|
||||||
|
"Bash(*.env)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 検索
|
||||||
@@ -76,13 +76,14 @@ 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 文字のプレビューのみ
|
||||||
3. **ローカル KB** (`memory_kb/local_knowledge.py`): `backend/knowledge_base/**.md` のうち、直近ユーザー発言と埋め込みコサイン類似度が高い上位 `LOCAL_KB_TOP_K`(既定 3)件かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上を**全文**注入。プロンプト上は Web 検索結果より優先する扱い
|
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=`)から閲覧できる(読み取り専用)
|
||||||
4. **長期メモリ**: `memories` の要約を全件連結。上限なし
|
4. **長期メモリ**: `memories` の要約を全件連結。上限なし
|
||||||
5. **短期メモリ**: セッション内の `messages` 全件(要約で削除された分を除く)
|
5. **短期メモリ**: セッション内の `messages` 全件(要約で削除された分を除く)
|
||||||
|
|
||||||
**要約**: `maybe_summarize()` がアシスタント応答の保存直後に走り、メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)以上になると古い半分を LLM で要約して `memories` に保存する。**要約した元メッセージは `archived_at` を立ててアーカイブされる**(物理削除はしない)。アーカイブ済みメッセージは `get_context_messages()` と要約の閾値判定の両方から除外されるため LLM には渡らないが、DB には残り、履歴表示・エクスポートからは参照できる。`memory_id` にどの要約に取り込まれたかを保持する。
|
**要約**: `maybe_summarize()` がアシスタント応答の保存直後に走り、メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)以上になると古い半分を LLM で要約して `memories` に保存する。**要約した元メッセージは `archived_at` を立ててアーカイブされる**(物理削除はしない)。アーカイブ済みメッセージは `get_context_messages()` と要約の閾値判定の両方から除外されるため LLM には渡らないが、DB には残り、履歴表示・エクスポートからは参照できる。`memory_id` にどの要約に取り込まれたかを保持する。
|
||||||
|
|
||||||
**遅延読み込み**: 重い情報は目次だけ注入し、`llm.resolve_tool_calls()` が本番生成の前にツール(`web_search` / `read_note` / `write_note` / `read_knowledge`、最大 2 ラウンド)を解決する。Web 検索の結果は構造化要約にされ `knowledge_entries` と `knowledge_base/web_search/*.md` の両方に保存されるため、次回以降は層 1・層 3 として自動的に効いてくる。
|
**遅延読み込み**: 重い情報は目次だけ注入し、`llm.stream_tool_calls()`(結果だけ欲しい場合は同等のラッパー `resolve_tool_calls()`)が本番生成の前にツール(`web_search` / `read_note` / `write_note` / `read_knowledge`、最大 2 ラウンド)を解決する。前者は実行の開始・完了を逐次 yield し、`api/chat.py` がそれを SSE の `tool` イベントとして中継して GUI に進行状況を出す。Web 検索の結果は構造化要約にされ `knowledge_entries` と `knowledge_base/web_search/*.md` の両方に保存されるため、次回以降は層 1・層 3 として自動的に効いてくる。
|
||||||
|
|
||||||
**付随機能**: `memory_kb/tagging.py` が各メッセージ・要約にバックグラウンドでタグを 3 つ付ける(コンテキストには注入せず、Discord 雑談 Bot の話題選びに使う)。`memory_kb/word_memory.py` は Janome で抽出した名詞を `deque(maxlen=200)` にプロセス内保持する(DB 非永続。起動時に直近 200 件から再シード)。
|
**付随機能**: `memory_kb/tagging.py` が各メッセージ・要約にバックグラウンドでタグを 3 つ付ける(コンテキストには注入せず、Discord 雑談 Bot の話題選びに使う)。`memory_kb/word_memory.py` は Janome で抽出した名詞を `deque(maxlen=200)` にプロセス内保持する(DB 非永続。起動時に直近 200 件から再シード)。
|
||||||
|
|
||||||
@@ -97,16 +98,20 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
|
|||||||
- **ツール実行の流れ**: `llm.resolve_tool_calls()` が本番の応答生成前にツール呼び出しを解決する。`web_search` と共通の仕組みで、ノート系ツールは `db` が渡されたときだけモデルに提示される
|
- **ツール実行の流れ**: `llm.resolve_tool_calls()` が本番の応答生成前にツール呼び出しを解決する。`web_search` と共通の仕組みで、ノート系ツールは `db` が渡されたときだけモデルに提示される
|
||||||
- **UI への反映**: AI がノートを書き換えた場合、SSE で `notes_updated` を送ってフロント側のノートパネルを再読み込みさせる
|
- **UI への反映**: AI がノートを書き換えた場合、SSE で `notes_updated` を送ってフロント側のノートパネルを再読み込みさせる
|
||||||
- **編集の衝突回避**: フロントは未保存の編集があるページ(`dirtyIds`)をサーバーの値で上書きしない
|
- **編集の衝突回避**: フロントは未保存の編集があるページ(`dirtyIds`)をサーバーの値で上書きしない
|
||||||
|
- **チャットへの紐付け**: `session_note_pages`(session_id × page_id)に登録したページは、本文全文がそのセッションのシステムプロンプトに毎回入る。`GET/PUT /chat/sessions/{id}/notes` で読み書きする。新規セッションではフロントがまだ session_id を持てないため、`POST /chat/stream` の `note_page_ids` でも同じ指定を送れ、コンテキスト組み立て前にサーバー側で反映される
|
||||||
|
- **取り込み**: `POST /api/notes/import`(multipart)が `.md` / `.txt` を 1 ファイル 1 ページ、`.zip` を中身ごと複数ページに変換する(ZIP 第 1 階層のフォルダ名がセクション)。タイトル衝突時は `(2)` を足して別ページを作り、対象外・サイズ超過・壊れた ZIP は黙って飛ばす。1 件も取り込めなければ 400。`python-multipart` が必要
|
||||||
|
- **ダウンロード**: `GET /api/notes/{page_id}/export` が 1 ページを Markdown、`GET /api/notes/export` が全ページを ZIP(1 ページ 1 Markdown、セクションはフォルダ、ファイル名の通し番号で `position` 順を保持)で返す。ファイル名はサーバーが決めるのでフロントは `download` 属性を付けず `Content-Disposition`(日本語タイトルのため RFC 5987 を併記)に任せる
|
||||||
- **セクションによるグルーピング**: `note_pages.section` にセクション名を持たせ、同じセクションのページは `position` 上で常に連続させる(`core/notes.set_section()` が割り当て時に並べ替える)。タブバーは `position` 順に描画し、隣接して同じ `section` が続く区間を1グループとして表示する。`get_notes_context()` はセクションごとに `## セクション名` の見出しを付けてタイトル一覧を出す。セクション名の一括変更は `POST /api/notes/sections/rename`(`core/notes.rename_section()`)
|
- **セクションによるグルーピング**: `note_pages.section` にセクション名を持たせ、同じセクションのページは `position` 上で常に連続させる(`core/notes.set_section()` が割り当て時に並べ替える)。タブバーは `position` 順に描画し、隣接して同じ `section` が続く区間を1グループとして表示する。`get_notes_context()` はセクションごとに `## セクション名` の見出しを付けてタイトル一覧を出す。セクション名の一括変更は `POST /api/notes/sections/rename`(`core/notes.rename_section()`)
|
||||||
|
|
||||||
### SSE ストリーミング
|
### SSE ストリーミング
|
||||||
|
|
||||||
`POST /chat/stream` が SSE を返す。イベント種別: `session_id`(新規セッション ID)/ `chunk`(テキスト断片)/ `sources`(Web 検索の出典)/ `notes_updated`(AI が共有ノートを更新)/ `done`(完了)/ `error`(エラー)。
|
`POST /chat/stream` が SSE を返す。イベント種別: `session_id`(新規セッション ID)/ `chunk`(テキスト断片)/ `tool`(ツール実行の開始・完了)/ `sources`(Web 検索の出典)/ `notes_updated`(AI が共有ノートを更新)/ `done`(完了)/ `error`(エラー)。
|
||||||
フロントは `src/api/client.ts` の `streamChat()` で fetch + ReadableStream により受信し、`messages.value[msgIdx]` を reactive proxy 経由で更新する。
|
フロントは `src/api/client.ts` の `streamChat()` で fetch + ReadableStream により受信し、`messages.value[msgIdx]` を reactive proxy 経由で更新する。
|
||||||
|
|
||||||
### Markdown レンダリング
|
### Markdown レンダリング
|
||||||
|
|
||||||
アシスタントメッセージは `marked` + `DOMPurify` でレンダリング。ストリーミング中の未閉じコードフェンスは自動補完してから描画する(`MarkdownContent.vue`)。
|
アシスタントメッセージは `marked` + `DOMPurify` でレンダリング。ストリーミング中の未閉じコードフェンスは自動補完してから描画する(`MarkdownContent.vue`)。
|
||||||
|
数式は `src/utils/math.ts` の marked 拡張が KaTeX で描画する(ブロック `$$...$$` / `\[...\]`、インライン `$...$` / `\(...\)`)。詳細は `docs/architecture.md` を参照。
|
||||||
|
|
||||||
### モデル切替
|
### モデル切替
|
||||||
|
|
||||||
@@ -132,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 設定画面から実行時変更可) |
|
||||||
@@ -144,6 +149,9 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
|
|||||||
| `ANTHROPIC_API_KEY` | — | Anthropic API キー |
|
| `ANTHROPIC_API_KEY` | — | Anthropic API キー |
|
||||||
| `OLLAMA_API_BASE` | `http://localhost:11434` | Ollama エンドポイント |
|
| `OLLAMA_API_BASE` | `http://localhost:11434` | Ollama エンドポイント |
|
||||||
| `NOTES_TOOL_CONTENT_LIMIT` | `4000` | `read_note` が 1 回に返す本文の最大文字数 |
|
| `NOTES_TOOL_CONTENT_LIMIT` | `4000` | `read_note` が 1 回に返す本文の最大文字数 |
|
||||||
|
| `SESSION_NOTES_CONTENT_LIMIT` | `8000` | セッションに紐付けたノート 1 ページあたり、システムプロンプトに載せる本文の最大文字数 |
|
||||||
|
| `NOTES_IMPORT_MAX_PAGES` | `100` | ノート取り込みで一度に作れるページ数の上限 |
|
||||||
|
| `NOTES_IMPORT_MAX_BYTES` | `2000000` | 取り込む 1 ファイル(ZIP 内は 1 エントリ)あたりの最大バイト数 |
|
||||||
| `KNOWLEDGE_TOOL_CONTENT_LIMIT` | `4000` | `read_knowledge` が 1 回に返す本文の最大文字数 |
|
| `KNOWLEDGE_TOOL_CONTENT_LIMIT` | `4000` | `read_knowledge` が 1 回に返す本文の最大文字数 |
|
||||||
| `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) |
|
| `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) |
|
||||||
| `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 |
|
| `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 |
|
||||||
|
|||||||
+54
-2
@@ -12,6 +12,7 @@ from app.core.memory_kb import memory as mem
|
|||||||
from app.core import llm
|
from app.core import llm
|
||||||
from app.core.memory_kb import knowledge
|
from app.core.memory_kb import knowledge
|
||||||
from app.core.memory_kb import local_knowledge
|
from app.core.memory_kb import local_knowledge
|
||||||
|
from app.core import notes
|
||||||
from app.core.notes import NOTE_TOOLS
|
from app.core.notes import NOTE_TOOLS
|
||||||
from app.core.search.websearch import WEB_SEARCH_TOOL
|
from app.core.search.websearch import WEB_SEARCH_TOOL
|
||||||
from app.core.config import DEFAULT_MODEL
|
from app.core.config import DEFAULT_MODEL
|
||||||
@@ -25,6 +26,13 @@ class ChatRequest(BaseModel):
|
|||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
message: str
|
message: str
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
|
# このセッションで本文をプロンプトに載せるノートのページID。
|
||||||
|
# 新規セッションではまだ session_id が無く PUT できないため、送信時にまとめて反映する。
|
||||||
|
note_page_ids: list[int] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SessionNotesRequest(BaseModel):
|
||||||
|
page_ids: list[int]
|
||||||
|
|
||||||
|
|
||||||
class SessionResponse(BaseModel):
|
class SessionResponse(BaseModel):
|
||||||
@@ -161,6 +169,22 @@ def import_session(req: ImportRequest, db: DBSession = Depends(get_db)):
|
|||||||
return session
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_id}/notes")
|
||||||
|
def get_session_notes(session_id: str, db: DBSession = Depends(get_db)):
|
||||||
|
"""このセッションで本文をプロンプトに載せるよう指定された共有ノートのページIDを返す。"""
|
||||||
|
if not db.get(Session, session_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
return {"page_ids": notes.get_attached_page_ids(db, session_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/sessions/{session_id}/notes")
|
||||||
|
def put_session_notes(session_id: str, req: SessionNotesRequest, db: DBSession = Depends(get_db)):
|
||||||
|
"""このセッションに紐付けるノートのページIDを丸ごと置き換える。存在しないIDは無視する。"""
|
||||||
|
if not db.get(Session, session_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
return {"page_ids": notes.set_attached_pages(db, session_id, req.page_ids)}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/sessions/{session_id}", status_code=204)
|
@router.delete("/sessions/{session_id}", status_code=204)
|
||||||
def delete_session(session_id: str, db: DBSession = Depends(get_db)):
|
def delete_session(session_id: str, db: DBSession = Depends(get_db)):
|
||||||
session = db.get(Session, session_id)
|
session = db.get(Session, session_id)
|
||||||
@@ -202,6 +226,11 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
|
|||||||
record_message(req.message)
|
record_message(req.message)
|
||||||
asyncio.create_task(tag_message_by_id(user_msg.id))
|
asyncio.create_task(tag_message_by_id(user_msg.id))
|
||||||
|
|
||||||
|
# 紐付けるノートは送信のたびに送られてくる。新規セッションだとフロントは事前に
|
||||||
|
# PUT できないため、コンテキストを組み立てる前にここで反映しておく。
|
||||||
|
if req.note_page_ids is not None:
|
||||||
|
notes.set_attached_pages(db, session.id, req.note_page_ids)
|
||||||
|
|
||||||
context = await mem.get_context_messages(db, session.id)
|
context = await mem.get_context_messages(db, session.id)
|
||||||
|
|
||||||
async def generate():
|
async def generate():
|
||||||
@@ -216,9 +245,18 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
|
|||||||
used_model = req.model or session.model
|
used_model = req.model or session.model
|
||||||
full_response = []
|
full_response = []
|
||||||
try:
|
try:
|
||||||
resolved_context, searches, notes_changed = await llm.resolve_tool_calls(
|
# ツールの実行状況は tool イベントとしてそのままフロントへ流し、GUIに進行状況を出す
|
||||||
|
resolved_context, searches, notes_changed = context, [], False
|
||||||
|
async for event in llm.stream_tool_calls(
|
||||||
context, model=used_model, session_id=session.id, db=db,
|
context, model=used_model, session_id=session.id, db=db,
|
||||||
)
|
):
|
||||||
|
if event["type"] == "resolved":
|
||||||
|
resolved_context = event["messages"]
|
||||||
|
searches = event["searches"]
|
||||||
|
notes_changed = event["notes_changed"]
|
||||||
|
else:
|
||||||
|
yield sse({"type": "tool", "value": {k: v for k, v in event.items() if k != "type"}})
|
||||||
|
|
||||||
if notes_changed:
|
if notes_changed:
|
||||||
# AIがノートを書き換えたので、フロントのノートパネルを再読み込みさせる
|
# AIがノートを書き換えたので、フロントのノートパネルを再読み込みさせる
|
||||||
yield sse({"type": "notes_updated"})
|
yield sse({"type": "notes_updated"})
|
||||||
@@ -228,11 +266,25 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
|
|||||||
for s in searches for r in s["results"]
|
for s in searches for r in s["results"]
|
||||||
]
|
]
|
||||||
yield sse({"type": "sources", "value": sources})
|
yield sse({"type": "sources", "value": sources})
|
||||||
|
yield sse({"type": "tool", "value": {
|
||||||
|
"id": "save_knowledge",
|
||||||
|
"name": "save_knowledge",
|
||||||
|
"label": "検索結果を要約して保存",
|
||||||
|
"detail": " / ".join(s["query"] for s in searches),
|
||||||
|
"status": "running",
|
||||||
|
}})
|
||||||
|
saved = 0
|
||||||
for s in searches:
|
for s in searches:
|
||||||
headline, summary = await knowledge.summarize_results(s["query"], s["results"])
|
headline, summary = await knowledge.summarize_results(s["query"], s["results"])
|
||||||
if summary:
|
if summary:
|
||||||
knowledge.save_knowledge(db, s["query"], summary, s["results"], headline=headline)
|
knowledge.save_knowledge(db, s["query"], summary, s["results"], headline=headline)
|
||||||
local_knowledge.save_search_result(s["query"], summary, s["results"])
|
local_knowledge.save_search_result(s["query"], summary, s["results"])
|
||||||
|
saved += 1
|
||||||
|
yield sse({"type": "tool", "value": {
|
||||||
|
"id": "save_knowledge",
|
||||||
|
"status": "done",
|
||||||
|
"result": f"{saved}件を蓄積知識に保存" if saved else "保存できる要約なし",
|
||||||
|
}})
|
||||||
|
|
||||||
# 履歴にtool_callsが含まれる場合、最終呼び出しにも同じツール定義を渡す必要がある
|
# 履歴にtool_callsが含まれる場合、最終呼び出しにも同じツール定義を渡す必要がある
|
||||||
used_tools = [WEB_SEARCH_TOOL, *NOTE_TOOLS] if len(resolved_context) > len(context) else None
|
used_tools = [WEB_SEARCH_TOOL, *NOTE_TOOLS] if len(resolved_context) > len(context) else None
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
from datetime import datetime
|
import io
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||||
|
from fastapi.responses import Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session as DBSession
|
from sqlalchemy.orm import Session as DBSession
|
||||||
|
|
||||||
from app.core.notes import next_position, reorder, rename_section, set_section
|
from app.core.notes import import_files, next_position, reorder, rename_section, set_section
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import NotePage
|
from app.db.models import NotePage
|
||||||
|
|
||||||
@@ -46,11 +50,73 @@ class ReorderRequest(BaseModel):
|
|||||||
order: list[int]
|
order: list[int]
|
||||||
|
|
||||||
|
|
||||||
|
# ファイル名に使えない文字(Windows の禁止文字。制御文字は _safe_filename 側で弾く)
|
||||||
|
_UNSAFE_FILENAME_CHARS = '\\/:*?"<>|'
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename(name: str, fallback: str) -> str:
|
||||||
|
"""タイトルやセクション名をファイル名に使える形へ落とす。空になったら fallback を使う。"""
|
||||||
|
cleaned = "".join("_" if c in _UNSAFE_FILENAME_CHARS or ord(c) < 32 else c for c in name)
|
||||||
|
cleaned = cleaned.strip().strip(".")
|
||||||
|
return cleaned[:80] or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _attachment_headers(filename: str) -> dict[str, str]:
|
||||||
|
"""日本語のファイル名は Content-Disposition にそのまま置けないため RFC 5987 形式を併記する。"""
|
||||||
|
ascii_fallback = filename.encode("ascii", "replace").decode("ascii").replace("?", "_")
|
||||||
|
return {
|
||||||
|
"Content-Disposition": (
|
||||||
|
f'attachment; filename="{ascii_fallback}"; '
|
||||||
|
f"filename*=UTF-8''{quote(filename, safe='')}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[NotePageResponse])
|
@router.get("", response_model=list[NotePageResponse])
|
||||||
def list_pages(db: DBSession = Depends(get_db)):
|
def list_pages(db: DBSession = Depends(get_db)):
|
||||||
return db.query(NotePage).order_by(NotePage.position, NotePage.id).all()
|
return db.query(NotePage).order_by(NotePage.position, NotePage.id).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export")
|
||||||
|
def export_all_pages(db: DBSession = Depends(get_db)):
|
||||||
|
"""全ページを 1ページ1ファイルの Markdown にして ZIP でダウンロードさせる。
|
||||||
|
|
||||||
|
本文はそのまま書き出す(1つのファイルに連結すると、ページ内の見出しの階層が
|
||||||
|
ページ間の見出しと混ざってしまうため)。タブの並び順を残すためファイル名には
|
||||||
|
通し番号を付け、セクションはフォルダにする。
|
||||||
|
"""
|
||||||
|
pages = db.query(NotePage).order_by(NotePage.position, NotePage.id).all()
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
for i, page in enumerate(pages, start=1):
|
||||||
|
entry = f"{i:02d}_{_safe_filename(page.title, f'page_{page.id}')}.md"
|
||||||
|
if page.section:
|
||||||
|
entry = f"{_safe_filename(page.section, 'section')}/{entry}"
|
||||||
|
archive.writestr(entry, page.content)
|
||||||
|
|
||||||
|
filename = f"notes_{datetime.now(timezone.utc).strftime('%Y%m%d')}.zip"
|
||||||
|
return Response(
|
||||||
|
content=buffer.getvalue(),
|
||||||
|
media_type="application/zip",
|
||||||
|
headers=_attachment_headers(filename),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{page_id}/export")
|
||||||
|
def export_page(page_id: int, db: DBSession = Depends(get_db)):
|
||||||
|
"""1ページを Markdown ファイルとしてダウンロードさせる。本文はそのまま、ファイル名がタイトルになる。"""
|
||||||
|
page = db.get(NotePage, page_id)
|
||||||
|
if not page:
|
||||||
|
raise HTTPException(status_code=404, detail="Note page not found")
|
||||||
|
filename = f"{_safe_filename(page.title, f'page_{page.id}')}.md"
|
||||||
|
return Response(
|
||||||
|
content=page.content,
|
||||||
|
media_type="text/markdown; charset=utf-8",
|
||||||
|
headers=_attachment_headers(filename),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=NotePageResponse, status_code=201)
|
@router.post("", response_model=NotePageResponse, status_code=201)
|
||||||
def create_page(req: NotePageCreate, db: DBSession = Depends(get_db)):
|
def create_page(req: NotePageCreate, db: DBSession = Depends(get_db)):
|
||||||
title = req.title.strip() or "新しいページ"
|
title = req.title.strip() or "新しいページ"
|
||||||
@@ -63,6 +129,24 @@ def create_page(req: NotePageCreate, db: DBSession = Depends(get_db)):
|
|||||||
return page
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import", response_model=list[NotePageResponse], status_code=201)
|
||||||
|
async def import_pages(files: list[UploadFile] = File(...), db: DBSession = Depends(get_db)):
|
||||||
|
"""アップロードされたファイルをページとして取り込む。
|
||||||
|
|
||||||
|
.md / .txt はそのまま1ページ、.zip は中のテキストファイルを1つずつページにする
|
||||||
|
(ZIP内のフォルダ名がセクションになるので、エクスポートしたZIPをそのまま戻せる)。
|
||||||
|
対象外の拡張子や大きすぎるファイルは黙って飛ばすので、1つも取り込めなければ 400。
|
||||||
|
"""
|
||||||
|
payload = [(f.filename or "無題", await f.read()) for f in files]
|
||||||
|
created = import_files(db, payload)
|
||||||
|
if not created:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="取り込めるファイルがありませんでした(.md / .txt / .zip のみ)",
|
||||||
|
)
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sections/rename")
|
@router.post("/sections/rename")
|
||||||
def rename_section_endpoint(req: SectionRenameRequest, db: DBSession = Depends(get_db)):
|
def rename_section_endpoint(req: SectionRenameRequest, db: DBSession = Depends(get_db)):
|
||||||
"""old_name セクションに属する全ページを new_name セクションへ一括で付け替える。new_name が空ならグループ解除。"""
|
"""old_name セクションに属する全ページを new_name セクションへ一括で付け替える。new_name が空ならグループ解除。"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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ナレッジベース(話題に応じて関連ファイルをシステムプロンプトに注入)
|
||||||
@@ -41,6 +42,13 @@ LOCAL_KB_MIN_SCORE: float = float(os.getenv("LOCAL_KB_MIN_SCORE", "0.75"))
|
|||||||
# read_note ツールが1回に返す本文の最大文字数
|
# read_note ツールが1回に返す本文の最大文字数
|
||||||
NOTES_TOOL_CONTENT_LIMIT: int = int(os.getenv("NOTES_TOOL_CONTENT_LIMIT", "4000"))
|
NOTES_TOOL_CONTENT_LIMIT: int = int(os.getenv("NOTES_TOOL_CONTENT_LIMIT", "4000"))
|
||||||
|
|
||||||
|
# アップロードで一度に取り込めるページ数と、1ファイル(ZIP内は1エントリ)あたりの最大バイト数
|
||||||
|
NOTES_IMPORT_MAX_PAGES: int = int(os.getenv("NOTES_IMPORT_MAX_PAGES", "100"))
|
||||||
|
NOTES_IMPORT_MAX_BYTES: int = int(os.getenv("NOTES_IMPORT_MAX_BYTES", "2000000"))
|
||||||
|
|
||||||
|
# セッションに紐付けたページ1枚あたり、システムプロンプトに載せる本文の最大文字数
|
||||||
|
SESSION_NOTES_CONTENT_LIMIT: int = int(os.getenv("SESSION_NOTES_CONTENT_LIMIT", "8000"))
|
||||||
|
|
||||||
# read_knowledge ツールが1回に返す蓄積知識の本文の最大文字数
|
# read_knowledge ツールが1回に返す蓄積知識の本文の最大文字数
|
||||||
KNOWLEDGE_TOOL_CONTENT_LIMIT: int = int(os.getenv("KNOWLEDGE_TOOL_CONTENT_LIMIT", "4000"))
|
KNOWLEDGE_TOOL_CONTENT_LIMIT: int = int(os.getenv("KNOWLEDGE_TOOL_CONTENT_LIMIT", "4000"))
|
||||||
|
|
||||||
|
|||||||
+72
-6
@@ -107,19 +107,45 @@ async def embed(texts: list[str], model: str) -> list[list[float]]:
|
|||||||
return [item["embedding"] for item in response.data]
|
return [item["embedding"] for item in response.data]
|
||||||
|
|
||||||
|
|
||||||
async def resolve_tool_calls(
|
def _tool_display(name: str, args: dict) -> tuple[str, str]:
|
||||||
|
"""ツール呼び出しをGUI表示用の (ラベル, 詳細) に変換する。"""
|
||||||
|
if name == "web_search":
|
||||||
|
return "Web検索", args.get("query", "")
|
||||||
|
if name == "read_note":
|
||||||
|
return "ノートを読む", args.get("title", "")
|
||||||
|
if name == "write_note":
|
||||||
|
label = "ノートを上書き" if args.get("mode") == "replace" else "ノートに追記"
|
||||||
|
return label, args.get("title", "")
|
||||||
|
if name == "read_knowledge":
|
||||||
|
return "蓄積知識を読む", args.get("query", "")
|
||||||
|
return name, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _first_line(text: str, limit: int = 80) -> str:
|
||||||
|
"""ツール結果の要点だけをGUIに出すため、1行目を切り出して長さを詰める。"""
|
||||||
|
line = text.strip().splitlines()[0] if text.strip() else ""
|
||||||
|
return line[:limit] + "…" if len(line) > limit else line
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_tool_calls(
|
||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
session_id: str | None = None,
|
session_id: str | None = None,
|
||||||
db: "DBSession | None" = None,
|
db: "DBSession | None" = None,
|
||||||
max_rounds: int = 2,
|
max_rounds: int = 2,
|
||||||
) -> tuple[list[dict], list[dict], bool]:
|
) -> AsyncIterator[dict]:
|
||||||
"""必要に応じてLLMにツールを呼ばせ、その結果を会話に組み込んだ上でメッセージ列を返す。
|
"""必要に応じてLLMにツールを呼ばせ、その進行状況をイベントとして逐次 yield する。
|
||||||
|
|
||||||
扱うツールは web_search、共有ノート(read_note / write_note)、蓄積知識(read_knowledge)。
|
扱うツールは web_search、共有ノート(read_note / write_note)、蓄積知識(read_knowledge)。
|
||||||
DBを見るものは db が渡されたときだけ提示する。モデルがtool callingに対応していない場合は
|
DBを見るものは db が渡されたときだけ提示する。モデルがtool callingに対応していない場合は
|
||||||
何もせず元のmessagesをそのまま返す。
|
何もせず元のmessagesをそのまま返す。
|
||||||
戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト, ノートを変更したか)
|
|
||||||
|
yield するイベント:
|
||||||
|
- {"type": "tool", "id", "name", "label", "detail", "status": "running"} … 実行開始
|
||||||
|
- {"type": "tool", "id", "status": "done", "result"} … 実行完了(GUI用の短い結果つき)
|
||||||
|
- {"type": "resolved", "messages", "searches", "notes_changed"} … 最後に必ず1回
|
||||||
|
|
||||||
|
最後の resolved イベントが従来の戻り値にあたる(resolve_tool_calls がそれを取り出す)。
|
||||||
"""
|
"""
|
||||||
# app.core.memory_kb.knowledge は app.core.llm を import しているため、
|
# app.core.memory_kb.knowledge は app.core.llm を import しているため、
|
||||||
# モジュールレベルで読むと循環 import になる(_save_log と同じ理由で関数内 import する)。
|
# モジュールレベルで読むと循環 import になる(_save_log と同じ理由で関数内 import する)。
|
||||||
@@ -142,7 +168,8 @@ async def resolve_tool_calls(
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.info("Model %s does not support tool calling; skipping tools", used_model)
|
logger.info("Model %s does not support tool calling; skipping tools", used_model)
|
||||||
return messages, searches, notes_changed
|
yield {"type": "resolved", "messages": messages, "searches": searches, "notes_changed": notes_changed}
|
||||||
|
return
|
||||||
|
|
||||||
choice_message = response.choices[0].message
|
choice_message = response.choices[0].message
|
||||||
tool_calls = getattr(choice_message, "tool_calls", None)
|
tool_calls = getattr(choice_message, "tool_calls", None)
|
||||||
@@ -169,19 +196,35 @@ async def resolve_tool_calls(
|
|||||||
args = {}
|
args = {}
|
||||||
|
|
||||||
name = tc.function.name
|
name = tc.function.name
|
||||||
|
label, detail = _tool_display(name, args)
|
||||||
|
yield {
|
||||||
|
"type": "tool",
|
||||||
|
"id": tc.id,
|
||||||
|
"name": name,
|
||||||
|
"label": label,
|
||||||
|
"detail": detail,
|
||||||
|
"status": "running",
|
||||||
|
}
|
||||||
|
|
||||||
if name in ("read_note", "write_note") and db is not None:
|
if name in ("read_note", "write_note") and db is not None:
|
||||||
tool_text, changed = execute_note_tool(db, name, args)
|
tool_text, changed = execute_note_tool(db, name, args)
|
||||||
notes_changed = notes_changed or changed
|
notes_changed = notes_changed or changed
|
||||||
|
result = _first_line(tool_text)
|
||||||
elif name == "read_knowledge" and db is not None:
|
elif name == "read_knowledge" and db is not None:
|
||||||
tool_text = execute_knowledge_tool(db, name, args)
|
tool_text = execute_knowledge_tool(db, name, args)
|
||||||
|
result = _first_line(tool_text)
|
||||||
else:
|
else:
|
||||||
query = args.get("query", "")
|
query = args.get("query", "")
|
||||||
results = await search_web(query)
|
results = await search_web(query)
|
||||||
if results:
|
if results:
|
||||||
tool_text = "\n\n".join(f"[{r['title']}]({r['url']})\n{r['content']}" for r in results)
|
tool_text = "\n\n".join(f"[{r['title']}]({r['url']})\n{r['content']}" for r in results)
|
||||||
searches.append({"query": query, "results": results})
|
searches.append({"query": query, "results": results})
|
||||||
|
result = f"{len(results)}件の結果"
|
||||||
else:
|
else:
|
||||||
tool_text = "検索結果が見つかりませんでした。"
|
tool_text = "検索結果が見つかりませんでした。"
|
||||||
|
result = "結果なし"
|
||||||
|
|
||||||
|
yield {"type": "tool", "id": tc.id, "status": "done", "result": result}
|
||||||
|
|
||||||
working_messages.append({
|
working_messages.append({
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
@@ -189,4 +232,27 @@ async def resolve_tool_calls(
|
|||||||
"content": tool_text,
|
"content": tool_text,
|
||||||
})
|
})
|
||||||
|
|
||||||
return working_messages, searches, notes_changed
|
yield {
|
||||||
|
"type": "resolved",
|
||||||
|
"messages": working_messages,
|
||||||
|
"searches": searches,
|
||||||
|
"notes_changed": notes_changed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_tool_calls(
|
||||||
|
messages: list[dict],
|
||||||
|
model: str | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
db: "DBSession | None" = None,
|
||||||
|
max_rounds: int = 2,
|
||||||
|
) -> tuple[list[dict], list[dict], bool]:
|
||||||
|
"""stream_tool_calls の進行状況イベントを捨て、最終結果だけを返すラッパー。
|
||||||
|
|
||||||
|
戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト, ノートを変更したか)
|
||||||
|
"""
|
||||||
|
resolved = (messages, [], False)
|
||||||
|
async for event in stream_tool_calls(messages, model, session_id, db, max_rounds):
|
||||||
|
if event["type"] == "resolved":
|
||||||
|
resolved = (event["messages"], event["searches"], event["notes_changed"])
|
||||||
|
return resolved
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,10 +28,15 @@ async def get_context_messages(db: DBSession, session_id: str) -> list[dict]:
|
|||||||
context.append({"role": "system", "content": knowledge_context})
|
context.append({"role": "system", "content": knowledge_context})
|
||||||
|
|
||||||
# 共有ノートは「タイトル一覧」だけを常時見せ、本文は read_note ツールで読ませる
|
# 共有ノートは「タイトル一覧」だけを常時見せ、本文は read_note ツールで読ませる
|
||||||
notes_context = notes.get_notes_context(db)
|
notes_context = notes.get_notes_context(db, session_id)
|
||||||
if notes_context:
|
if notes_context:
|
||||||
context.append({"role": "system", "content": notes_context})
|
context.append({"role": "system", "content": notes_context})
|
||||||
|
|
||||||
|
# ただしユーザーがこのセッションに紐付けたページは、本文もそのまま載せる
|
||||||
|
session_notes_context = notes.get_session_notes_context(db, session_id)
|
||||||
|
if session_notes_context:
|
||||||
|
context.append({"role": "system", "content": session_notes_context})
|
||||||
|
|
||||||
if messages:
|
if messages:
|
||||||
latest_user_message = next((m.content for m in reversed(messages) if m.role == "user"), "")
|
latest_user_message = next((m.content for m in reversed(messages) if m.role == "user"), "")
|
||||||
local_kb_context = await local_knowledge.get_local_kb_context(db, latest_user_message)
|
local_kb_context = await local_knowledge.get_local_kb_context(db, latest_user_message)
|
||||||
|
|||||||
+178
-4
@@ -2,14 +2,25 @@
|
|||||||
|
|
||||||
システムプロンプトにはページの「タイトル一覧」だけを注入し、本文はモデルが
|
システムプロンプトにはページの「タイトル一覧」だけを注入し、本文はモデルが
|
||||||
read_note ツールで必要なときだけ読み出す。書き込みは write_note ツールで行う。
|
read_note ツールで必要なときだけ読み出す。書き込みは write_note ツールで行う。
|
||||||
|
|
||||||
|
例外として、ユーザーがセッションに紐付けたページ(SessionNotePage)は本文も
|
||||||
|
毎回そのままシステムプロンプトに載る。ツールの往復を待たずに確実に参照させたい
|
||||||
|
資料のための仕組みで、指定はセッション単位なので会話ごとに組み合わせを変えられる。
|
||||||
"""
|
"""
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import zipfile
|
||||||
|
|
||||||
from sqlalchemy.orm import Session as DBSession
|
from sqlalchemy.orm import Session as DBSession
|
||||||
|
|
||||||
from app.core.config import NOTES_TOOL_CONTENT_LIMIT
|
from app.core.config import (
|
||||||
from app.db.models import NotePage
|
NOTES_IMPORT_MAX_BYTES,
|
||||||
|
NOTES_IMPORT_MAX_PAGES,
|
||||||
|
NOTES_TOOL_CONTENT_LIMIT,
|
||||||
|
SESSION_NOTES_CONTENT_LIMIT,
|
||||||
|
)
|
||||||
|
from app.db.models import NotePage, SessionNotePage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -138,9 +149,169 @@ def rename_section(db: DBSession, old_name: str, new_name: str | None) -> int:
|
|||||||
return len(pages)
|
return len(pages)
|
||||||
|
|
||||||
|
|
||||||
def get_notes_context(db: DBSession) -> str | None:
|
# 取り込めるテキストファイルの拡張子。これ以外はZIP内でも単体でも無視する
|
||||||
"""共有ノートのタイトル一覧をシステムプロンプト用のテキストとして返す。セクションごとに見出しを付ける。"""
|
_TEXT_SUFFIXES = (".md", ".markdown", ".txt", ".text")
|
||||||
|
# ダウンロードしたZIPのファイル名に付く並び順の通し番号(01_タイトル.md)
|
||||||
|
_EXPORT_PREFIX = re.compile(r"^\d{2,}_")
|
||||||
|
|
||||||
|
|
||||||
|
def _decode(raw: bytes) -> str:
|
||||||
|
"""取り込んだファイルを文字列にする。UTF-8優先、日本語Windows由来のCP932も拾う。"""
|
||||||
|
for encoding in ("utf-8-sig", "utf-8", "cp932"):
|
||||||
|
try:
|
||||||
|
return raw.decode(encoding)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
return raw.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _title_from_filename(filename: str) -> str:
|
||||||
|
"""ファイル名からページタイトルを作る。拡張子とエクスポート時の通し番号を落とす。"""
|
||||||
|
stem = filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||||
|
for suffix in _TEXT_SUFFIXES:
|
||||||
|
if stem.lower().endswith(suffix):
|
||||||
|
stem = stem[: -len(suffix)]
|
||||||
|
break
|
||||||
|
return _EXPORT_PREFIX.sub("", stem).strip() or "無題のページ"
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_title(db: DBSession, title: str, taken: set[str]) -> str:
|
||||||
|
"""既存ページや同時取り込み分とタイトルがぶつかったら「(2)」を付けてずらす。
|
||||||
|
|
||||||
|
上書きせずに増やすのは、エクスポートしたZIPを既存のノートに投げ戻したときに
|
||||||
|
元のページを壊さないため。要らない方は後からタブの × で消せる。
|
||||||
|
"""
|
||||||
|
existing = {p.title for p in db.query(NotePage.title).all()} | taken
|
||||||
|
if title not in existing:
|
||||||
|
return title
|
||||||
|
n = 2
|
||||||
|
while f"{title} ({n})" in existing:
|
||||||
|
n += 1
|
||||||
|
return f"{title} ({n})"
|
||||||
|
|
||||||
|
|
||||||
|
def _create_imported_page(db: DBSession, title: str, content: str, section: str | None) -> NotePage:
|
||||||
|
page = NotePage(title=title, content=content, position=next_position(db))
|
||||||
|
db.add(page)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(page)
|
||||||
|
if section:
|
||||||
|
# 同じセクションの末尾に並べ直す(連続配置の前提を保つ)
|
||||||
|
page = set_section(db, page.id, section) or page
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
def import_files(db: DBSession, files: list[tuple[str, bytes]]) -> list[NotePage]:
|
||||||
|
"""アップロードされたファイルをノートのページとして取り込む。
|
||||||
|
|
||||||
|
files は (ファイル名, 中身) のリスト。.md / .txt はそのまま1ページになり、.zip は
|
||||||
|
中のテキストファイルを1つずつページにする(ZIP内のフォルダ名がセクションになる)。
|
||||||
|
エクスポートで出したZIPをそのまま投げ戻せば、セクションと並び順ごと復元できる。
|
||||||
|
|
||||||
|
取り込めないファイル(拡張子が対象外・大きすぎる)は黙って飛ばす。作成したページを返す。
|
||||||
|
"""
|
||||||
|
created: list[NotePage] = []
|
||||||
|
taken: set[str] = set()
|
||||||
|
|
||||||
|
def add(filename: str, raw: bytes, section: str | None) -> None:
|
||||||
|
if len(created) >= NOTES_IMPORT_MAX_PAGES or len(raw) > NOTES_IMPORT_MAX_BYTES:
|
||||||
|
logger.info("Skipped note import: %s (%d bytes)", filename, len(raw))
|
||||||
|
return
|
||||||
|
title = _unique_title(db, _title_from_filename(filename), taken)
|
||||||
|
taken.add(title)
|
||||||
|
created.append(_create_imported_page(db, title, _decode(raw), section))
|
||||||
|
|
||||||
|
for filename, raw in files:
|
||||||
|
if filename.lower().endswith(".zip"):
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(io.BytesIO(raw)) as archive:
|
||||||
|
for info in archive.infolist():
|
||||||
|
if info.is_dir() or not info.filename.lower().endswith(_TEXT_SUFFIXES):
|
||||||
|
continue
|
||||||
|
if info.file_size > NOTES_IMPORT_MAX_BYTES:
|
||||||
|
logger.info("Skipped zip entry: %s (%d bytes)", info.filename, info.file_size)
|
||||||
|
continue
|
||||||
|
# ZIP内のフォルダ名をセクションにする(ネストは最初の階層だけ見る)
|
||||||
|
parts = info.filename.replace("\\", "/").split("/")
|
||||||
|
section = parts[0] if len(parts) > 1 and parts[0] not in ("", ".", "..") else None
|
||||||
|
add(info.filename, archive.read(info), section)
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
logger.warning("Not a valid zip: %s", filename)
|
||||||
|
elif filename.lower().endswith(_TEXT_SUFFIXES):
|
||||||
|
add(filename, raw, None)
|
||||||
|
else:
|
||||||
|
logger.info("Skipped unsupported file: %s", filename)
|
||||||
|
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def get_attached_page_ids(db: DBSession, session_id: str) -> list[int]:
|
||||||
|
"""セッションに紐付けられた(本文をプロンプトに載せる)ページIDを position 順で返す。"""
|
||||||
|
rows = (
|
||||||
|
db.query(SessionNotePage.page_id)
|
||||||
|
.join(NotePage, NotePage.id == SessionNotePage.page_id)
|
||||||
|
.filter(SessionNotePage.session_id == session_id)
|
||||||
|
.order_by(NotePage.position, NotePage.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [row[0] for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def set_attached_pages(db: DBSession, session_id: str, page_ids: list[int]) -> list[int]:
|
||||||
|
"""セッションに紐付けるページを page_ids で置き換える。存在しないIDは黙って捨てる。
|
||||||
|
|
||||||
|
戻り値は実際に紐付いたページIDを position 順で並べたもの。
|
||||||
|
"""
|
||||||
|
valid = {p.id for p in db.query(NotePage.id).filter(NotePage.id.in_(page_ids)).all()} if page_ids else set()
|
||||||
|
|
||||||
|
existing = db.query(SessionNotePage).filter(SessionNotePage.session_id == session_id).all()
|
||||||
|
for link in existing:
|
||||||
|
if link.page_id not in valid:
|
||||||
|
db.delete(link)
|
||||||
|
already = {link.page_id for link in existing if link.page_id in valid}
|
||||||
|
for page_id in valid - already:
|
||||||
|
db.add(SessionNotePage(session_id=session_id, page_id=page_id))
|
||||||
|
db.commit()
|
||||||
|
return get_attached_page_ids(db, session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_notes_context(db: DBSession, session_id: str) -> str | None:
|
||||||
|
"""セッションに紐付けられたページの本文を、システムプロンプト用のテキストとして返す。
|
||||||
|
|
||||||
|
1ページも紐付いていなければ None。1ページあたり SESSION_NOTES_CONTENT_LIMIT 文字で切る。
|
||||||
|
"""
|
||||||
|
pages = (
|
||||||
|
db.query(NotePage)
|
||||||
|
.join(SessionNotePage, SessionNotePage.page_id == NotePage.id)
|
||||||
|
.filter(SessionNotePage.session_id == session_id)
|
||||||
|
.order_by(NotePage.position, NotePage.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not pages:
|
||||||
|
return None
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"【この会話で参照するノート】",
|
||||||
|
"ユーザーがこの会話に紐付けたページです。本文をそのまま載せているので、"
|
||||||
|
"これらのページに read_note を使う必要はありません。",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for page in pages:
|
||||||
|
content = page.content.strip() or "(空)"
|
||||||
|
if len(content) > SESSION_NOTES_CONTENT_LIMIT:
|
||||||
|
content = content[:SESSION_NOTES_CONTENT_LIMIT] + "\n…(以下省略。全文は read_note で読めます)"
|
||||||
|
lines += [f"## {page.title}", content, ""]
|
||||||
|
return "\n".join(lines).rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
def get_notes_context(db: DBSession, session_id: str | None = None) -> str | None:
|
||||||
|
"""共有ノートのタイトル一覧をシステムプロンプト用のテキストとして返す。セクションごとに見出しを付ける。
|
||||||
|
|
||||||
|
session_id を渡すと、そのセッションに紐付いた(本文が別途プロンプトに載る)ページに
|
||||||
|
印を付ける。同じ本文を read_note で読み直させないため。
|
||||||
|
"""
|
||||||
pages = db.query(NotePage).order_by(NotePage.position, NotePage.id).all()
|
pages = db.query(NotePage).order_by(NotePage.position, NotePage.id).all()
|
||||||
|
attached = set(get_attached_page_ids(db, session_id)) if session_id else set()
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
"【共有ノート】",
|
"【共有ノート】",
|
||||||
@@ -155,6 +326,9 @@ def get_notes_context(db: DBSession) -> str | None:
|
|||||||
current_section = page.section
|
current_section = page.section
|
||||||
if current_section:
|
if current_section:
|
||||||
lines.append(f"## {current_section}")
|
lines.append(f"## {current_section}")
|
||||||
|
if page.id in attached:
|
||||||
|
lines.append(f"- {page.title} — (本文は「この会話で参照するノート」に全文あり)")
|
||||||
|
continue
|
||||||
preview = " ".join(page.content.split())[:40]
|
preview = " ".join(page.content.split())[:40]
|
||||||
suffix = f" — {preview}…" if preview else " — (空)"
|
suffix = f" — {preview}…" if preview else " — (空)"
|
||||||
lines.append(f"- {page.title}{suffix}")
|
lines.append(f"- {page.title}{suffix}")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy import String, Text, ForeignKey, Integer, Float
|
from sqlalchemy import String, Text, ForeignKey, Integer, Float, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from app.db.database import Base
|
from app.db.database import Base
|
||||||
from app.db.types import UtcDateTime
|
from app.db.types import UtcDateTime
|
||||||
@@ -16,6 +16,7 @@ class Session(Base):
|
|||||||
|
|
||||||
messages: Mapped[list["Message"]] = relationship(back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at")
|
messages: Mapped[list["Message"]] = relationship(back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at")
|
||||||
memories: Mapped[list["Memory"]] = relationship(back_populates="session", cascade="all, delete-orphan")
|
memories: Mapped[list["Memory"]] = relationship(back_populates="session", cascade="all, delete-orphan")
|
||||||
|
note_links: Mapped[list["SessionNotePage"]] = relationship(back_populates="session", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
class Message(Base):
|
class Message(Base):
|
||||||
@@ -119,7 +120,8 @@ class NotePage(Base):
|
|||||||
"""ユーザーとAIが共同で編集する共有ノートの1ページ。UIではタブとして表示する。
|
"""ユーザーとAIが共同で編集する共有ノートの1ページ。UIではタブとして表示する。
|
||||||
|
|
||||||
ページのタイトル一覧は毎回システムプロンプトに注入され、本文はモデルが
|
ページのタイトル一覧は毎回システムプロンプトに注入され、本文はモデルが
|
||||||
read_note ツールで読み出す(app.core.notes を参照)。
|
read_note ツールで読み出す(app.core.notes を参照)。ただし SessionNotePage で
|
||||||
|
セッションに紐付けられたページは、本文もそのままシステムプロンプトに載る。
|
||||||
"""
|
"""
|
||||||
__tablename__ = "note_pages"
|
__tablename__ = "note_pages"
|
||||||
|
|
||||||
@@ -131,6 +133,28 @@ class NotePage(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
|
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
|
||||||
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
session_links: Mapped[list["SessionNotePage"]] = relationship(back_populates="page", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class SessionNotePage(Base):
|
||||||
|
"""「このセッションではこのページの本文をプロンプトに載せる」という指定。
|
||||||
|
|
||||||
|
通常、共有ノートはタイトル一覧だけがシステムプロンプトに入り、本文はモデルが
|
||||||
|
read_note ツールで取りに行く。ここに登録されたページはそれを待たず、本文が毎回
|
||||||
|
そのままシステムプロンプトに入る(app.core.notes.get_session_notes_context を参照)。
|
||||||
|
セッション単位なので、会話ごとに違う組み合わせを選べる。
|
||||||
|
"""
|
||||||
|
__tablename__ = "session_note_pages"
|
||||||
|
__table_args__ = (UniqueConstraint("session_id", "page_id", name="uq_session_note_page"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
session_id: Mapped[str] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE"))
|
||||||
|
page_id: Mapped[int] = mapped_column(ForeignKey("note_pages.id", ondelete="CASCADE"))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
session: Mapped["Session"] = relationship(back_populates="note_links")
|
||||||
|
page: Mapped["NotePage"] = relationship(back_populates="session_links")
|
||||||
|
|
||||||
|
|
||||||
class AppSetting(Base):
|
class AppSetting(Base):
|
||||||
"""GUIの設定画面から編集する接続情報(APIキー等)。app.core.settings_store が読み書きする。"""
|
"""GUIの設定画面から編集する接続情報(APIキー等)。app.core.settings_store が読み書きする。"""
|
||||||
|
|||||||
+2
-1
@@ -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)])
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ dependencies = [
|
|||||||
"discord-py>=2.7.1",
|
"discord-py>=2.7.1",
|
||||||
"janome>=0.5.0",
|
"janome>=0.5.0",
|
||||||
"trafilatura>=2.1.0",
|
"trafilatura>=2.1.0",
|
||||||
|
"python-multipart>=0.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
Generated
+11
@@ -1300,6 +1300,7 @@ dependencies = [
|
|||||||
{ name = "nvidia-ml-py" },
|
{ name = "nvidia-ml-py" },
|
||||||
{ name = "psutil" },
|
{ name = "psutil" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "python-multipart" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
{ name = "sse-starlette" },
|
{ name = "sse-starlette" },
|
||||||
{ name = "trafilatura" },
|
{ name = "trafilatura" },
|
||||||
@@ -1336,6 +1337,7 @@ requires-dist = [
|
|||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||||
|
{ name = "python-multipart", specifier = ">=0.0.9" },
|
||||||
{ name = "sqlalchemy", specifier = ">=2.0.0" },
|
{ name = "sqlalchemy", specifier = ">=2.0.0" },
|
||||||
{ name = "sse-starlette", specifier = ">=2.1.0" },
|
{ name = "sse-starlette", specifier = ">=2.1.0" },
|
||||||
{ name = "trafilatura", specifier = ">=2.1.0" },
|
{ name = "trafilatura", specifier = ">=2.1.0" },
|
||||||
@@ -1642,6 +1644,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-multipart"
|
||||||
|
version = "0.0.32"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytz"
|
name = "pytz"
|
||||||
version = "2026.2"
|
version = "2026.2"
|
||||||
|
|||||||
+122
-1
@@ -15,15 +15,22 @@ SSE でストリーミング応答を返す。
|
|||||||
{
|
{
|
||||||
"message": "こんにちは",
|
"message": "こんにちは",
|
||||||
"session_id": "uuid(省略時は新規セッション作成)",
|
"session_id": "uuid(省略時は新規セッション作成)",
|
||||||
"model": "gpt-5.4(省略時は DEFAULT_MODEL)"
|
"model": "gpt-5.4(省略時は DEFAULT_MODEL)",
|
||||||
|
"note_page_ids": [1, 2]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`note_page_ids` は「このセッションで本文をシステムプロンプトに載せる共有ノートのページ ID」。
|
||||||
|
渡すとコンテキスト組み立ての前にサーバー側の指定を丸ごと置き換える(`PUT /chat/sessions/{id}/notes` と同じ内容)。
|
||||||
|
新規セッションではフロントがまだ session_id を持てないため、初回はここでしか指定できない。省略すると現在の指定を変更しない。
|
||||||
|
|
||||||
**SSE イベント**
|
**SSE イベント**
|
||||||
| type | value | タイミング |
|
| type | value | タイミング |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `session_id` | セッション UUID | 新規セッション作成直後 |
|
| `session_id` | セッション UUID | 新規セッション作成直後 |
|
||||||
| `chunk` | テキスト断片 | LLM トークン生成ごと |
|
| `chunk` | テキスト断片 | LLM トークン生成ごと |
|
||||||
|
| `tool` | `{id, name, label, detail, status, result}` | ツール実行の開始時 (`status: running`) と完了時 (`status: done`)。同じ `id` で 2 回届くのでフロントはマージして表示する |
|
||||||
|
| `sources` | `[{title, url}]` | Web 検索した時のみ(出典一覧) |
|
||||||
| `notes_updated` | — | AI が共有ノートを書き換えた直後 |
|
| `notes_updated` | — | AI が共有ノートを書き換えた直後 |
|
||||||
| `done` | — | 正常完了 |
|
| `done` | — | 正常完了 |
|
||||||
| `error` | エラーメッセージ | 例外発生時 |
|
| `error` | エラーメッセージ | 例外発生時 |
|
||||||
@@ -62,6 +69,28 @@ SSE でストリーミング応答を返す。
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### GET /chat/sessions/{session_id}/notes
|
||||||
|
このセッションで本文をシステムプロンプトに載せるよう指定された共有ノートのページ ID を返す。
|
||||||
|
順序はノートのタブ順(`position`)。セッションが無ければ `404`。
|
||||||
|
|
||||||
|
**レスポンス**
|
||||||
|
```json
|
||||||
|
{ "page_ids": [1, 2] }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PUT /chat/sessions/{session_id}/notes
|
||||||
|
指定を丸ごと置き換える。存在しないページ ID は黙って捨てられ、実際に紐付いた ID が返る。
|
||||||
|
空配列を渡すと全解除。セッションが無ければ `404`。
|
||||||
|
|
||||||
|
**リクエスト**
|
||||||
|
```json
|
||||||
|
{ "page_ids": [1, 2] }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### DELETE /chat/sessions/{session_id}
|
### DELETE /chat/sessions/{session_id}
|
||||||
セッションと紐づくメッセージ・メモリをすべて削除。`204 No Content` を返す。
|
セッションと紐づくメッセージ・メモリをすべて削除。`204 No Content` を返す。
|
||||||
|
|
||||||
@@ -185,6 +214,98 @@ SSE でストリーミング応答を返す。
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### POST /api/notes/import
|
||||||
|
`multipart/form-data` の `files` でアップロードされたファイルをページとして取り込み、
|
||||||
|
`201 Created` で作成したページの配列を返す。
|
||||||
|
|
||||||
|
| 拡張子 | 取り込み方 |
|
||||||
|
|---|---|
|
||||||
|
| `.md` / `.markdown` / `.txt` / `.text` | 1 ファイル 1 ページ。タイトルはファイル名から拡張子と `01_` 形式の通し番号を落としたもの |
|
||||||
|
| `.zip` | 中のテキストファイルを 1 つずつページにする。第 1 階層のフォルダ名がセクションになる(`GET /api/notes/export` の ZIP をそのまま戻せる) |
|
||||||
|
|
||||||
|
- 既存ページとタイトルが衝突したら `タイトル (2)` のように連番を足して**別ページとして作る**(上書きはしない)
|
||||||
|
- 文字コードは UTF-8 (BOM 可) → CP932 の順に試す
|
||||||
|
- 対象外の拡張子・`NOTES_IMPORT_MAX_BYTES`(既定 2,000,000)超のファイル・`NOTES_IMPORT_MAX_PAGES`(既定 100)を超えた分・壊れた ZIP は**黙って飛ばす**
|
||||||
|
- 1 ページも取り込めなかった場合のみ `400`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/notes/export
|
||||||
|
全ページを ZIP でダウンロードさせる(`Content-Disposition: attachment`)。
|
||||||
|
1 ページ 1 ファイルの Markdown で、本文はそのまま。エントリ名は `<セクション>/<通し番号>_<タイトル>.md`
|
||||||
|
(セクション無しのページは ZIP 直下)。通し番号は `position` 順なのでタブの並びが残る。
|
||||||
|
ファイル名は `notes_YYYYMMDD.zip`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/notes/{page_id}/export
|
||||||
|
1 ページを Markdown ファイルとしてダウンロードさせる。本文はそのままで、ファイル名がタイトルになる。
|
||||||
|
ページが存在しない場合は `404`。
|
||||||
|
|
||||||
|
日本語タイトルを扱うため `Content-Disposition` には RFC 5987 形式を併記する:
|
||||||
|
|
||||||
|
```
|
||||||
|
attachment; filename="______.md"; filename*=UTF-8''%E8%B2%B7%E3%81%84%E7%89%A9%E3%83%AA%E3%82%B9%E3%83%88.md
|
||||||
|
```
|
||||||
|
|
||||||
|
ファイル名に使えない文字(`\ / : * ? " < > |` と制御文字)は `_` に置き換え、80 文字で切る。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ローカルナレッジベース(閲覧)
|
||||||
|
|
||||||
|
`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
|
||||||
|
|||||||
@@ -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・ワードプール参照などの運用系
|
||||||
@@ -66,6 +67,7 @@ backend/
|
|||||||
| `knowledge_sources` | id, entry_id, title, url | 上記知識の出典 URL |
|
| `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, updated_at | ローカル Markdown KB の埋め込みキャッシュ |
|
||||||
| `note_pages` | id, title, content, position, section, created_at, updated_at | ユーザーと AI が共同編集する共有ノートのページ(`section` でタブをグループ化) |
|
| `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 設定画面から編集した接続情報 |
|
| `app_settings` | key, value, updated_at | GUI 設定画面から編集した接続情報 |
|
||||||
| `model_presets` | id, label, value, created_at | モデル選択プルダウンの選択肢 |
|
| `model_presets` | id, label, value, created_at | モデル選択プルダウンの選択肢 |
|
||||||
| `api_logs` | id, session_id, model, request_json, response_text, error, latency_ms, created_at | LLM API 呼び出しログ |
|
| `api_logs` | id, session_id, model, request_json, response_text, error, latency_ms, created_at | LLM API 呼び出しログ |
|
||||||
@@ -79,12 +81,19 @@ LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messag
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 1 | 蓄積知識 | 過去の Web 検索で得た `knowledge_entries` の**見出し一覧のみ**(本文・出典は含めない) | `knowledge.get_knowledge_context()` | 最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件 |
|
| 1 | 蓄積知識 | 過去の Web 検索で得た `knowledge_entries` の**見出し一覧のみ**(本文・出典は含めない) | `knowledge.get_knowledge_context()` | 最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件 |
|
||||||
| 2 | 共有ノート | ページの**タイトル一覧+本文先頭 40 文字**のみ | `notes.get_notes_context()` | 全ページ(本文は入れない) |
|
| 2 | 共有ノート | ページの**タイトル一覧+本文先頭 40 文字**のみ | `notes.get_notes_context()` | 全ページ(本文は入れない) |
|
||||||
|
| 2.5 | 参照指定ノート | ユーザーがこのセッションに紐付けたページの**本文全文** | `notes.get_session_notes_context()` | 紐付いたページのみ。1 ページ `SESSION_NOTES_CONTENT_LIMIT`(既定 8000)文字で打ち切り |
|
||||||
| 3 | ローカル KB | `knowledge_base/**.md` から選ばれた**ファイル全文** | `local_knowledge.get_local_kb_context()` | 直近ユーザー発言との cos 類似度上位 `LOCAL_KB_TOP_K`(既定 3)件、かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上 |
|
| 3 | ローカル KB | `knowledge_base/**.md` から選ばれた**ファイル全文** | `local_knowledge.get_local_kb_context()` | 直近ユーザー発言との cos 類似度上位 `LOCAL_KB_TOP_K`(既定 3)件、かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上 |
|
||||||
| 4 | 長期メモリ | `memories` の要約を全件連結 | `memory.get_context_messages()` | 上限なし |
|
| 4 | 長期メモリ | `memories` の要約を全件連結 | `memory.get_context_messages()` | 上限なし |
|
||||||
| 5 | 短期メモリ | セッション内の `messages` 全件(要約済みで削除された分を除く) | 同上 | 上限なし(要約で間接的に制御) |
|
| 5 | 短期メモリ | セッション内の `messages` 全件(要約済みで削除された分を除く) | 同上 | 上限なし(要約で間接的に制御) |
|
||||||
|
|
||||||
層 3 のプロンプトは「Web 検索結果より優先度が高い」「これで答えられるなら検索するな」と明示しており、層 1 より優先される。
|
層 3 のプロンプトは「Web 検索結果より優先度が高い」「これで答えられるなら検索するな」と明示しており、層 1 より優先される。
|
||||||
|
|
||||||
|
層 2.5 は「重い本文はツールで取りに行かせる」という原則の唯一の例外で、ユーザーが明示的に選んだページだけが対象。
|
||||||
|
`session_note_pages` に行がある間だけ効き、`GET/PUT /chat/sessions/{id}/notes` で読み書きする。
|
||||||
|
新規セッションではフロントがまだ session_id を持てないため、`POST /chat/stream` の `note_page_ids`
|
||||||
|
でも同じ指定を送れるようにしてあり、コンテキストを組み立てる前にサーバー側で反映される。
|
||||||
|
層 2 の一覧では紐付け済みのページに印が付き、同じ本文を `read_note` で読み直させないようにしている。
|
||||||
|
|
||||||
#### 要約(長期メモリ化)
|
#### 要約(長期メモリ化)
|
||||||
|
|
||||||
`maybe_summarize()` がアシスタント応答の保存直後に走る。
|
`maybe_summarize()` がアシスタント応答の保存直後に走る。
|
||||||
@@ -146,6 +155,7 @@ LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messag
|
|||||||
|
|
||||||
```
|
```
|
||||||
data: {"type":"session_id","value":"<uuid>"} # 新規セッション時のみ
|
data: {"type":"session_id","value":"<uuid>"} # 新規セッション時のみ
|
||||||
|
data: {"type":"tool","value":{...}} # ツール実行ごとに開始・完了の2回
|
||||||
data: {"type":"notes_updated"} # AIが共有ノートを更新した時のみ
|
data: {"type":"notes_updated"} # AIが共有ノートを更新した時のみ
|
||||||
data: {"type":"sources","value":[{...}]} # Web検索した時のみ(出典の title/url)
|
data: {"type":"sources","value":[{...}]} # Web検索した時のみ(出典の title/url)
|
||||||
data: {"type":"chunk","value":"テキスト断片"} # 0回以上
|
data: {"type":"chunk","value":"テキスト断片"} # 0回以上
|
||||||
@@ -153,6 +163,13 @@ data: {"type":"done"} # 正常完了
|
|||||||
data: {"type":"error","value":"エラー内容"} # 例外発生時
|
data: {"type":"error","value":"エラー内容"} # 例外発生時
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`tool` イベントは `llm.stream_tool_calls()`(`resolve_tool_calls()` の逐次版)が yield する進行状況を
|
||||||
|
そのまま中継したもので、`{id, name, label, detail, status}` で始まり、同じ `id` の
|
||||||
|
`{id, status:"done", result}` で終わる。検索結果の要約・蓄積知識への保存も
|
||||||
|
`id: "save_knowledge"` の同形式のイベントとして送る。フロント(`ToolActivityList.vue`)は
|
||||||
|
`id` をキーにマージし、アシスタントバブルの上に実行中スピナーつきで一覧表示する。
|
||||||
|
表示専用なので DB には保存せず、履歴を読み直すと消える。
|
||||||
|
|
||||||
バッファリング防止のため `Cache-Control: no-cache` と `X-Accel-Buffering: no` を付与する。
|
バッファリング防止のため `Cache-Control: no-cache` と `X-Accel-Buffering: no` を付与する。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -201,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 プロキシ設定
|
||||||
|
|
||||||
|
|||||||
+34
-4
@@ -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` |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -46,11 +48,14 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
|
|||||||
## 1. チャット
|
## 1. チャット
|
||||||
|
|
||||||
- `POST /chat/stream` が SSE を返し、フロントは fetch + ReadableStream で受けて 1 文字ずつ描画する。
|
- `POST /chat/stream` が SSE を返し、フロントは fetch + ReadableStream で受けて 1 文字ずつ描画する。
|
||||||
イベントは `session_id` / `chunk` / `sources` / `notes_updated` / `done` / `error` の 6 種。
|
イベントは `session_id` / `chunk` / `tool` / `sources` / `notes_updated` / `done` / `error` の 7 種。
|
||||||
- アシスタントの発言は `marked` + `DOMPurify` で描画する。ストリーミング途中で閉じていないコードフェンスは
|
- アシスタントの発言は `marked` + `DOMPurify` で描画する。ストリーミング途中で閉じていないコードフェンスは
|
||||||
自動で補完してから描画するので、生成中でもレイアウトが崩れない。
|
自動で補完してから描画するので、生成中でもレイアウトが崩れない。
|
||||||
- セッションは左サイドバーの一覧から切り替え・削除できる。タイトルは最初の発言の先頭 40 文字。
|
- セッションは左サイドバーの一覧から切り替え・削除できる。タイトルは最初の発言の先頭 40 文字。
|
||||||
- Web 検索が走った応答には出典(`sources`)が、各メッセージには自動生成されたタグが添えて表示される。
|
- Web 検索が走った応答には出典(`sources`)が、各メッセージには自動生成されたタグが添えて表示される。
|
||||||
|
- Web 検索・ノートの読み書き・蓄積知識の読み出しといったツール実行は、バブルの上に
|
||||||
|
「🔍 Web検索 / クエリ / ✓ 5件の結果」の形でリアルタイムに表示される(`ToolActivityList.vue`)。
|
||||||
|
実行中はスピナーが回り、応答が途中で切れたツールは「⚠ 中断されました」になる。表示専用で永続化はしない。
|
||||||
- 失敗時は赤いバブルとトースト通知の両方で知らせる。
|
- 失敗時は赤いバブルとトースト通知の両方で知らせる。
|
||||||
|
|
||||||
## 2. セッションの書き出し / 取り込み
|
## 2. セッションの書き出し / 取り込み
|
||||||
@@ -68,10 +73,13 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 蓄積知識 | 過去の Web 検索結果の**見出し一覧のみ**(本文は `read_knowledge` で読む) | 最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件 |
|
| 1 蓄積知識 | 過去の Web 検索結果の**見出し一覧のみ**(本文は `read_knowledge` で読む) | 最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件 |
|
||||||
| 2 共有ノート | ページのタイトル一覧+本文先頭 40 文字 | 全ページ(本文は入れない) |
|
| 2 共有ノート | ページのタイトル一覧+本文先頭 40 文字 | 全ページ(本文は入れない) |
|
||||||
|
| 2.5 参照指定ノート | このチャットに紐付けたページの**本文全文** | 紐付いたページのみ・1 ページ `SESSION_NOTES_CONTENT_LIMIT`(既定 8000)文字 |
|
||||||
| 3 ローカル KB | 選ばれた Markdown の**全文** | 類似度上位 `LOCAL_KB_TOP_K`(既定 3)件かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上 |
|
| 3 ローカル KB | 選ばれた Markdown の**全文** | 類似度上位 `LOCAL_KB_TOP_K`(既定 3)件かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上 |
|
||||||
| 4 長期メモリ | 要約を全件連結 | 上限なし |
|
| 4 長期メモリ | 要約を全件連結 | 上限なし |
|
||||||
| 5 短期メモリ | セッション内の全メッセージ | 上限なし(要約で間接的に制御) |
|
| 5 短期メモリ | セッション内の全メッセージ | 上限なし(要約で間接的に制御) |
|
||||||
|
|
||||||
|
層 2.5 だけは例外で、ユーザーが 📌 で選んだページの本文をツールを介さず直接載せる。
|
||||||
|
|
||||||
重い情報(ノート本文・蓄積知識の本文・Web 検索)は目次だけを渡し、本体はツール(`read_note` / `write_note` / `read_knowledge` / `web_search`)で
|
重い情報(ノート本文・蓄積知識の本文・Web 検索)は目次だけを渡し、本体はツール(`read_note` / `write_note` / `read_knowledge` / `web_search`)で
|
||||||
取りに行かせる遅延読み込み方式。`llm.resolve_tool_calls()` が本番生成の前に最大 2 ラウンドでこれを解決する。
|
取りに行かせる遅延読み込み方式。`llm.resolve_tool_calls()` が本番生成の前に最大 2 ラウンドでこれを解決する。
|
||||||
|
|
||||||
@@ -86,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 から取り直して要約し直せる。
|
||||||
@@ -97,6 +105,9 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
|
|||||||
- 直近のユーザー発言との埋め込みコサイン類似度で選ぶ。埋め込みは `local_knowledge_docs` に
|
- 直近のユーザー発言との埋め込みコサイン類似度で選ぶ。埋め込みは `local_knowledge_docs` に
|
||||||
内容ハッシュ付きでキャッシュされ、ファイルが変わったときだけ取り直す。
|
内容ハッシュ付きでキャッシュされ、ファイルが変わったときだけ取り直す。
|
||||||
- プロンプト上は「Web 検索結果より優先。これで答えられるなら検索するな」と明示している。
|
- プロンプト上は「Web 検索結果より優先。これで答えられるなら検索するな」と明示している。
|
||||||
|
- ヘッダーのパネルメニュー →「ナレッジベース」で開くサイドパネル(`KnowledgeBasePanel.vue`)から中身を閲覧できる。
|
||||||
|
フォルダ単位のグループ表示・折りたたみ、タイトル/パス/本文先頭での絞り込み、Markdown 描画に対応。
|
||||||
|
各ファイルの緑の点は埋め込み済み(=自動注入の対象)であることを示す。閲覧専用で、編集はできない。
|
||||||
|
|
||||||
## 7. 共有ノート
|
## 7. 共有ノート
|
||||||
|
|
||||||
@@ -106,6 +117,15 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
|
|||||||
- AI 側はタイトル一覧だけを常に見ており、`read_note` で本文を読み、`write_note` で追記/全置換/新規作成する。
|
- AI 側はタイトル一覧だけを常に見ており、`read_note` で本文を読み、`write_note` で追記/全置換/新規作成する。
|
||||||
AI が書き換えると SSE の `notes_updated` が飛び、開いているノートパネルが自動で最新化される。
|
AI が書き換えると SSE の `notes_updated` が飛び、開いているノートパネルが自動で最新化される。
|
||||||
- 自分が編集中(未保存)のページは AI の更新で上書きされない。
|
- 自分が編集中(未保存)のページは AI の更新で上書きされない。
|
||||||
|
- **取り込み**: `.md` / `.txt` / `.zip` を本文エリアにドロップするか ⬆ ボタンで選ぶとページになる。
|
||||||
|
ZIP はフォルダ名がセクションになるので、⬇ で出した ZIP をそのまま戻せば構成ごと復元される。
|
||||||
|
タイトルが衝突したら `(2)` を足して別ページにするので、既存のページは壊れない。
|
||||||
|
- **チャットへの紐付け**: タブの 📌 を押すと、そのページの本文が**今開いているチャット**のシステム
|
||||||
|
プロンプトに毎回そのまま載る。AI が `read_note` を呼ぶのを待たずに確実に参照させたいときに使う。
|
||||||
|
指定はチャット単位で保存されるので、会話ごとに違う組み合わせを選べる。
|
||||||
|
- **ダウンロード**: パネル下部の ⬇ ボタンで、開いているページを `.md` として、または全ページを
|
||||||
|
ZIP(1 ページ 1 Markdown、セクションはフォルダ、ファイル名の通し番号でタブ順を保持)として
|
||||||
|
手元に保存できる。未保存の編集は書き出す前に保存される。
|
||||||
|
|
||||||
## 8-9. 自動タグ付けとワードプール
|
## 8-9. 自動タグ付けとワードプール
|
||||||
|
|
||||||
@@ -139,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. ログビューアとシステムモニタ
|
||||||
@@ -164,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 リバースプロキシ)/
|
||||||
|
|||||||
+64
-1
@@ -18,10 +18,73 @@ API 仕様は `docs/api.md` の「共有ノート」節を参照。
|
|||||||
| タイトルを変更 | タブを**ダブルクリック**すると入力欄になるので書き換えて Enter(または欄外をクリック) |
|
| タイトルを変更 | タブを**ダブルクリック**すると入力欄になるので書き換えて Enter(または欄外をクリック) |
|
||||||
| ページを削除 | タブ内の **×** をクリック(確認ダイアログあり) |
|
| ページを削除 | タブ内の **×** をクリック(確認ダイアログあり) |
|
||||||
| 本文を編集 | ページを開いた状態で下の編集エリアに直接入力 |
|
| 本文を編集 | ページを開いた状態で下の編集エリアに直接入力 |
|
||||||
|
| ファイルを取り込む | 本文エリアにファイルをドロップ、またはパネル下部の **⬆ 取り込み** から選択 |
|
||||||
|
| チャットに紐付ける | タブ内の **📌** をクリック(もう一度押すと解除) |
|
||||||
|
| ページを1つ保存 | パネル下部の **⬇ このページ (.md)** をクリック |
|
||||||
|
| 全ページを保存 | パネル下部の **⬇ すべて (.zip)** をクリック |
|
||||||
|
|
||||||
本文は入力を止めてから少し待つと自動保存される。保存待ちの間はタブに **●**(未保存マーク)が付く。
|
本文は入力を止めてから少し待つと自動保存される。保存待ちの間はタブに **●**(未保存マーク)が付く。
|
||||||
ページが1つも無い場合は「+ 最初のページを作成」から始められる。
|
ページが1つも無い場合は「+ 最初のページを作成」から始められる。
|
||||||
|
|
||||||
|
## ファイルを取り込む
|
||||||
|
|
||||||
|
手元のファイルをページとして取り込める。本文エリアに**ドラッグ&ドロップ**するか、
|
||||||
|
パネル下部の **⬆ 取り込み** からファイルを選ぶ(複数まとめて可)。
|
||||||
|
|
||||||
|
| ファイル | どうなるか |
|
||||||
|
|---|---|
|
||||||
|
| `.md` / `.txt` | 1 ファイルが 1 ページになる。ファイル名がタイトル |
|
||||||
|
| `.zip` | 中のテキストファイルが 1 つずつページになる。フォルダ名がセクションになる |
|
||||||
|
|
||||||
|
**⬇ すべて (.zip) で出した ZIP をそのまま戻せる**ので、バックアップや別環境への引っ越しに使える。
|
||||||
|
ファイル名の `01_` のような通し番号は取り込み時に落とされる。
|
||||||
|
|
||||||
|
既存のページと同じタイトルだった場合は上書きせず、`設計メモ (2)` のように連番を足して別ページを作る。
|
||||||
|
要らない方はタブの × で消せばよい。
|
||||||
|
|
||||||
|
取り込めない拡張子(PDF や画像など)や大きすぎるファイル(1 ファイル 2MB 超)は飛ばされる。
|
||||||
|
1 つも取り込めなかったときだけエラーが表示される。
|
||||||
|
|
||||||
|
## チャットに紐付けて必ず読ませる
|
||||||
|
|
||||||
|
普段、AIに渡っているのは**ページのタイトル一覧だけ**で、本文はAIが必要だと判断したときに
|
||||||
|
`read_note` で読みに行く。つまり「読みに行ってくれないと参照されない」。
|
||||||
|
|
||||||
|
タブの **📌** を押すと、そのページの本文が**今開いているチャット**のプロンプトに毎回そのまま載る。
|
||||||
|
AIの判断を待たずに確実に参照させたい資料(仕様書、設定、前提条件など)に使う。
|
||||||
|
|
||||||
|
📌 が付いたタブは上端に黄色い線が入る。もう一度押すと解除。
|
||||||
|
|
||||||
|
**チャットごとの設定**であることに注意。
|
||||||
|
|
||||||
|
| チャット | 📌 を付けたページ |
|
||||||
|
|---|---|
|
||||||
|
| 「新機能の相談」 | 設計メモ / APIメモ |
|
||||||
|
| 「夕飯どうする」 | 買い物リスト |
|
||||||
|
|
||||||
|
チャットを切り替えると 📌 の状態もそのチャットのものに切り替わる。新規チャットでは
|
||||||
|
📌 を付けてから最初のメッセージを送れば、その1通目からちゃんと反映される。
|
||||||
|
|
||||||
|
長いページは 1 ページ 8000 文字(`SESSION_NOTES_CONTENT_LIMIT`)で打ち切られ、
|
||||||
|
残りが必要ならAIが `read_note` で読み足せる。あれもこれも 📌 すると毎回のリクエストが
|
||||||
|
その分重くなるので、本当に読ませたいページだけに絞るのがよい。
|
||||||
|
|
||||||
|
## 手元にダウンロードする
|
||||||
|
|
||||||
|
パネル下部の ⬇ ボタンでノートをファイルとして保存できる。未保存の編集があっても、
|
||||||
|
保存してから書き出すので取りこぼしはない。
|
||||||
|
|
||||||
|
| ボタン | 出てくるもの |
|
||||||
|
|---|---|
|
||||||
|
| **⬇ このページ (.md)** | 開いているページの本文そのままの Markdown ファイル。ファイル名はページのタイトル |
|
||||||
|
| **⬇ すべて (.zip)** | 全ページをまとめた ZIP。1 ページ 1 Markdown で、セクションはフォルダになる |
|
||||||
|
|
||||||
|
ZIP の中のファイル名には `01_`, `02_` … と通し番号が付く。タブの並び順を残すためで、
|
||||||
|
展開したときも画面と同じ順番で並ぶ。ファイル名に使えない文字(`/` や `:` など)は `_` に置き換わる。
|
||||||
|
|
||||||
|
複数ページを 1 つのファイルに連結せず ZIP にしているのは、ページ内の見出し(`#`)が
|
||||||
|
ページ間の見出しと混ざって階層が壊れるのを避けるため。
|
||||||
|
|
||||||
## セクションでまとめる
|
## セクションでまとめる
|
||||||
|
|
||||||
複数のページを1つのグループとしてタブ上にまとめられる。
|
複数のページを1つのグループとしてタブ上にまとめられる。
|
||||||
@@ -51,7 +114,7 @@ API 仕様は `docs/api.md` の「共有ノート」節を参照。
|
|||||||
## AIとの連携
|
## AIとの連携
|
||||||
|
|
||||||
- 全ページの**タイトル一覧**は、会話のたびに自動でAIに渡される(セクションがあれば見出し付きで)。
|
- 全ページの**タイトル一覧**は、会話のたびに自動でAIに渡される(セクションがあれば見出し付きで)。
|
||||||
ただし本文はここには含まれない。
|
ただし本文はここには含まれない(📌 を付けたページを除く)。
|
||||||
- AIは `read_note` ツールでページ本文を読み、`write_note` ツールで追記・置き換え・新規ページ作成ができる。
|
- AIは `read_note` ツールでページ本文を読み、`write_note` ツールで追記・置き換え・新規ページ作成ができる。
|
||||||
「ノートに書いておいて」「〇〇について記録して」のように頼むと、AIが該当ページを判断して書き込む。
|
「ノートに書いておいて」「〇〇について記録して」のように頼むと、AIが該当ページを判断して書き込む。
|
||||||
- AIがノートを書き換えると、開いているノートパネルは自動的に最新の内容へ更新される。
|
- AIがノートを書き換えると、開いているノートパネルは自動的に最新の内容へ更新される。
|
||||||
|
|||||||
Generated
+26
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+15
-31
@@ -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 v-if="authStore.required" class="log-toggle" @click="authStore.logout()">
|
||||||
<button class="log-toggle" :class="{ active: activePanel === 'settings' }" @click="togglePanel('settings')">
|
ログアウト
|
||||||
設定
|
</button>
|
||||||
</button>
|
</div>
|
||||||
<button class="log-toggle" :class="{ active: activePanel === 'logs' }" @click="togglePanel('logs')">
|
|
||||||
ログ
|
|
||||||
</button>
|
|
||||||
<button v-if="authStore.required" class="log-toggle" @click="authStore.logout()">
|
|
||||||
ログアウト
|
|
||||||
</button>
|
|
||||||
</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; }
|
||||||
|
|||||||
+108
-4
@@ -21,9 +21,23 @@ export interface Source {
|
|||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ツール実行1件の進行状況。開始時に status='running' で届き、完了時に同じ id で status='done' が届く。 */
|
||||||
|
export interface ToolActivity {
|
||||||
|
id: string
|
||||||
|
/** バックエンドのツール名 (web_search / read_note / write_note / read_knowledge / save_knowledge)。 */
|
||||||
|
name?: string
|
||||||
|
/** 「Web検索」などの表示用ラベル。 */
|
||||||
|
label?: string
|
||||||
|
/** 検索クエリやページタイトルなど、何に対して実行したか。 */
|
||||||
|
detail?: string
|
||||||
|
status: 'running' | 'done' | 'error'
|
||||||
|
/** 完了時の短い結果(「5件の結果」など)。 */
|
||||||
|
result?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface SSEChunk {
|
export interface SSEChunk {
|
||||||
type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources' | 'notes_updated'
|
type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources' | 'notes_updated' | 'tool'
|
||||||
value?: string | Source[]
|
value?: string | Source[] | ToolActivity
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE = ''
|
const BASE = ''
|
||||||
@@ -61,6 +75,24 @@ export async function fetchMessages(sessionId: string): Promise<MessageRecord[]>
|
|||||||
return parseJson<MessageRecord[]>(res, 'メッセージ取得')
|
return parseJson<MessageRecord[]>(res, 'メッセージ取得')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** このセッションで本文をプロンプトに載せるよう指定されたノートのページID。 */
|
||||||
|
export async function fetchSessionNotes(sessionId: string): Promise<number[]> {
|
||||||
|
const res = await fetch(`${BASE}/chat/sessions/${sessionId}/notes`)
|
||||||
|
const body = await parseJson<{ page_ids: number[] }>(res, '参照ノート取得')
|
||||||
|
return body.page_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
/** セッションに紐付けるノートを丸ごと置き換える。戻り値は実際に紐付いたページID。 */
|
||||||
|
export async function updateSessionNotes(sessionId: string, pageIds: number[]): Promise<number[]> {
|
||||||
|
const res = await fetch(`${BASE}/chat/sessions/${sessionId}/notes`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ page_ids: pageIds }),
|
||||||
|
})
|
||||||
|
const body = await parseJson<{ page_ids: number[] }>(res, '参照ノート更新')
|
||||||
|
return body.page_ids
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteSession(sessionId: string): Promise<void> {
|
export async function deleteSession(sessionId: string): Promise<void> {
|
||||||
await fetch(`${BASE}/chat/sessions/${sessionId}`, { method: 'DELETE' })
|
await fetch(`${BASE}/chat/sessions/${sessionId}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
@@ -144,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +246,8 @@ export function streamChat(
|
|||||||
message: string,
|
message: string,
|
||||||
sessionId: string | null,
|
sessionId: string | null,
|
||||||
model: string,
|
model: string,
|
||||||
|
/** 本文をプロンプトに載せるノートのページID。新規セッションではここでしか指定できない。 */
|
||||||
|
notePageIds: number[],
|
||||||
onChunk: (chunk: SSEChunk) => void,
|
onChunk: (chunk: SSEChunk) => void,
|
||||||
onError: (err: Error) => void,
|
onError: (err: Error) => void,
|
||||||
): () => void {
|
): () => void {
|
||||||
@@ -219,7 +256,7 @@ export function streamChat(
|
|||||||
fetch(`${BASE}/chat/stream`, {
|
fetch(`${BASE}/chat/stream`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ message, session_id: sessionId, model }),
|
body: JSON.stringify({ message, session_id: sessionId, model, note_page_ids: notePageIds }),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
@@ -300,6 +337,40 @@ export async function deleteNotePage(pageId: number): Promise<void> {
|
|||||||
await fetch(`${BASE}/api/notes/${pageId}`, { method: 'DELETE' })
|
await fetch(`${BASE}/api/notes/${pageId}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ファイルをページとして取り込む。.md / .txt は1ファイル1ページ、.zip は中身を展開して複数ページ。
|
||||||
|
* 取り込めるファイルが1つも無ければサーバーが 400 を返す。
|
||||||
|
*/
|
||||||
|
export async function importNotePages(files: File[]): Promise<NotePage[]> {
|
||||||
|
const form = new FormData()
|
||||||
|
for (const file of files) form.append('files', file)
|
||||||
|
const res = await fetch(`${BASE}/api/notes/import`, { method: 'POST', body: form })
|
||||||
|
return parseJson<NotePage[]>(res, 'ノート取り込み')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ダウンロードのためだけにアンカーをクリックする。
|
||||||
|
* ファイル名はサーバーの Content-Disposition に任せたいので download 属性は付けない
|
||||||
|
* (ノートのタイトルには日本語やスラッシュが入りうるため、名前付けはサーバー側に一本化している)。
|
||||||
|
*/
|
||||||
|
function triggerDownload(url: string): void {
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
a.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1ページを Markdown ファイルとしてダウンロードする。 */
|
||||||
|
export function exportNotePage(pageId: number): void {
|
||||||
|
triggerDownload(`${BASE}/api/notes/${pageId}/export`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全ページを ZIP(1ページ1 Markdown、セクションはフォルダ)としてダウンロードする。 */
|
||||||
|
export function exportAllNotePages(): void {
|
||||||
|
triggerDownload(`${BASE}/api/notes/export`)
|
||||||
|
}
|
||||||
|
|
||||||
/** section = old のページ全体を section = new へ一括で付け替える。new が空/nullならグループ解除。 */
|
/** section = old のページ全体を section = new へ一括で付け替える。new が空/nullならグループ解除。 */
|
||||||
export async function renameNoteSection(oldName: string, newName: string | null): Promise<{ renamed: number }> {
|
export async function renameNoteSection(oldName: string, newName: string | null): Promise<{ renamed: number }> {
|
||||||
const res = await fetch(`${BASE}/api/notes/sections/rename`, {
|
const res = await fetch(`${BASE}/api/notes/sections/rename`, {
|
||||||
@@ -327,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, 'ナレッジベースファイル取得')
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, ref, watch, nextTick } from 'vue'
|
import { computed, ref, watch, nextTick } from 'vue'
|
||||||
import { useChatStore } from '../stores/chat'
|
import { useChatStore } from '../stores/chat'
|
||||||
import MarkdownContent from './MarkdownContent.vue'
|
import MarkdownContent from './MarkdownContent.vue'
|
||||||
|
import ToolActivityList from './ToolActivityList.vue'
|
||||||
import type { Source } from '../api/client'
|
import type { Source } from '../api/client'
|
||||||
|
|
||||||
const store = useChatStore()
|
const store = useChatStore()
|
||||||
@@ -43,6 +44,7 @@ watch(
|
|||||||
:class="['message-wrap', msg.role, { archived: msg.archived }]"
|
:class="['message-wrap', msg.role, { archived: msg.archived }]"
|
||||||
:title="msg.archived ? '要約済み: 内容は長期メモリに引き継がれ、この発言自体はモデルに渡りません' : undefined"
|
:title="msg.archived ? '要約済み: 内容は長期メモリに引き継がれ、この発言自体はモデルに渡りません' : undefined"
|
||||||
>
|
>
|
||||||
|
<ToolActivityList v-if="msg.tools && msg.tools.length > 0" :tools="msg.tools" />
|
||||||
<div :class="['message', msg.role, { error: msg.error }]">
|
<div :class="['message', msg.role, { error: msg.error }]">
|
||||||
<div class="bubble">
|
<div class="bubble">
|
||||||
<!-- ユーザーメッセージはプレーンテキスト -->
|
<!-- ユーザーメッセージはプレーンテキスト -->
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref, nextTick, computed } from 'vue'
|
import { onMounted, onUnmounted, ref, nextTick, computed } from 'vue'
|
||||||
import { useNotesStore } from '../stores/notes'
|
import { useNotesStore } from '../stores/notes'
|
||||||
|
import { useChatStore } from '../stores/chat'
|
||||||
import type { NotePage } from '../api/client'
|
import type { NotePage } from '../api/client'
|
||||||
|
|
||||||
interface TabGroup {
|
interface TabGroup {
|
||||||
@@ -9,7 +10,11 @@ interface TabGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const store = useNotesStore()
|
const store = useNotesStore()
|
||||||
|
const chat = useChatStore()
|
||||||
const renamingId = ref<number | null>(null)
|
const renamingId = ref<number | null>(null)
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
/** ファイルを本文エリアの上までドラッグしてきているか(取り込み用のハイライト表示) */
|
||||||
|
const fileOver = ref(false)
|
||||||
// ref="renameInput" は入れ子の v-for の中にあるため、Vue 3 では単一要素ではなく配列になる
|
// ref="renameInput" は入れ子の v-for の中にあるため、Vue 3 では単一要素ではなく配列になる
|
||||||
const renameInput = ref<HTMLInputElement[]>([])
|
const renameInput = ref<HTMLInputElement[]>([])
|
||||||
|
|
||||||
@@ -34,6 +39,40 @@ const groups = computed<TabGroup[]>(() => {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** ファイルをページとして取り込む。取り込めた件数はトーストで知らせる。 */
|
||||||
|
async function importFiles(files: FileList | File[] | null) {
|
||||||
|
const list = [...(files ?? [])]
|
||||||
|
if (list.length === 0) return
|
||||||
|
const created = await store.importFiles(list)
|
||||||
|
if (created.length > 0) {
|
||||||
|
chat.pushToast(`${created.length}ページを取り込みました: ${created.map((p) => p.title).join(', ')}`, 'success')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPickFiles(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
void importFiles(input.files)
|
||||||
|
input.value = '' // 同じファイルを続けて選び直せるようにする
|
||||||
|
}
|
||||||
|
|
||||||
|
/** タブのドラッグ&ドロップ(draggingId が立つ)とは別物。ファイルのドロップだけを拾う。 */
|
||||||
|
function isFileDrag(e: DragEvent): boolean {
|
||||||
|
return !!e.dataTransfer && [...e.dataTransfer.types].includes('Files')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileDragOver(e: DragEvent) {
|
||||||
|
if (!isFileDrag(e)) return
|
||||||
|
e.preventDefault()
|
||||||
|
fileOver.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileDrop(e: DragEvent) {
|
||||||
|
if (!isFileDrag(e)) return
|
||||||
|
e.preventDefault()
|
||||||
|
fileOver.value = false
|
||||||
|
void importFiles(e.dataTransfer!.files)
|
||||||
|
}
|
||||||
|
|
||||||
async function startRename(id: number) {
|
async function startRename(id: number) {
|
||||||
renamingId.value = id
|
renamingId.value = id
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -155,6 +194,7 @@ onUnmounted(() => {
|
|||||||
class="tab"
|
class="tab"
|
||||||
:class="{
|
:class="{
|
||||||
active: page.id === store.activeId,
|
active: page.id === store.activeId,
|
||||||
|
attached: store.attachedIds.has(page.id),
|
||||||
dragging: draggingId === page.id,
|
dragging: draggingId === page.id,
|
||||||
'drop-before': dropTarget?.id === page.id && dropTarget.place === 'before',
|
'drop-before': dropTarget?.id === page.id && dropTarget.place === 'before',
|
||||||
'drop-after': dropTarget?.id === page.id && dropTarget.place === 'after',
|
'drop-after': dropTarget?.id === page.id && dropTarget.place === 'after',
|
||||||
@@ -181,6 +221,14 @@ onUnmounted(() => {
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="tab-title" :title="page.title">{{ page.title }}</span>
|
<span class="tab-title" :title="page.title">{{ page.title }}</span>
|
||||||
<span v-if="store.dirtyIds.has(page.id)" class="dot" title="未保存">●</span>
|
<span v-if="store.dirtyIds.has(page.id)" class="dot" title="未保存">●</span>
|
||||||
|
<button
|
||||||
|
class="tab-pin"
|
||||||
|
:class="{ on: store.attachedIds.has(page.id) }"
|
||||||
|
:title="store.attachedIds.has(page.id)
|
||||||
|
? 'この会話のプロンプトに本文を載せている(クリックで解除)'
|
||||||
|
: 'この会話のプロンプトに本文を載せる'"
|
||||||
|
@click.stop="store.toggleAttached(page.id)"
|
||||||
|
>📌</button>
|
||||||
<button class="tab-tag" title="セクションに割り当て" @click.stop="assignSection(page)">🏷</button>
|
<button class="tab-tag" title="セクションに割り当て" @click.stop="assignSection(page)">🏷</button>
|
||||||
<button class="tab-del" title="削除" @click.stop="confirmRemove(page.id, page.title)">×</button>
|
<button class="tab-del" title="削除" @click.stop="confirmRemove(page.id, page.title)">×</button>
|
||||||
</template>
|
</template>
|
||||||
@@ -198,24 +246,68 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<div v-if="store.error" class="error">⚠ {{ store.error }}</div>
|
<div v-if="store.error" class="error">⚠ {{ store.error }}</div>
|
||||||
|
|
||||||
<textarea
|
<div
|
||||||
v-if="store.activePage"
|
class="body"
|
||||||
class="editor"
|
:class="{ 'file-over': fileOver }"
|
||||||
:value="store.activePage.content"
|
@dragover="onFileDragOver"
|
||||||
placeholder="ここに書いた内容はAIと共有されます。AIも read_note / write_note でこのページを読み書きできます。"
|
@dragleave="fileOver = false"
|
||||||
spellcheck="false"
|
@drop="onFileDrop"
|
||||||
@input="onContentInput"
|
>
|
||||||
@blur="store.flush()"
|
<textarea
|
||||||
/>
|
v-if="store.activePage"
|
||||||
<div v-else class="empty">
|
class="editor"
|
||||||
<p>ページがありません。</p>
|
:value="store.activePage.content"
|
||||||
<button class="create-btn" @click="store.addPage()">+ 最初のページを作成</button>
|
placeholder="ここに書いた内容はAIと共有されます。AIも read_note / write_note でこのページを読み書きできます。 .md / .txt / .zip をここにドロップすると新しいページとして取り込みます。"
|
||||||
|
spellcheck="false"
|
||||||
|
@input="onContentInput"
|
||||||
|
@blur="store.flush()"
|
||||||
|
/>
|
||||||
|
<div v-else class="empty">
|
||||||
|
<p>ページがありません。</p>
|
||||||
|
<button class="create-btn" @click="store.addPage()">+ 最初のページを作成</button>
|
||||||
|
<p class="empty-hint">.md / .txt / .zip をここにドロップしても取り込めます。</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="fileOver" class="drop-overlay">ドロップしてページとして取り込む</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
class="file-input"
|
||||||
|
multiple
|
||||||
|
accept=".md,.markdown,.txt,.text,.zip"
|
||||||
|
@change="onPickFiles"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
title=".md / .txt / .zip をページとして取り込む"
|
||||||
|
@click="fileInput?.click()"
|
||||||
|
>⬆ 取り込み</button>
|
||||||
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
:disabled="store.activeId === null"
|
||||||
|
title="開いているページを Markdown ファイルとして保存"
|
||||||
|
@click="store.activeId !== null && store.downloadPage(store.activeId)"
|
||||||
|
>⬇ このページ (.md)</button>
|
||||||
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
:disabled="store.pages.length === 0"
|
||||||
|
title="全ページを ZIP(1ページ1 Markdown、セクションはフォルダ)として保存"
|
||||||
|
@click="store.downloadAll()"
|
||||||
|
>⬇ すべて (.zip)</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hint">
|
<div class="hint">
|
||||||
ページのタイトル一覧はAIのシステムプロンプトに常に含まれます。本文はAIが必要に応じて読み出します。
|
ページのタイトル一覧はAIのシステムプロンプトに常に含まれます。本文はAIが必要に応じて読み出します。
|
||||||
🏷 でページをセクションにまとめられます(セクション名をクリックすると変更・解除)。
|
🏷 でページをセクションにまとめられます(セクション名をクリックすると変更・解除)。
|
||||||
タブはドラッグで並べ替え・他のセクションへの移動ができます。
|
タブはドラッグで並べ替え・他のセクションへの移動ができます。
|
||||||
|
⬇ でノートを手元にダウンロードできます(未保存の編集は保存してから書き出します)。
|
||||||
|
⬆ または本文エリアへのドロップで .md / .txt / .zip を取り込めます(⬇ で出した ZIP を戻せばセクションごと復元されます)。
|
||||||
|
<span class="hint-pin">
|
||||||
|
📌 を付けたページは、<strong>今開いているチャット</strong>のプロンプトに本文がそのまま載ります
|
||||||
|
(AIが read_note で読みに行くのを待たずに確実に参照されます)。指定はチャットごとに保存されます。
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -279,6 +371,8 @@ onUnmounted(() => {
|
|||||||
max-width: 160px;
|
max-width: 160px;
|
||||||
}
|
}
|
||||||
.tab.active { background: #2a2a2a; color: #fff; border-color: #444; }
|
.tab.active { background: #2a2a2a; color: #fff; border-color: #444; }
|
||||||
|
/* プロンプトに本文を載せているページ。タブ上端の線で「積んでいる」ことを示す */
|
||||||
|
.tab.attached { box-shadow: inset 0 2px 0 #eab308; }
|
||||||
.tab.dragging { opacity: 0.4; }
|
.tab.dragging { opacity: 0.4; }
|
||||||
.tab.drop-before { box-shadow: inset 2px 0 0 #93c5fd; }
|
.tab.drop-before { box-shadow: inset 2px 0 0 #93c5fd; }
|
||||||
.tab.drop-after { box-shadow: inset -2px 0 0 #93c5fd; }
|
.tab.drop-after { box-shadow: inset -2px 0 0 #93c5fd; }
|
||||||
@@ -292,7 +386,7 @@ onUnmounted(() => {
|
|||||||
.drop-end.active { background: #93c5fd; opacity: 0.5; }
|
.drop-end.active { background: #93c5fd; opacity: 0.5; }
|
||||||
.tab-title { overflow: hidden; text-overflow: ellipsis; }
|
.tab-title { overflow: hidden; text-overflow: ellipsis; }
|
||||||
.dot { color: #eab308; font-size: 8px; }
|
.dot { color: #eab308; font-size: 8px; }
|
||||||
.tab-tag, .tab-del, .tab-add {
|
.tab-pin, .tab-tag, .tab-del, .tab-add {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: #666;
|
color: #666;
|
||||||
@@ -301,9 +395,13 @@ onUnmounted(() => {
|
|||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.tab-tag { font-size: 11px; opacity: 0; transition: opacity 0.15s; }
|
.tab-pin, .tab-tag { font-size: 11px; opacity: 0; transition: opacity 0.15s; }
|
||||||
|
.tab:hover .tab-pin, .tab.active .tab-pin,
|
||||||
.tab:hover .tab-tag, .tab.active .tab-tag { opacity: 0.8; }
|
.tab:hover .tab-tag, .tab.active .tab-tag { opacity: 0.8; }
|
||||||
.tab-tag:hover { opacity: 1 !important; }
|
.tab-pin:hover, .tab-tag:hover { opacity: 1 !important; }
|
||||||
|
/* 載せているページのピンは、タブが非アクティブでも常に見えるようにする */
|
||||||
|
.tab-pin.on { opacity: 1; filter: none; }
|
||||||
|
.tab-pin:not(.on) { filter: grayscale(1); }
|
||||||
.tab-del:hover, .tab-add:hover { color: #f87171; }
|
.tab-del:hover, .tab-add:hover { color: #f87171; }
|
||||||
.tab-add { color: #888; padding: 0 8px; align-self: center; }
|
.tab-add { color: #888; padding: 0 8px; align-self: center; }
|
||||||
.tab-add:hover { color: #93c5fd; }
|
.tab-add:hover { color: #93c5fd; }
|
||||||
@@ -323,6 +421,25 @@ onUnmounted(() => {
|
|||||||
background: #2a1414;
|
background: #2a1414;
|
||||||
border-bottom: 1px solid #7f1d1d;
|
border-bottom: 1px solid #7f1d1d;
|
||||||
}
|
}
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.body.file-over { outline: 2px dashed #93c5fd; outline-offset: -6px; }
|
||||||
|
.drop-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(22, 22, 22, 0.85);
|
||||||
|
color: #93c5fd;
|
||||||
|
font-size: 13px;
|
||||||
|
pointer-events: none; /* dragleave が誤発火しないように、当たり判定を持たせない */
|
||||||
|
}
|
||||||
|
.file-input { display: none; }
|
||||||
.editor {
|
.editor {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
resize: none;
|
resize: none;
|
||||||
@@ -336,6 +453,7 @@ onUnmounted(() => {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
.editor::placeholder { color: #555; }
|
.editor::placeholder { color: #555; }
|
||||||
|
.empty-hint { color: #555; font-size: 11px; }
|
||||||
.empty {
|
.empty {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -355,6 +473,28 @@ onUnmounted(() => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-top: 1px solid #2a2a2a;
|
||||||
|
}
|
||||||
|
.action-btn {
|
||||||
|
background: #1f1f1f;
|
||||||
|
color: #aaa;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.action-btn:hover:not(:disabled) { background: #2a2a2a; color: #93c5fd; border-color: #444; }
|
||||||
|
.action-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.hint-pin { display: block; margin-top: 4px; color: #8a7a3a; }
|
||||||
|
.hint-pin strong { color: #eab308; font-weight: 600; }
|
||||||
.hint {
|
.hint {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -9,10 +9,10 @@ const store = useChatStore()
|
|||||||
<div
|
<div
|
||||||
v-for="toast in store.toasts"
|
v-for="toast in store.toasts"
|
||||||
:key="toast.id"
|
:key="toast.id"
|
||||||
class="toast"
|
:class="['toast', toast.kind]"
|
||||||
@click="store.dismissToast(toast.id)"
|
@click="store.dismissToast(toast.id)"
|
||||||
>
|
>
|
||||||
<span class="icon">⚠</span>
|
<span class="icon">{{ toast.kind === 'success' ? '✓' : '⚠' }}</span>
|
||||||
<span class="msg">{{ toast.message }}</span>
|
<span class="msg">{{ toast.message }}</span>
|
||||||
<button class="close">✕</button>
|
<button class="close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,9 +46,10 @@ const store = useChatStore()
|
|||||||
animation: slide-in 0.2s ease;
|
animation: slide-in 0.2s ease;
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||||
}
|
}
|
||||||
|
.toast.success { background: #0a1f14; border-color: #166534; color: #6ee7b7; }
|
||||||
.icon { flex-shrink: 0; }
|
.icon { flex-shrink: 0; }
|
||||||
.msg { flex: 1; word-break: break-word; }
|
.msg { flex: 1; word-break: break-word; }
|
||||||
.close { background: none; border: none; color: #f87171; cursor: pointer; font-size: 11px; padding: 0; }
|
.close { background: none; border: none; color: inherit; cursor: pointer; font-size: 11px; padding: 0; }
|
||||||
@keyframes slide-in {
|
@keyframes slide-in {
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/** アシスタント応答の生成中に実行したツール(Web検索・ノート読み書きなど)を明示的に見せるリスト。 */
|
||||||
|
import type { ToolActivity } from '../api/client'
|
||||||
|
|
||||||
|
defineProps<{ tools: ToolActivity[] }>()
|
||||||
|
|
||||||
|
const ICONS: Record<string, string> = {
|
||||||
|
web_search: '🔍',
|
||||||
|
read_note: '📖',
|
||||||
|
write_note: '✍️',
|
||||||
|
read_knowledge: '🧠',
|
||||||
|
save_knowledge: '💾',
|
||||||
|
}
|
||||||
|
|
||||||
|
function icon(tool: ToolActivity): string {
|
||||||
|
return ICONS[tool.name ?? ''] ?? '🛠'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tool-activity">
|
||||||
|
<div v-for="tool in tools" :key="tool.id" :class="['tool-row', tool.status]">
|
||||||
|
<span class="icon">{{ icon(tool) }}</span>
|
||||||
|
<span class="label">{{ tool.label || tool.name }}</span>
|
||||||
|
<span v-if="tool.detail" class="detail" :title="tool.detail">{{ tool.detail }}</span>
|
||||||
|
<span v-if="tool.status === 'running'" class="spinner" aria-label="実行中" />
|
||||||
|
<span v-else class="result">{{ tool.status === 'error' ? '⚠' : '✓' }} {{ tool.result }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tool-activity {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
max-width: 72%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-left: 2px solid #3f6212;
|
||||||
|
background: #1b2114;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.tool-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
color: #a3a3a3;
|
||||||
|
}
|
||||||
|
.tool-row.running .label { color: #d9f99d; }
|
||||||
|
.icon { flex: none; }
|
||||||
|
.label { flex: none; color: #bef264; }
|
||||||
|
.detail {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #d4d4d4;
|
||||||
|
background: #262f1a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
}
|
||||||
|
.result { flex: none; color: #86efac; }
|
||||||
|
.tool-row.error .result { color: #f87171; }
|
||||||
|
.spinner {
|
||||||
|
flex: none;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border: 2px solid #3f6212;
|
||||||
|
border-top-color: #bef264;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Component } from 'vue'
|
||||||
|
import KnowledgeBasePanel from './components/KnowledgeBasePanel.vue'
|
||||||
|
import LogViewer from './components/LogViewer.vue'
|
||||||
|
import NotePanel from './components/NotePanel.vue'
|
||||||
|
import SettingsPanel from './components/SettingsPanel.vue'
|
||||||
|
|
||||||
|
/** 右サイドに開けるパネル。'none' はどれも開いていない状態。 */
|
||||||
|
export type PanelId = 'notes' | 'kb' | 'settings' | 'logs'
|
||||||
|
|
||||||
|
export interface SidePanelDef {
|
||||||
|
id: PanelId
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
/** メニューに添える一行説明 */
|
||||||
|
description: string
|
||||||
|
component: Component
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 右サイドパネルの一覧。ヘッダーのメニューの並びと、開いたときに描画されるコンポーネントを兼ねる。
|
||||||
|
* パネルを増やすときはここに1行足すだけでよい。
|
||||||
|
*/
|
||||||
|
export const SIDE_PANELS: SidePanelDef[] = [
|
||||||
|
{ id: 'notes', label: 'ノート', icon: '📝', description: 'AIと共有するノートの編集', component: NotePanel },
|
||||||
|
{ id: 'kb', label: 'ナレッジベース', icon: '📚', description: 'knowledge_base/ の Markdown を閲覧', component: KnowledgeBasePanel },
|
||||||
|
{ id: 'settings', label: '設定', icon: '⚙️', description: 'APIキー・モデルプリセット', component: SettingsPanel },
|
||||||
|
{ id: 'logs', label: 'ログ', icon: '📜', description: 'LLM API 呼び出しの履歴', component: LogViewer },
|
||||||
|
]
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { useNotesStore } from './notes'
|
import { useNotesStore } from './notes'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { fetchSessions, fetchMessages, deleteSession, exportSession, importSession, streamChat, type SessionInfo, type Source, type ImportPayload } from '../api/client'
|
import { fetchSessions, fetchMessages, deleteSession, exportSession, importSession, streamChat, type SessionInfo, type Source, type ToolActivity, type ImportPayload } from '../api/client'
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
@@ -11,11 +11,14 @@ export interface Message {
|
|||||||
archived?: boolean
|
archived?: boolean
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
sources?: Source[]
|
sources?: Source[]
|
||||||
|
/** この応答を作る際に実行したツール(Web検索・ノート読み書きなど)。表示専用で永続化はしない。 */
|
||||||
|
tools?: ToolActivity[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Toast {
|
export interface Toast {
|
||||||
id: number
|
id: number
|
||||||
message: string
|
message: string
|
||||||
|
kind: 'error' | 'success'
|
||||||
}
|
}
|
||||||
|
|
||||||
let toastSeq = 0
|
let toastSeq = 0
|
||||||
@@ -32,9 +35,9 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
sessions.value.find((s) => s.id === currentSessionId.value) ?? null,
|
sessions.value.find((s) => s.id === currentSessionId.value) ?? null,
|
||||||
)
|
)
|
||||||
|
|
||||||
function pushToast(message: string, durationMs = 5000) {
|
function pushToast(message: string, kind: Toast['kind'] = 'error', durationMs = 5000) {
|
||||||
const id = ++toastSeq
|
const id = ++toastSeq
|
||||||
toasts.value.push({ id, message })
|
toasts.value.push({ id, message, kind })
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
toasts.value = toasts.value.filter((t) => t.id !== id)
|
toasts.value = toasts.value.filter((t) => t.id !== id)
|
||||||
}, durationMs)
|
}, durationMs)
|
||||||
@@ -51,6 +54,8 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
async function selectSession(sessionId: string | null) {
|
async function selectSession(sessionId: string | null) {
|
||||||
currentSessionId.value = sessionId
|
currentSessionId.value = sessionId
|
||||||
messages.value = []
|
messages.value = []
|
||||||
|
// プロンプトに載せるノートはセッションごとの設定なので、切り替えのたびに読み直す
|
||||||
|
useNotesStore().loadAttached(sessionId).catch(() => {})
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
try {
|
try {
|
||||||
const records = await fetchMessages(sessionId)
|
const records = await fetchMessages(sessionId)
|
||||||
@@ -96,12 +101,32 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
const session = await importSession(payload)
|
const session = await importSession(payload)
|
||||||
await loadSessions()
|
await loadSessions()
|
||||||
await selectSession(session.id)
|
await selectSession(session.id)
|
||||||
pushToast(`「${session.title}」をインポートしました`)
|
pushToast(`「${session.title}」をインポートしました`, 'success')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
pushToast(`インポート失敗: ${e instanceof Error ? e.message : e}`)
|
pushToast(`インポート失敗: ${e instanceof Error ? e.message : e}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** tool イベントを同じ id の行にマージする(開始時に追加し、完了時に上書きする)。 */
|
||||||
|
function mergeTool(msgIdx: number, activity: ToolActivity) {
|
||||||
|
const msg = messages.value[msgIdx]
|
||||||
|
if (!msg) return
|
||||||
|
const tools = msg.tools ?? (msg.tools = [])
|
||||||
|
const existing = tools.find((t) => t.id === activity.id)
|
||||||
|
if (existing) Object.assign(existing, activity)
|
||||||
|
else tools.push({ ...activity })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ストリームが終わった時点で running のまま残っているツールを中断扱いにする。 */
|
||||||
|
function settleTools(msgIdx: number) {
|
||||||
|
for (const tool of messages.value[msgIdx]?.tools ?? []) {
|
||||||
|
if (tool.status === 'running') {
|
||||||
|
tool.status = 'error'
|
||||||
|
tool.result = '中断されました'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function sendMessage(text: string): void {
|
function sendMessage(text: string): void {
|
||||||
if (isStreaming.value || !text.trim()) return
|
if (isStreaming.value || !text.trim()) return
|
||||||
|
|
||||||
@@ -110,26 +135,35 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
const msgIdx = messages.value.length - 1 // reactive proxy 経由で更新するためインデックスを保持
|
const msgIdx = messages.value.length - 1 // reactive proxy 経由で更新するためインデックスを保持
|
||||||
isStreaming.value = true
|
isStreaming.value = true
|
||||||
|
|
||||||
|
const notesStore = useNotesStore()
|
||||||
streamChat(
|
streamChat(
|
||||||
text,
|
text,
|
||||||
currentSessionId.value,
|
currentSessionId.value,
|
||||||
selectedModel.value,
|
selectedModel.value,
|
||||||
|
[...notesStore.attachedIds],
|
||||||
(chunk) => {
|
(chunk) => {
|
||||||
if (chunk.type === 'session_id' && chunk.value) {
|
if (chunk.type === 'session_id' && chunk.value) {
|
||||||
currentSessionId.value = chunk.value as string
|
currentSessionId.value = chunk.value as string
|
||||||
|
// 新規セッションはこの時点で作られる。送信時に渡したノートの紐付けが
|
||||||
|
// サーバー側に保存されているので、その保存先を控え直す
|
||||||
|
notesStore.loadAttached(chunk.value as string).catch(() => {})
|
||||||
loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`))
|
loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`))
|
||||||
} else if (chunk.type === 'chunk' && chunk.value) {
|
} else if (chunk.type === 'chunk' && chunk.value) {
|
||||||
messages.value[msgIdx].content += chunk.value as string
|
messages.value[msgIdx].content += chunk.value as string
|
||||||
} else if (chunk.type === 'sources' && chunk.value) {
|
} else if (chunk.type === 'sources' && chunk.value) {
|
||||||
messages.value[msgIdx].sources = chunk.value as Source[]
|
messages.value[msgIdx].sources = chunk.value as Source[]
|
||||||
|
} else if (chunk.type === 'tool' && chunk.value) {
|
||||||
|
mergeTool(msgIdx, chunk.value as ToolActivity)
|
||||||
} else if (chunk.type === 'notes_updated') {
|
} else if (chunk.type === 'notes_updated') {
|
||||||
// AIが共有ノートを書き換えたので、開いているノートパネルに反映する
|
// AIが共有ノートを書き換えたので、開いているノートパネルに反映する
|
||||||
useNotesStore().load().catch((e) => pushToast(`ノート更新失敗: ${e.message}`))
|
useNotesStore().load().catch((e) => pushToast(`ノート更新失敗: ${e.message}`))
|
||||||
} else if (chunk.type === 'done') {
|
} else if (chunk.type === 'done') {
|
||||||
|
settleTools(msgIdx)
|
||||||
messages.value[msgIdx].pending = false
|
messages.value[msgIdx].pending = false
|
||||||
isStreaming.value = false
|
isStreaming.value = false
|
||||||
loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`))
|
loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`))
|
||||||
} else if (chunk.type === 'error') {
|
} else if (chunk.type === 'error') {
|
||||||
|
settleTools(msgIdx)
|
||||||
messages.value[msgIdx].content = (chunk.value as string) ?? 'バックエンドでエラーが発生しました。'
|
messages.value[msgIdx].content = (chunk.value as string) ?? 'バックエンドでエラーが発生しました。'
|
||||||
messages.value[msgIdx].pending = false
|
messages.value[msgIdx].pending = false
|
||||||
messages.value[msgIdx].error = true
|
messages.value[msgIdx].error = true
|
||||||
@@ -137,6 +171,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
(err) => {
|
(err) => {
|
||||||
|
settleTools(msgIdx)
|
||||||
// 途中まで届いていた内容は残しつつ、エラーメッセージを付記する
|
// 途中まで届いていた内容は残しつつ、エラーメッセージを付記する
|
||||||
const partial = messages.value[msgIdx].content
|
const partial = messages.value[msgIdx].content
|
||||||
messages.value[msgIdx].content = partial ? `${partial}\n\n⚠️ ${err.message}` : err.message
|
messages.value[msgIdx].content = partial ? `${partial}\n\n⚠️ ${err.message}` : err.message
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -5,8 +5,13 @@ import {
|
|||||||
createNotePage,
|
createNotePage,
|
||||||
updateNotePage,
|
updateNotePage,
|
||||||
deleteNotePage,
|
deleteNotePage,
|
||||||
|
exportNotePage,
|
||||||
|
exportAllNotePages,
|
||||||
renameNoteSection,
|
renameNoteSection,
|
||||||
reorderNotePages,
|
reorderNotePages,
|
||||||
|
fetchSessionNotes,
|
||||||
|
updateSessionNotes,
|
||||||
|
importNotePages,
|
||||||
type NotePage,
|
type NotePage,
|
||||||
} from '../api/client'
|
} from '../api/client'
|
||||||
|
|
||||||
@@ -18,6 +23,13 @@ export const useNotesStore = defineStore('notes', () => {
|
|||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
/** 未保存の編集があるページID。AIによる更新で上書きしないために使う。 */
|
/** 未保存の編集があるページID。AIによる更新で上書きしないために使う。 */
|
||||||
const dirtyIds = ref<Set<number>>(new Set())
|
const dirtyIds = ref<Set<number>>(new Set())
|
||||||
|
/**
|
||||||
|
* 本文をシステムプロンプトに載せるページID。チャットセッションごとの設定で、
|
||||||
|
* セッションを切り替えるたびに loadAttached() で読み直す。
|
||||||
|
*/
|
||||||
|
const attachedIds = ref<Set<number>>(new Set())
|
||||||
|
/** attachedIds がどのセッションのものか。null は「まだ保存先が無い新規チャット」。 */
|
||||||
|
const attachedSessionId = ref<string | null>(null)
|
||||||
|
|
||||||
let saveTimer: number | null = null
|
let saveTimer: number | null = null
|
||||||
|
|
||||||
@@ -97,11 +109,50 @@ export const useNotesStore = defineStore('notes', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** セッションに紐付いたページを読み込む。sessionId が null(新規チャット)なら選択を空にする。 */
|
||||||
|
async function loadAttached(sessionId: string | null): Promise<void> {
|
||||||
|
attachedSessionId.value = sessionId
|
||||||
|
if (sessionId === null) {
|
||||||
|
attachedIds.value = new Set()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
attachedIds.value = new Set(await fetchSessionNotes(sessionId))
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '参照ノートの取得に失敗しました'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ページをプロンプトに載せる/載せないを切り替える。
|
||||||
|
* まだセッションが無い新規チャットではローカルに保持するだけで、最初の送信時にまとめて保存される。
|
||||||
|
*/
|
||||||
|
async function toggleAttached(id: number): Promise<void> {
|
||||||
|
const next = new Set(attachedIds.value)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
attachedIds.value = next
|
||||||
|
|
||||||
|
const sessionId = attachedSessionId.value
|
||||||
|
if (sessionId === null) return
|
||||||
|
try {
|
||||||
|
attachedIds.value = new Set(await updateSessionNotes(sessionId, [...next]))
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '参照ノートの更新に失敗しました'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function removePage(id: number): Promise<void> {
|
async function removePage(id: number): Promise<void> {
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
await deleteNotePage(id)
|
await deleteNotePage(id)
|
||||||
dirtyIds.value.delete(id)
|
dirtyIds.value.delete(id)
|
||||||
|
// 紐付けはサーバー側でもカスケード削除される
|
||||||
|
if (attachedIds.value.has(id)) {
|
||||||
|
const next = new Set(attachedIds.value)
|
||||||
|
next.delete(id)
|
||||||
|
attachedIds.value = next
|
||||||
|
}
|
||||||
pages.value = pages.value.filter((p) => p.id !== id)
|
pages.value = pages.value.filter((p) => p.id !== id)
|
||||||
if (activeId.value === id) activeId.value = pages.value[0]?.id ?? null
|
if (activeId.value === id) activeId.value = pages.value[0]?.id ?? null
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -140,13 +191,46 @@ export const useNotesStore = defineStore('notes', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保留中の保存を直ちに実行する(タブ切替・パネルを閉じる時など)。 */
|
/**
|
||||||
function flush(): void {
|
* 保留中の保存を直ちに実行する(タブ切替・パネルを閉じる時など)。
|
||||||
|
* await すれば保存の完了まで待てる(save() は自分でエラーを握るので reject はしない)。
|
||||||
|
*/
|
||||||
|
function flush(): Promise<void> {
|
||||||
if (saveTimer !== null) {
|
if (saveTimer !== null) {
|
||||||
clearTimeout(saveTimer)
|
clearTimeout(saveTimer)
|
||||||
saveTimer = null
|
saveTimer = null
|
||||||
}
|
}
|
||||||
for (const id of [...dirtyIds.value]) void save(id)
|
return Promise.all([...dirtyIds.value].map((id) => save(id))).then(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ファイルをページとして取り込み、最初に作られたページを開く。
|
||||||
|
* 戻り値は作成されたページ(失敗時は空配列)。
|
||||||
|
*/
|
||||||
|
async function importFiles(files: File[]): Promise<NotePage[]> {
|
||||||
|
error.value = null
|
||||||
|
await flush()
|
||||||
|
try {
|
||||||
|
const created = await importNotePages(files)
|
||||||
|
await load()
|
||||||
|
if (created.length > 0) activeId.value = created[0].id
|
||||||
|
return created
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'ファイルの取り込みに失敗しました'
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 編集中の内容を保存してから、そのページを Markdown としてダウンロードする。 */
|
||||||
|
async function downloadPage(id: number): Promise<void> {
|
||||||
|
await flush()
|
||||||
|
exportNotePage(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 編集中の内容を保存してから、全ページを ZIP としてダウンロードする。 */
|
||||||
|
async function downloadAll(): Promise<void> {
|
||||||
|
await flush()
|
||||||
|
exportAllNotePages()
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -155,6 +239,10 @@ export const useNotesStore = defineStore('notes', () => {
|
|||||||
activePage,
|
activePage,
|
||||||
error,
|
error,
|
||||||
dirtyIds,
|
dirtyIds,
|
||||||
|
attachedIds,
|
||||||
|
attachedSessionId,
|
||||||
|
loadAttached,
|
||||||
|
toggleAttached,
|
||||||
load,
|
load,
|
||||||
selectPage,
|
selectPage,
|
||||||
addPage,
|
addPage,
|
||||||
@@ -164,5 +252,8 @@ export const useNotesStore = defineStore('notes', () => {
|
|||||||
renameSection,
|
renameSection,
|
||||||
reorder,
|
reorder,
|
||||||
flush,
|
flush,
|
||||||
|
importFiles,
|
||||||
|
downloadPage,
|
||||||
|
downloadAll,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
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'],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user