Author SHA1 Message Date
colcaroli 904458e7df Merge pull request 'Re-embed the knowledge base when the embedding model changes' (#7) from feature/embedding-model-management into main
Reviewed-on: #7
2026-08-23 14:05:26 +00:00
colcaroliandClaude Opus 5 97568ba90e Re-embed the knowledge base when the embedding model changes
Vectors from two different embedding models are not comparable — Titan v2 has
1024 dimensions where nomic-embed-text has 768 — and _cosine_similarity zips
them, so a mismatch produced quietly meaningless scores rather than an error.
Nothing forced a re-embed either: sync_local_kb only looked at the content
hash, so switching models left every cached vector in place.

local_knowledge_docs now records the model each vector came from, and a row is
re-embedded when its content hash OR its model differs. Saving a new
LOCAL_KB_EMBEDDING_MODEL from the settings panel starts a background pass over
every file; the knowledge base panel also has a button for it. While that pass
runs, the per-request sync steps aside so no file is embedded twice.

The panel now shows what the embedding model is doing: model name, vector
size, how many files are embedded with the current model, how many are still
on an older one, a progress bar while a pass runs, and the last error. A
"接続テスト" button embeds one short string so a broken or unreachable model
can be told apart from one that is merely slow to warm up.

Also read TAVILY_API_KEY from os.environ at call time. It was imported as a
config constant, which is evaluated before load_into_env() runs, so a key
saved from the settings panel was stored but never used — not even after a
restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 22:56:12 +09:00
colcaroli 52dfc0c349 Merge pull request 'Set the search summary model from the settings panel' (#6) from feature/configurable-summary-model into main
Reviewed-on: #6
2026-08-23 13:11:18 +00:00
colcaroliandClaude Opus 5 cd38cbf19d Set the search summary model from the settings panel
Summarizing web search results falls back to DEFAULT_MODEL, so a local Ollama
model ends up doing it and the call can fail — which surfaces only as
"保存できる要約なし" in the chat. Make SEARCH_SUMMARY_MODEL settable at runtime:
add it to the settings allowlist and read it from os.environ on each call
(knowledge._summary_model), the way LOCAL_KB_EMBEDDING_MODEL already works.
Importing it as a config constant would keep the old value until a restart.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 22:10:36 +09:00
colcaroli 2ced2e3cd5 Merge pull request 'Browse the knowledge base from a single side panel menu' (#5) from feature/knowledge-base-panel into main
Reviewed-on: #5
2026-08-23 12:41:29 +00:00
colcaroliandClaude Opus 5 f085db421c Browse the knowledge base from a single side panel menu
Add a read-only view of backend/knowledge_base/**.md so the files that get
injected into the system prompt can be inspected from the GUI:

- GET /api/kb/files lists the Markdown files (title, preview, size, mtime and
  whether the file is already embedded), GET /api/kb/file returns one in full.
  Paths are resolved against the knowledge base directory and rejected unless
  they stay inside it and end in .md.
- KnowledgeBasePanel.vue groups the files by folder (collapsible, so web_search
  can be folded away), filters by title/path/preview and renders the selected
  file with MarkdownContent.

The header had grown to four side panel toggles, so collapse them into one
menu: panels.ts now holds the list of panels along with the component each one
renders, App.vue picks it with <component :is>, and SidePanelMenu.vue shows the
active panel on its button. Re-picking the open panel (or "パネルを閉じる")
closes it; a click outside or Esc closes just the menu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:38:54 +09:00
colcaroli 5d63a59b86 Merge pull request 'Render math in chat messages with KaTeX' (#4) from feature/katex-math-rendering into main
Reviewed-on: #4
2026-08-23 11:13:00 +00:00
colcaroli 8f9458170e Merge branch 'main' into feature/katex-math-rendering 2026-08-23 11:12:14 +00:00
colcaroliandClaude Opus 5 4882d291be Render math in chat messages with KaTeX
Add a marked extension that tokenizes math delimiters and renders them
with KaTeX: `$$ ... $$` / `\[ ... \]` as block, `$ ... $` / `\( ... \)`
and mid-paragraph `$$ ... $$` as inline.

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:10:03 +09:00
colcaroli 6f9fc10fa1 Merge pull request 'Feature/tool activity and notes' (#3) from feature/tool-activity-and-notes into main
Reviewed-on: #3
2026-08-23 07:06:36 +00:00
colcaroliandClaude Opus 5 0e58a07af4 Pin notes to a chat, and import notes from files
Two additions to the shared notes, both about getting content in and out of
the prompt.

Per-chat pinning. Notes normally reach the model as a title list only, and
the body arrives just if the model decides to call read_note. A 📌 on the tab
now puts a page's full body in the system prompt for the chat that is open,
every turn, no tool round trip. The selection is per session (new table
session_note_pages, unique on session_id + page_id), so different
conversations carry different pages. The title list marks pinned pages so the
model does not read them twice, and bodies are capped per page by
SESSION_NOTES_CONTENT_LIMIT.

A new chat has no session row until its first message, so the front end
cannot PUT the selection yet. POST /chat/stream therefore accepts
note_page_ids and applies it before building the context, which makes the
pins count from the very first turn.

File import. Dropping .md/.txt on the editor - or picking them with the new
⬆ button - creates pages, and a .zip is unpacked into one page per text
entry with the first path component as the section. That is exactly the
shape the export writes, so a downloaded zip round-trips back in. Titles that
collide get a " (2)" suffix rather than overwriting, since restoring a backup
into a live workspace should not destroy anything. UploadFile needs
python-multipart, added to the lockfile.

Toasts grew a kind: the import success message was rendering in the red error
toast, as was the existing session-import message. Both are green now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 16:04:41 +09:00
colcaroliandClaude Opus 5 216d38c7bd Let users download shared notes as Markdown
The shared notes had no way out: everything lived in the SQLite row and the
only reader was the panel itself. Add two export endpoints and buttons for
them at the bottom of the note panel.

GET /api/notes/{page_id}/export returns one page as .md, and
GET /api/notes/export returns every page as a zip laid out as
<section>/<NN>_<title>.md. Pages are not concatenated into a single file on
purpose - the headings inside a page would collide with whatever heading
level we used as a page separator, so each page stays a verbatim file and the
numeric prefix carries the tab order instead.

Titles reach the filesystem here, so strip the Windows-forbidden characters
and control characters, cap the length, and send Content-Disposition with an
RFC 5987 filename* so Japanese titles survive. The front end deliberately
omits the anchor's download attribute to let that name win.

notes store: flush() now returns a promise so the download can wait for
pending edits to be saved before asking the server for the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:33:08 +09:00
colcaroliandClaude Opus 5 f89bc9bc9a Deny Claude Code access to .env files
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:11:07 +09:00
colcaroliandClaude Opus 5 366320c43a Show tool and web search activity in the chat UI
Tool calls were invisible until the answer arrived: the only trace was the
sources list, and only for web searches. Surface them as they happen instead.

Add llm.stream_tool_calls(), an incremental version of resolve_tool_calls()
that yields a start and a completion event per tool call and ends with a
"resolved" event carrying the previous return value. resolve_tool_calls() is
kept as a thin wrapper that drops the progress events.

api/chat.py relays those events as a new SSE "tool" event, and wraps the
search-summary/knowledge-save step in one of the same shape, since it is a
noticeable pause of its own.

On the front end, ToolActivityList.vue renders one row per tool above the
assistant bubble (label, target, spinner while running, short result when
done). The store merges events by id and marks anything still running as
interrupted when the stream ends early. Display only - not persisted, so the
rows disappear when the session is reloaded, same as sources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:11:00 +09:00
colcaroli 8cac5d0de0 Merge pull request 'Feature/knowledge index and timezone' (#2) from feature/knowledge-index-and-timezone into main
Reviewed-on: #2
2026-08-20 06:00:21 +00:00
colcaroliandClaude Opus 5 762026aa2c Drop optional lightningcss entries from the frontend lockfile
npm install(npm 11 / Node 24)を実行した際の再解決で、lightningcss と
その各プラットフォーム向けバイナリ、detect-libc のエントリが外れた。
いずれも vite の optional な peer 依存で、実際には使われていない。

package.json は変更しておらず、追加・バージョン更新も無い(286行の削除のみ)。
この状態で npm run type-check(vue-tsc)が通ることは確認済み。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:57:30 +09:00
colcaroliandClaude Opus 5 2ee7238afb Archive summarized messages instead of deleting them
要約で長期メモリに移したメッセージをDBから物理削除していたため、
一度要約されると人間も内容を確認できなくなっていた。削除をやめ、
messages に archived_at(要約に取り込まれた時刻)と memory_id
(どの要約に取り込まれたか)を持たせる。

モデルからは従来どおり見えない:
- get_context_messages() がアーカイブ済みを除外する
- maybe_summarize() の閾値判定も現役メッセージだけを数える
  (ここを除外し忘れると件数が減らず再要約が繰り返される)
- backfill_tags() もアーカイブ済みは対象外にする

人間からは確認できる:
- GET /chat/sessions/{id}/messages がアーカイブ済みも archived_at
  付きで返す。export/import もアーカイブ状態を保持する
- チャット画面では半透明で表示し、最後のアーカイブ済みメッセージの
  直後に「ここから先がモデルに渡る」区切り線を出す

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:33:18 +09:00
colcaroliandClaude Opus 5 be3af44891 Store timestamps as UTC-aware and slim the knowledge context to headlines
タイムゾーン:
SQLite の DATETIME は tzinfo を保存できず、DateTime(timezone=True) を
指定してもバインド時に捨てられるため、API のレスポンスがオフセット無しの
ISO 文字列になりフロントがローカル時刻として誤解していた。UtcDateTime
(TypeDecorator) を追加し、書き込み時は naive な UTC に正規化、読み出し時は
UTC の aware として返すようにする。格納フォーマットは変わらないので既存
データはそのまま正しく解釈でき、マイグレーションは不要。
コンテナは TZ 未設定で UTC だったため、天気通知の cron 時刻・深夜スキップ
判定・「明日」の日付計算が9時間ずれていた。TZ=Asia/Tokyo を指定し、slim
イメージに無い tzdata を導入する。

蓄積知識:
層1が最新15件の要約全文を毎リクエスト無条件に注入しており、同じ内容が
ローカルKB(層3)にも保存されているため重複していた。層1を「クエリ+
一行要約」の目次に変え、本文は read_knowledge ツールで読み出す形にする。
一行要約は要約生成時の既存 LLM 呼び出しに相乗りするので呼び出し回数は
増えない。headline を持たない既存エントリは本文の先頭行から導出する。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:22:59 +09:00
colcaroliandClaude Opus 5 8b2231ea97 Add drag-and-drop reordering for note tabs, and a user-facing guide
Tabs can now be dragged to reorder within a group and to move between
sections in one gesture: dropping on a tab's left/right half inserts
before/after it and adopts that tab's section, while a dedicated zone
past the last group ungroups and appends at the end. A blue indicator
line shows the drop position while dragging.

core/notes.reorder() takes the full new page-id order computed
client-side (NotePanel.vue has the reactive list already) rather than
recomputing positions server-side, validates it against the current
page-id set, sets the moved page's section, then renumbers position.
POST /api/notes/reorder exposes it.

Also adds docs/notes-guide.md, an end-user walkthrough of the shared
notes panel (pages, sections, drag-and-drop, AI interaction) — the
existing docs were all developer-facing specs with no operating
instructions for the feature itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:27:38 +09:00
colcaroliandClaude Opus 5 1c21fc3406 Add section grouping to shared note tabs
Notes can now be clustered into named sections in the tab bar: each tab
gets a 🏷 button to assign it to a section, and clicking a section's
label renames (or, given an empty name, ungroups) every page in it.

note_pages gains a `section` column. Since the app has no migration
framework, database.py adds the column to existing SQLite files at
startup via PRAGMA table_info + ALTER TABLE. core/notes.set_section()
keeps same-section pages contiguous by renumbering position whenever a
page's section changes, so the tab bar and the AI-facing title list
(now grouped under "## section" headings) can both treat "adjacent" as
"same group" without a separate ordering field.

Also fixes a bug in the tab rename input: ref="renameInput" sits inside
a nested v-for, which Vue 3 binds as an array rather than a single
element, so renameInput.value?.select() was throwing on every rename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:28:36 +09:00
colcaroli b1350eb8bc Merge pull request 'Feature/shared notes' (#1) from feature/shared-notes into main
Reviewed-on: #1
2026-08-18 16:29:56 +00:00
45 changed files with 3571 additions and 517 deletions
+5
View File
@@ -15,6 +15,11 @@
"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 90)"
],
"deny": [
"Read(**/.env)",
"Edit(**/.env)",
"Bash(*.env)"
]
}
}
+43 -8
View File
@@ -59,17 +59,35 @@ Copy-Item .env.example .env
frontend (Vue3 + Pinia, :5173)
↕ REST + SSE (Vite proxy)
backend (FastAPI, :8000)
├── app/api/ チャット・メモリ・ノート・ログ のルーター
├── app/core/ LLM ラッパー・メモリロジック・共有ノート・設定
└── app/db/ SQLAlchemy モデル・DB 接続
├── app/api/ チャット・メモリ・ノート・ナレッジベース閲覧・ログ・設定 のルーター
├── app/core/ LLM ラッパー・共有ノート・設定
│ ├── memory_kb/ コンテキスト管理(要約・蓄積知識・ローカルKB・タグ付け)
│ ├── search/ Tavily Web 検索
│ └── discord/ Discord Bot・定期通知
└── app/db/ SQLAlchemy モデル・DB 接続
↕
SQLite (playchat.db) + LiteLLM → 任意の LLM
```
### メモリ機構
### コンテキスト管理
- **短期**: セッション内の全メッセージをコンテキストに含める
- **長期**: メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(デフォルト 20)を超えると古い半分を LLM で要約 → `memories` テーブルに保存 → 次回以降のシステムプロンプトに注入
LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_context_messages()` が毎リクエスト組み立てる。
以下の順に system メッセージを積み、最後に会話履歴を連結する(詳細は `docs/architecture.md`)。
1. **蓄積知識** (`memory_kb/knowledge.py`): 過去の Web 検索で得た `knowledge_entries` の**見出し一覧のみ**(`- 「クエリ」: 一行要約`)。本文と出典は含めず、必要なら `read_knowledge` ツールで読み出す。最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件。セッションを横断する
2. **共有ノート** (`core/notes.py`): タイトル一覧+本文先頭 40 文字のプレビューのみ
2.5. **参照指定ノート** (`core/notes.py`): ユーザーがそのセッションに紐付けた(GUI のタブで 📌 を押した)ページの**本文全文**。`session_note_pages` テーブルで管理し、1 ページ `SESSION_NOTES_CONTENT_LIMIT`(既定 8000)文字で打ち切る。層 2 の一覧側には印が付き、同じ本文を `read_note` で読み直させない
3. **ローカル KB** (`memory_kb/local_knowledge.py`): `backend/knowledge_base/**.md` のうち、直近ユーザー発言と埋め込みコサイン類似度が高い上位 `LOCAL_KB_TOP_K`(既定 3)件かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上を**全文**注入。プロンプト上は Web 検索結果より優先する扱い。中身は GUI ヘッダーのパネルメニュー →「ナレッジベース」で開くサイドパネル(`api/knowledge_base.py` + `KnowledgeBasePanel.vue`、`GET /api/kb/files` と `GET /api/kb/file?path=`)から閲覧できる(読み取り専用)。同パネルで埋め込みの状態確認・接続テスト・再埋め込みもできる(`GET /api/kb/embedding`、`POST /api/kb/embedding/test`、`POST /api/kb/embedding/reindex`)。埋め込みは `local_knowledge_docs.embedding_model` に使用モデルを持ち、モデルを変えた行は次の同期で埋め直される
4. **長期メモリ**: `memories` の要約を全件連結。上限なし
5. **短期メモリ**: セッション内の `messages` 全件(要約で削除された分を除く)
**要約**: `maybe_summarize()` がアシスタント応答の保存直後に走り、メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)以上になると古い半分を LLM で要約して `memories` に保存する。**要約した元メッセージは `archived_at` を立ててアーカイブされる**(物理削除はしない)。アーカイブ済みメッセージは `get_context_messages()` と要約の閾値判定の両方から除外されるため LLM には渡らないが、DB には残り、履歴表示・エクスポートからは参照できる。`memory_id` にどの要約に取り込まれたかを保持する。
**遅延読み込み**: 重い情報は目次だけ注入し、`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 件から再シード)。
**変更時の注意**: トークン数ベースの制御は一切なく、すべて件数・文字数ベース。またツールを使わない会話でも `resolve_tool_calls()` がフルコンテキストを 1 回投げるため、1 応答あたり LLM 呼び出しは最低 2 回になる。
### 共有ノート
@@ -80,15 +98,20 @@ SQLite (playchat.db) + LiteLLM → 任意の LLM
- **ツール実行の流れ**: `llm.resolve_tool_calls()` が本番の応答生成前にツール呼び出しを解決する。`web_search` と共通の仕組みで、ノート系ツールは `db` が渡されたときだけモデルに提示される
- **UI への反映**: AI がノートを書き換えた場合、SSE で `notes_updated` を送ってフロント側のノートパネルを再読み込みさせる
- **編集の衝突回避**: フロントは未保存の編集があるページ(`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()`)
### 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 経由で更新する。
### Markdown レンダリング
アシスタントメッセージは `marked` + `DOMPurify` でレンダリング。ストリーミング中の未閉じコードフェンスは自動補完してから描画する(`MarkdownContent.vue`)。
数式は `src/utils/math.ts` の marked 拡張が KaTeX で描画する(ブロック `$$...$$` / `\[...\]`、インライン `$...$` / `\(...\)`)。詳細は `docs/architecture.md` を参照。
### モデル切替
@@ -109,14 +132,26 @@ SQLite (playchat.db) + LiteLLM → 任意の LLM
| 変数 | デフォルト | 説明 |
|---|---|---|
| `DEFAULT_MODEL` | `gpt-5.4` | デフォルト LLM モデル |
| `DEFAULT_MODEL` | `gpt-4o` | デフォルト LLM モデル(`.env.example` では `gpt-5.4`) |
| `DATABASE_URL` | `sqlite:///./playchat.db` | DB 接続先 |
| `MEMORY_SUMMARIZE_THRESHOLD` | `20` | 長期メモリ化するメッセージ数の閾値 |
| `KNOWLEDGE_CONTEXT_LIMIT` | `15` | 蓄積知識をシステムプロンプトに注入する最大件数 |
| `TAVILY_API_KEY` | — | Tavily Web 検索の API キー(未設定なら `web_search` は空を返す。GUI 設定画面から実行時変更可) |
| `SEARCH_SUMMARY_MODEL` | — | Web 検索結果の要約に使うモデル(未設定なら `DEFAULT_MODEL`。GUI 設定画面から実行時変更可) |
| `TAGGING_MODEL` | — | メッセージの自動タグ付けに使うモデル(未設定なら `DEFAULT_MODEL`) |
| `LOCAL_KB_DIR` | `knowledge_base` | ローカル Markdown ナレッジベースのディレクトリ |
| `LOCAL_KB_EMBEDDING_MODEL` | `text-embedding-3-small` | ローカル KB の埋め込みモデル(GUI 設定画面から実行時変更可。変更すると全件が自動で埋め直される) |
| `LOCAL_KB_TOP_K` | `3` | ローカル KB から注入する最大ファイル数 |
| `LOCAL_KB_MIN_SCORE` | `0.75` | ローカル KB を注入するコサイン類似度の下限 |
| `CORS_ORIGINS` | `http://localhost:5173` | 許可する CORS オリジン |
| `DEBUG` | `false` | LiteLLM デバッグログの有効化 |
| `OPENAI_API_KEY` | — | OpenAI API キー |
| `ANTHROPIC_API_KEY` | — | Anthropic API キー |
| `OLLAMA_API_BASE` | `http://localhost:11434` | Ollama エンドポイント |
| `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 回に返す本文の最大文字数 |
| `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) |
| `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 |
+5
View File
@@ -1,5 +1,10 @@
FROM python:3.12-slim
# slim イメージには /usr/share/zoneinfo が無く、TZ を設定してもUTCのままになるため入れる。
RUN apt-get update \
&& apt-get install -y --no-install-recommends tzdata \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir uv
WORKDIR /app
+62 -5
View File
@@ -12,6 +12,7 @@ from app.core.memory_kb import memory as mem
from app.core import llm
from app.core.memory_kb import knowledge
from app.core.memory_kb import local_knowledge
from app.core import notes
from app.core.notes import NOTE_TOOLS
from app.core.search.websearch import WEB_SEARCH_TOOL
from app.core.config import DEFAULT_MODEL
@@ -25,6 +26,13 @@ class ChatRequest(BaseModel):
session_id: str | None = None
message: str
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):
@@ -42,6 +50,8 @@ class MessageResponse(BaseModel):
role: str
content: str
created_at: datetime
# 要約に取り込まれ、LLMには渡らなくなったメッセージ。人間の履歴確認用に残してある。
archived_at: datetime | None = None
tags: list[str] = []
class Config:
@@ -52,6 +62,7 @@ class ImportMessage(BaseModel):
role: str
content: str
created_at: datetime | None = None
archived_at: datetime | None = None
tags: list[str] = []
@@ -83,6 +94,7 @@ def get_messages(session_id: str, db: DBSession = Depends(get_db)):
role=m.role,
content=m.content,
created_at=m.created_at,
archived_at=m.archived_at,
tags=[t.tag for t in m.tags],
)
for m in session.messages
@@ -110,6 +122,7 @@ def export_session(session_id: str, db: DBSession = Depends(get_db)):
"role": m.role,
"content": m.content,
"created_at": m.created_at.isoformat(),
"archived_at": m.archived_at.isoformat() if m.archived_at else None,
"tags": [t.tag for t in m.tags],
}
for m in session.messages
@@ -143,7 +156,7 @@ def import_session(req: ImportRequest, db: DBSession = Depends(get_db)):
db.flush()
for m in req.messages:
msg = Message(session_id=session.id, role=m.role, content=m.content)
msg = Message(session_id=session.id, role=m.role, content=m.content, archived_at=m.archived_at)
if m.created_at:
msg.created_at = m.created_at
db.add(msg)
@@ -156,6 +169,22 @@ def import_session(req: ImportRequest, db: DBSession = Depends(get_db)):
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)
def delete_session(session_id: str, db: DBSession = Depends(get_db)):
session = db.get(Session, session_id)
@@ -197,6 +226,11 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
record_message(req.message)
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)
async def generate():
@@ -211,9 +245,18 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
used_model = req.model or session.model
full_response = []
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,
)
):
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:
# AIがノートを書き換えたので、フロントのノートパネルを再読み込みさせる
yield sse({"type": "notes_updated"})
@@ -223,11 +266,25 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
for s in searches for r in s["results"]
]
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:
summary = await knowledge.summarize_results(s["query"], s["results"])
headline, summary = await knowledge.summarize_results(s["query"], s["results"])
if summary:
knowledge.save_knowledge(db, s["query"], summary, s["results"])
knowledge.save_knowledge(db, s["query"], summary, s["results"], headline=headline)
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が含まれる場合、最終呼び出しにも同じツール定義を渡す必要がある
used_tools = [WEB_SEARCH_TOOL, *NOTE_TOOLS] if len(resolved_context) > len(context) else None
+112
View File
@@ -0,0 +1,112 @@
"""ローカルナレッジベース(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 import local_knowledge
from app.core.memory_kb.local_knowledge import list_kb_files, read_kb_file
from app.db.database import get_db
from app.db.models import LocalKnowledgeDoc
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 を一覧で返す(本文は含めず、先頭のプレビューのみ)。"""
# 「埋め込み済み」は現在のモデルで埋め込まれている行だけを指す(別モデルの行は埋め直し待ち)
current_model = local_knowledge.current_embedding_model()
indexed_paths = {
# DB 側は OS 依存の区切り文字で保存されているため、比較用に posix 形式へ寄せる
path.replace("\\", "/")
for (path,) in db.query(LocalKnowledgeDoc.path)
.filter(LocalKnowledgeDoc.embedding_model == current_model)
.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)
class EmbeddingStatus(BaseModel):
"""埋め込みモデルの現状。GUI の状態表示・進捗バーに使う。"""
model: str
# 現在のモデルで埋め込まれたベクトルの次元数(1件も無ければ None)
dimensions: int | None
file_count: int
indexed: int
# 別のモデルで埋め込まれていて埋め直しが必要な件数
stale: int
running: bool
total: int
done: int
failed: int
started_at: datetime | None
finished_at: datetime | None
last_error: str | None
class EmbeddingProbe(BaseModel):
"""短いテキストを実際に埋め込んでみた結果。"""
ok: bool
model: str
dimensions: int | None
latency_ms: float
error: str | None
@router.get("/embedding", response_model=EmbeddingStatus)
def get_embedding_status(db: DBSession = Depends(get_db)):
"""現在の埋め込みモデルと、埋め込み済み件数・実行中の進捗を返す。"""
return local_knowledge.embedding_status(db)
@router.post("/embedding/test", response_model=EmbeddingProbe)
async def test_embedding():
"""埋め込みモデルに実際に1回リクエストして、応答するかどうかを確かめる。"""
return await local_knowledge.probe_embedding()
@router.post("/embedding/reindex", response_model=EmbeddingStatus)
async def reindex_embeddings(db: DBSession = Depends(get_db)):
"""全ファイルの再埋め込みをバックグラウンドで開始する。実行中なら何もしない(進捗をそのまま返す)。"""
local_knowledge.start_reembed_all()
return local_knowledge.embedding_status(db)
+124 -3
View 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 sqlalchemy.orm import Session as DBSession
from app.core.notes import next_position
from app.core.notes import import_files, next_position, reorder, rename_section, set_section
from app.db.database import get_db
from app.db.models import NotePage
@@ -16,6 +20,7 @@ class NotePageResponse(BaseModel):
title: str
content: str
position: int
section: str | None
updated_at: datetime
class Config:
@@ -25,11 +30,46 @@ class NotePageResponse(BaseModel):
class NotePageCreate(BaseModel):
title: str = "新しいページ"
content: str = ""
section: str | None = None
class NotePageUpdate(BaseModel):
title: str | None = None
content: str | None = None
section: str | None = None
class SectionRenameRequest(BaseModel):
old_name: str
new_name: str | None = None
class ReorderRequest(BaseModel):
page_id: int
section: str | None = None
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])
@@ -37,6 +77,46 @@ def list_pages(db: DBSession = Depends(get_db)):
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)
def create_page(req: NotePageCreate, db: DBSession = Depends(get_db)):
title = req.title.strip() or "新しいページ"
@@ -44,9 +124,45 @@ def create_page(req: NotePageCreate, db: DBSession = Depends(get_db)):
db.add(page)
db.commit()
db.refresh(page)
if req.section is not None and req.section.strip():
page = set_section(db, page.id, req.section)
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")
def rename_section_endpoint(req: SectionRenameRequest, db: DBSession = Depends(get_db)):
"""old_name セクションに属する全ページを new_name セクションへ一括で付け替える。new_name が空ならグループ解除。"""
count = rename_section(db, req.old_name, req.new_name)
return {"renamed": count}
@router.post("/reorder")
def reorder_pages(req: ReorderRequest, db: DBSession = Depends(get_db)):
"""タブのドラッグ&ドロップによる並べ替え。order は全ページIDを新しい順序で並べたもの。"""
ok = reorder(db, req.page_id, req.section, req.order)
if not ok:
raise HTTPException(status_code=400, detail="order must contain exactly the current page ids")
return {"reordered": len(req.order)}
@router.patch("/{page_id}", response_model=NotePageResponse)
def update_page(page_id: int, req: NotePageUpdate, db: DBSession = Depends(get_db)):
page = db.get(NotePage, page_id)
@@ -61,6 +177,11 @@ def update_page(page_id: int, req: NotePageUpdate, db: DBSession = Depends(get_d
page.content = req.content
db.commit()
db.refresh(page)
# section はページの並び順にも影響するため、変更があれば専用ロジックで反映する
if "section" in req.model_fields_set:
page = set_section(db, page_id, req.section)
return page
+26 -3
View File
@@ -1,9 +1,12 @@
import os
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session as DBSession
from app.core import settings_store
from app.core.auth import require_settings_password
from app.core.memory_kb import local_knowledge
from app.db.database import get_db
router = APIRouter(prefix="/api/settings", tags=["settings"], dependencies=[Depends(require_settings_password)])
@@ -12,7 +15,10 @@ router = APIRouter(prefix="/api/settings", tags=["settings"], dependencies=[Depe
class SettingEntry(BaseModel):
key: str
is_set: bool
masked: str | None
# 秘匿キーはマスク済みの値、それ以外は現在値そのもの。未設定なら None
display_value: str | None
# 入力欄をパスワード扱いにするか(APIキー等は True)
secret: bool
overridden: bool
@@ -26,7 +32,8 @@ def list_settings(db: DBSession = Depends(get_db)):
@router.put("", response_model=list[SettingEntry])
def save_settings(req: SaveSettingsRequest, db: DBSession = Depends(get_db)):
async def save_settings(req: SaveSettingsRequest, db: DBSession = Depends(get_db)):
before_embedding_model = os.environ.get("LOCAL_KB_EMBEDDING_MODEL")
try:
for key, value in req.values.items():
if not value:
@@ -34,13 +41,29 @@ def save_settings(req: SaveSettingsRequest, db: DBSession = Depends(get_db)):
settings_store.save(db, key, value)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
_reembed_if_model_changed(before_embedding_model)
return settings_store.list_masked(db)
@router.delete("/{key}", response_model=list[SettingEntry])
def clear_setting(key: str, db: DBSession = Depends(get_db)):
async def clear_setting(key: str, db: DBSession = Depends(get_db)):
before_embedding_model = os.environ.get("LOCAL_KB_EMBEDDING_MODEL")
try:
settings_store.clear(db, key)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
_reembed_if_model_changed(before_embedding_model)
return settings_store.list_masked(db)
def _reembed_if_model_changed(before: str | None) -> None:
"""埋め込みモデルが変わったら、ローカルKBの再埋め込みをバックグラウンドで走らせる。
モデルが違うと次元数も意味空間も変わり、古いベクトルは比較しても無意味なので、
設定を変えた時点で全件やり直す(完了を待たずに応答は返す)。
"""
if os.environ.get("LOCAL_KB_EMBEDDING_MODEL") == before:
return
local_knowledge.start_reembed_all()
+12
View File
@@ -25,10 +25,12 @@ SESSION_MAX_AGE_DAYS: int = int(os.getenv("SESSION_MAX_AGE_DAYS", "30"))
TAGGING_MODEL: str | None = os.getenv("TAGGING_MODEL")
# Tavily Web検索
# GUI設定画面から実行時に変更できるため、実際の参照は search/websearch._api_key() が os.environ から行う。
TAVILY_API_KEY: str | None = os.getenv("TAVILY_API_KEY")
# 蓄積した知識をシステムプロンプトに注入する際の最大件数
KNOWLEDGE_CONTEXT_LIMIT: int = int(os.getenv("KNOWLEDGE_CONTEXT_LIMIT", "15"))
# Web検索結果を構造的な要約にまとめる際に使うモデル(未設定ならDEFAULT_MODELを使用)
# GUI設定画面から実行時に変更できるため、実際の参照は memory_kb/knowledge._summary_model() が os.environ から行う。
SEARCH_SUMMARY_MODEL: str | None = os.getenv("SEARCH_SUMMARY_MODEL")
# ローカルMarkdownナレッジベース(話題に応じて関連ファイルをシステムプロンプトに注入)
@@ -41,6 +43,16 @@ LOCAL_KB_MIN_SCORE: float = float(os.getenv("LOCAL_KB_MIN_SCORE", "0.75"))
# read_note ツールが1回に返す本文の最大文字数
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回に返す蓄積知識の本文の最大文字数
KNOWLEDGE_TOOL_CONTENT_LIMIT: int = int(os.getenv("KNOWLEDGE_TOOL_CONTENT_LIMIT", "4000"))
# Amazon Bedrock
# 以下はすべて LiteLLM(boto3) が os.environ から直接読み取るため、.env に書くだけで自動的に有効になる。
# IAM経由(推奨): AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY を設定する。
+82 -10
View File
@@ -107,26 +107,56 @@ async def embed(texts: list[str], model: str) -> list[list[float]]:
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],
model: str | None = None,
session_id: str | None = None,
db: "DBSession | None" = None,
max_rounds: int = 2,
) -> tuple[list[dict], list[dict], bool]:
"""必要に応じてLLMにツールを呼ばせ、その結果を会話に組み込んだ上でメッセージ列を返す。
) -> AsyncIterator[dict]:
"""必要に応じてLLMにツールを呼ばせ、その進行状況をイベントとして逐次 yield する。
扱うツールは web_search と共有ノート(read_note / write_note)。ノート系は db が
渡されたときだけ提示する。モデルがtool callingに対応していない場合は何もせず
元のmessagesをそのまま返す。
戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト, ノートを変更したか)
扱うツールは web_search、共有ノート(read_note / write_note)、蓄積知識(read_knowledge)。
DBを見るものは db が渡されたときだけ提示する。モデルがtool callingに対応していない場合は
何もせず元の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 しているため、
# モジュールレベルで読むと循環 import になる(_save_log と同じ理由で関数内 import する)。
from app.core.memory_kb.knowledge import KNOWLEDGE_TOOLS, execute_knowledge_tool
used_model = model or DEFAULT_MODEL
working_messages = list(messages)
searches: list[dict] = []
notes_changed = False
tools = [WEB_SEARCH_TOOL] + (NOTE_TOOLS if db is not None else [])
tools = [WEB_SEARCH_TOOL] + (NOTE_TOOLS + KNOWLEDGE_TOOLS if db is not None else [])
for _ in range(max_rounds):
try:
@@ -138,7 +168,8 @@ async def resolve_tool_calls(
)
except Exception:
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
tool_calls = getattr(choice_message, "tool_calls", None)
@@ -165,17 +196,35 @@ async def resolve_tool_calls(
args = {}
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:
tool_text, changed = execute_note_tool(db, name, args)
notes_changed = notes_changed or changed
result = _first_line(tool_text)
elif name == "read_knowledge" and db is not None:
tool_text = execute_knowledge_tool(db, name, args)
result = _first_line(tool_text)
else:
query = args.get("query", "")
results = await search_web(query)
if results:
tool_text = "\n\n".join(f"[{r['title']}]({r['url']})\n{r['content']}" for r in results)
searches.append({"query": query, "results": results})
result = f"{len(results)}件の結果"
else:
tool_text = "検索結果が見つかりませんでした。"
result = "結果なし"
yield {"type": "tool", "id": tc.id, "status": "done", "result": result}
working_messages.append({
"role": "tool",
@@ -183,4 +232,27 @@ async def resolve_tool_calls(
"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
+144 -18
View File
@@ -1,5 +1,6 @@
import asyncio
import logging
import os
import re
import httpx
@@ -7,7 +8,10 @@ import trafilatura
from sqlalchemy.orm import Session as DBSession
from app.core import llm
from app.core.config import KNOWLEDGE_CONTEXT_LIMIT, SEARCH_SUMMARY_MODEL
from app.core.config import (
KNOWLEDGE_CONTEXT_LIMIT,
KNOWLEDGE_TOOL_CONTENT_LIMIT,
)
from app.core.memory_kb import local_knowledge
from app.db.models import KnowledgeEntry, KnowledgeSource
@@ -19,6 +23,9 @@ _HTML_TAG_RE = re.compile(r"<[^>]+>")
_SUMMARY_PROMPT = (
"以下はWeb検索「{query}」で得られた複数の検索結果です。"
"これらの内容を整理し、Markdown形式で構造的にまとめてください。\n"
"- 1行目は「要約: 」で始まる60文字以内の一文にする"
"(何についての知識かが一目で分かる見出し。システムプロンプトにはこの行だけを載せる)\n"
"- 2行目以降を本文とし、以下の要領でまとめる\n"
"- 検索結果間で重複する情報はまとめ、矛盾する情報があれば併記する\n"
"- 見出し(##)やトピックごとの箇条書きを使い、要点が一目で分かるようにする\n"
"- 各項目の末尾に (出典: サイト名) を付ける\n"
@@ -27,15 +34,54 @@ _SUMMARY_PROMPT = (
)
async def summarize_results(query: str, results: list[dict], model: str | None = None) -> str:
_HEADLINE_MARKERS = ("要約:", "要約:")
def _summary_model() -> str | None:
"""GUIの設定画面から実行時に変更できるよう、起動時定数ではなく都度 os.environ を見に行く。
未設定(空文字含む)なら None を返し、llm.complete() 側で DEFAULT_MODEL にフォールバックさせる。
"""
return os.environ.get("SEARCH_SUMMARY_MODEL") or None
def _split_headline(text: str) -> tuple[str, str]:
"""LLMの出力を (一行要約, 本文) に分ける。1行目が想定の形式でなければ見出しは空で返す。"""
lines = text.strip().splitlines()
if not lines:
return "", ""
first = lines[0].strip().lstrip("#").lstrip("-*").strip().strip("*").strip()
for marker in _HEADLINE_MARKERS:
if not first.startswith(marker):
continue
headline = first[len(marker):].strip().strip("*").strip()
body = "\n".join(lines[1:]).strip()
if headline and body:
return headline[:120], body
break
return "", text.strip()
def derive_headline(query: str, summary: str) -> str:
"""headline を持たないエントリ(この機能より前に保存されたもの)のための代替の見出し。"""
for line in summary.splitlines():
stripped = line.strip().lstrip("#").lstrip("-*").strip().strip("*").strip()
if stripped:
return stripped[:120]
return query
async def summarize_results(query: str, results: list[dict], model: str | None = None) -> tuple[str, str]:
"""Web検索結果(title/url/content のリスト)をLLMで構造的なMarkdown要約にまとめる。
要約に失敗した場合は空文字を返す(生のスクレイピング結果を未加工のままファイルに
戻り値は (一行要約, 本文)。一行要約はシステムプロンプトに並べる見出しとして使い、
本文はDBとローカルナレッジベースに保存する。
要約に失敗した場合は ("", "") を返す(生のスクレイピング結果を未加工のままファイルに
書き込むと、ページに残ったスクリプト断片等がセキュリティソフトに誤検知されることがあるため、
フォールバックとして生テキストを書き出すことはしない)。
"""
if not results:
return ""
return "", ""
raw = "\n\n".join(
f"### {r.get('title') or r['url']}\n{r.get('content', '')[:1500]}"
@@ -43,17 +89,20 @@ async def summarize_results(query: str, results: list[dict], model: str | None =
if r.get("content")
)
if not raw:
return ""
return "", ""
prompt = _SUMMARY_PROMPT.format(query=query, results=raw)
try:
return await llm.complete(
text = await llm.complete(
[{"role": "user", "content": prompt}],
model=model or SEARCH_SUMMARY_MODEL,
model=model or _summary_model(),
)
except Exception:
logger.exception("Failed to summarize search results for query=%r", query)
return ""
return "", ""
headline, body = _split_headline(text)
return headline or derive_headline(query, body), body
def _strip_tags(html: str) -> str:
@@ -111,12 +160,13 @@ async def regenerate_from_sources(db: DBSession) -> int:
if not results:
continue
new_summary = await summarize_results(entry.query, results)
new_headline, new_summary = await summarize_results(entry.query, results)
if not new_summary:
continue
old_summary = entry.summary
entry.summary = new_summary
entry.headline = new_headline or derive_headline(entry.query, new_summary)
db.commit()
local_knowledge.delete_search_result_file(entry.query, old_summary)
@@ -129,9 +179,11 @@ async def regenerate_from_sources(db: DBSession) -> int:
return count
def save_knowledge(db: DBSession, query: str, summary: str, sources: list[dict]) -> KnowledgeEntry:
def save_knowledge(
db: DBSession, query: str, summary: str, sources: list[dict], headline: str = "",
) -> KnowledgeEntry:
"""Web検索で得た知見をDBに保存する。"""
entry = KnowledgeEntry(query=query, summary=summary)
entry = KnowledgeEntry(query=query, summary=summary, headline=headline or derive_headline(query, summary))
db.add(entry)
db.flush()
for src in sources:
@@ -143,8 +195,80 @@ def save_knowledge(db: DBSession, query: str, summary: str, sources: list[dict])
return entry
READ_KNOWLEDGE_TOOL = {
"type": "function",
"function": {
"name": "read_knowledge",
"description": "過去のWeb検索で蓄積した知識の本文を読み出す。システムプロンプトに提示された"
"「調べた事柄の一覧」の中に今の話題に関係しそうな項目があり、その本文がまだ提示されていないときに使う。"
"ここで読めるならWeb検索をやり直す必要はない。",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "読み出す項目のクエリ(一覧の「」内に載っているもの)"},
},
"required": ["query"],
},
},
}
KNOWLEDGE_TOOLS = [READ_KNOWLEDGE_TOOL]
def _find_entry(db: DBSession, query: str) -> KnowledgeEntry | None:
"""クエリで蓄積知識を探す。完全一致 → 大文字小文字・前後空白を無視した一致 → 部分一致 の順に試す。
同じクエリで複数回検索されている場合は、最も新しいものを返す。
"""
entries = db.query(KnowledgeEntry).order_by(KnowledgeEntry.created_at.desc()).all()
for entry in entries:
if entry.query == query:
return entry
normalized = query.strip().casefold()
for entry in entries:
if entry.query.strip().casefold() == normalized:
return entry
for entry in entries:
if normalized and normalized in entry.query.strip().casefold():
return entry
return None
def read_knowledge(db: DBSession, query: str) -> str:
"""蓄積知識の本文をツール結果用のテキストとして返す。"""
entry = _find_entry(db, query)
if not entry:
available = ", ".join(
e.query
for e in db.query(KnowledgeEntry).order_by(KnowledgeEntry.created_at.desc()).limit(KNOWLEDGE_CONTEXT_LIMIT)
)
return f"「{query}」に一致する知識はありません。読み出せる項目: {available or '(なし)'}"
content = entry.summary
if len(content) > KNOWLEDGE_TOOL_CONTENT_LIMIT:
content = content[:KNOWLEDGE_TOOL_CONTENT_LIMIT] + "\n…(以下省略)"
urls = ", ".join(s.url for s in entry.sources) or "出典不明"
return f"「{entry.query}」について調べた内容:\n\n{content}\n\n(出典: {urls})"
def execute_knowledge_tool(db: DBSession, name: str, args: dict) -> str:
"""蓄積知識のツールを実行する。ノート系と違いDBを変更しないため、戻り値はテキストのみ。"""
try:
if name == "read_knowledge":
return read_knowledge(db, args.get("query", ""))
except Exception as e:
logger.exception("Knowledge tool %s failed", name)
return f"知識の読み出しに失敗しました: {e}"
return f"未知のツールです: {name}"
def get_knowledge_context(db: DBSession) -> str | None:
"""蓄積された知識を、システムプロンプトに注入できる形のテキストにして返す。"""
"""蓄積された知識の「見出し一覧」を、システムプロンプトに注入できる形のテキストにして返す。
本文(summary)はここには含めない。同じ内容が knowledge_base/web_search/ 配下にも
Markdownとして保存されており、話題に関連していればローカルナレッジベース側が
全文を注入するため、ここで全件の本文を積むと同じ情報を二重に渡すことになる。
"""
entries = (
db.query(KnowledgeEntry)
.order_by(KnowledgeEntry.created_at.desc())
@@ -154,12 +278,14 @@ def get_knowledge_context(db: DBSession) -> str | None:
if not entries:
return None
blocks = []
for entry in reversed(entries):
urls = ", ".join(s.url for s in entry.sources) or "出典不明"
blocks.append(f"- 「{entry.query}」について: {entry.summary}\n (出典: {urls})")
blocks = [
f"- 「{entry.query}」: {entry.headline or derive_headline(entry.query, entry.summary)}"
for entry in reversed(entries)
]
return (
"以下はこれまでのWeb検索で得た知識です。関連する話題が出たら活用してください。"
"ただしローカルナレッジベースの資料が別途提示されている場合は、そちらを優先してください:\n\n" + "\n".join(blocks)
"以下はこれまでのWeb検索で調べた事柄の一覧です(見出しのみで、本文は含みません)。\n"
"関連する話題が出たときは、まず本文がローカルナレッジベースの資料として別途提示されていないか確認し、"
"あればそれを本文として使ってください。提示されていない場合は read_knowledge ツールで読み出せます。"
"いずれかで足りるならWeb検索をやり直す必要はありません:\n\n" + "\n".join(blocks)
)
+255 -23
View File
@@ -3,11 +3,14 @@
- sync_local_kb(): ディレクトリをスキャンし、新規/変更されたファイルだけ埋め込みを計算してDBに保存する
- get_local_kb_context(): 直近のユーザー発言に埋め込み類似度の高いファイルを探し、システムプロンプト用テキストにする
"""
import asyncio
import hashlib
import json
import logging
import math
import os
import time
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy.orm import Session as DBSession
@@ -18,6 +21,46 @@ from app.db.models import KnowledgeEntry, LocalKnowledgeDoc
logger = logging.getLogger(__name__)
# 埋め込みの進行状況。GUIに出すためだけのプロセス内の状態で、永続化はしない
# (uvicornを複数ワーカーで動かすとワーカーごとに別の値になる点に注意)。
_progress: dict = {
"running": False,
"total": 0,
"done": 0,
"failed": 0,
"started_at": None,
"finished_at": None,
"last_error": None,
"last_model": None,
}
# create_task の戻り値。GCで途中終了しないように保持しておく
_reembed_task: "asyncio.Task | None" = None
def _start_progress(model: str, total: int) -> None:
_progress.update(
running=True, total=total, done=0, failed=0,
started_at=datetime.now(timezone.utc), finished_at=None,
last_error=None, last_model=model,
)
def _finish_progress() -> None:
_progress.update(running=False, finished_at=datetime.now(timezone.utc))
def _progress_snapshot() -> dict:
return {
"running": _progress["running"],
"total": _progress["total"],
"done": _progress["done"],
"failed": _progress["failed"],
"started_at": _progress["started_at"],
"finished_at": _progress["finished_at"],
"last_error": _progress["last_error"],
}
def _kb_dir() -> Path:
return Path(LOCAL_KB_DIR)
@@ -28,6 +71,11 @@ def _embedding_model() -> str:
return os.environ.get("LOCAL_KB_EMBEDDING_MODEL", "text-embedding-3-small")
def current_embedding_model() -> str:
"""いま使われている埋め込みモデル名。"""
return _embedding_model()
def _hash(content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest()
@@ -88,46 +136,75 @@ def backfill_search_results(db: DBSession) -> int:
return count
async def sync_local_kb(db: DBSession) -> None:
"""ナレッジベースディレクトリをスキャンし、新規/変更ファイルの埋め込みを更新する。
削除されたファイルに対応するレコードも削除する。
async def sync_local_kb(db: DBSession, force: bool = False) -> None:
"""ナレッジベースディレクトリをスキャンし、埋め込みが古いファイルだけ計算し直す。
埋め込みを取り直すのは、ファイル内容が変わったか、埋め込みモデルが変わったとき
(次元数の違うベクトルを比べても意味のあるスコアにならないため)。force=True なら全件やり直す。
削除されたファイルに対応するレコードも消す。
進捗は _progress に載せてGUIから見えるようにする。
"""
kb_dir = _kb_dir()
if not kb_dir.is_dir():
return
# 再埋め込みが走っている最中は、チャット毎の同期は譲って何もしない
# (同じファイルを二重に埋め込むのを避けるため。ここまでawaitを挟まないので取りこぼしはない)。
# force=True で呼ばれるのは start_reembed_all() が実行中フラグを立てた本人だけなので素通しする。
if _progress["running"] and not force:
return
model = _embedding_model()
md_files = sorted(kb_dir.rglob("*.md"))
seen_paths: set[str] = set()
# 先に対象を洗い出す。1件も無ければ進捗を触らない(毎リクエストの同期で履歴が消えないように)
pending: list[tuple[Path, str, str, LocalKnowledgeDoc | None]] = []
for file_path in md_files:
rel_path = str(file_path.relative_to(kb_dir))
seen_paths.add(rel_path)
content = file_path.read_text(encoding="utf-8")
content = file_path.read_text(encoding="utf-8", errors="replace")
content_hash = _hash(content)
existing = db.query(LocalKnowledgeDoc).filter(LocalKnowledgeDoc.path == rel_path).first()
if existing and existing.content_hash == content_hash:
up_to_date = (
existing is not None
and existing.content_hash == content_hash
and existing.embedding_model == model
)
if up_to_date and not force:
continue
pending.append((file_path, rel_path, content, existing))
if pending:
_start_progress(model, len(pending))
try:
[embedding] = await llm.embed([content], model=_embedding_model())
except Exception:
logger.exception("Failed to embed local knowledge file: %s", rel_path)
continue
for file_path, rel_path, content, existing in pending:
try:
[embedding] = await llm.embed([content], model=model)
except Exception as e:
logger.exception("Failed to embed local knowledge file: %s", rel_path)
_progress["failed"] += 1
_progress["last_error"] = f"{rel_path}: {e}"[:500]
continue
title = _title_from_content(content, fallback=file_path.stem)
if existing:
existing.content_hash = content_hash
existing.title = title
existing.embedding_json = json.dumps(embedding)
else:
db.add(LocalKnowledgeDoc(
path=rel_path,
title=title,
content_hash=content_hash,
embedding_json=json.dumps(embedding),
))
db.commit()
title = _title_from_content(content, fallback=file_path.stem)
if existing:
existing.content_hash = _hash(content)
existing.title = title
existing.embedding_json = json.dumps(embedding)
existing.embedding_model = model
else:
db.add(LocalKnowledgeDoc(
path=rel_path,
title=title,
content_hash=_hash(content),
embedding_json=json.dumps(embedding),
embedding_model=model,
))
db.commit()
_progress["done"] += 1
finally:
_finish_progress()
stale = db.query(LocalKnowledgeDoc).filter(LocalKnowledgeDoc.path.notin_(seen_paths)).all()
for doc in stale:
@@ -136,6 +213,85 @@ async def sync_local_kb(db: DBSession) -> None:
db.commit()
async def reembed_all() -> None:
"""全ファイルを現在のモデルで埋め込み直す。バックグラウンドタスクから呼ぶ想定で、DBセッションは自前で持つ。"""
from app.db.database import SessionLocal
db = SessionLocal()
try:
await sync_local_kb(db, force=True)
except Exception as e:
logger.exception("Re-embedding of the local knowledge base failed")
_progress["last_error"] = str(e)[:500]
finally:
# 対象0件で sync 側が進捗を触らなかった場合でも、実行中フラグは必ず下ろす
_finish_progress()
db.close()
def start_reembed_all() -> bool:
"""再埋め込みをバックグラウンドで開始する。すでに走っていれば False を返して何もしない。"""
global _reembed_task
if _progress["running"]:
return False
# GUIは開始リクエストの応答をそのまま状態表示に使うので、タスクが動き出すのを待たず
# ここで「実行中」にしておく(総数は実際にスキャンした時点で入る)。
_start_progress(_embedding_model(), 0)
# create_task の戻り値を保持しないとGCで途中終了することがあるためモジュールに持たせる
_reembed_task = asyncio.create_task(reembed_all())
return True
def embedding_status(db: DBSession) -> dict:
"""GUIの「埋め込み」表示用。現在のモデルと、そのモデルで埋め込み済みの件数を返す。"""
model = _embedding_model()
kb_dir = _kb_dir()
file_count = len(list(kb_dir.rglob("*.md"))) if kb_dir.is_dir() else 0
rows = db.query(LocalKnowledgeDoc.embedding_model, LocalKnowledgeDoc.embedding_json).all()
indexed = 0
dimensions = None
for row_model, embedding_json in rows:
if row_model != model:
continue
indexed += 1
if dimensions is None:
dimensions = len(json.loads(embedding_json))
return {
"model": model,
"dimensions": dimensions,
"file_count": file_count,
"indexed": indexed,
# 別のモデルで埋め込まれた(=埋め直しが要る)行。モデル不明の古い行も含む
"stale": len(rows) - indexed,
**_progress_snapshot(),
}
async def probe_embedding() -> dict:
"""埋め込みモデルが実際に応答するかを短いテキストで確認する。GUIの「接続テスト」用。"""
model = _embedding_model()
start = time.perf_counter()
try:
[vector] = await llm.embed(["これは埋め込みモデルの疎通確認です。"], model=model)
except Exception as e:
return {
"ok": False,
"model": model,
"dimensions": None,
"latency_ms": round((time.perf_counter() - start) * 1000, 1),
"error": f"{type(e).__name__}: {e}"[:500],
}
return {
"ok": True,
"model": model,
"dimensions": len(vector),
"latency_ms": round((time.perf_counter() - start) * 1000, 1),
"error": None,
}
async def get_local_kb_context(db: DBSession, query_text: str) -> str | None:
"""直近のユーザー発言に関連するローカルMarkdown知識をシステムプロンプト用テキストにして返す。"""
if not query_text.strip():
@@ -174,3 +330,79 @@ async def get_local_kb_context(db: DBSession, query_text: str) -> str | None:
"ここに必要な情報が含まれている場合はWeb検索を行わず、この内容のみで回答してください。"
"情報が不足する場合に限り、補足としてWeb検索を使ってください:\n\n" + "\n\n".join(blocks)
)
# --- GUI からの閲覧用(埋め込みやコンテキスト注入とは無関係の、ファイルブラウズ機能) ---
# 一覧のタイトル・プレビュー生成のために読む先頭バイト数(全文を読まずに済ませるため)
_PREVIEW_READ_CHARS = 2000
def _preview_from_content(content: str) -> str:
"""本文から見出し・空行を除いた最初の行を、一覧に出すプレビューとして切り出す。"""
for line in content.splitlines():
stripped = line.strip()
if stripped and not stripped.startswith("#"):
return stripped[:120]
return ""
def resolve_kb_path(rel_path: str) -> Path | None:
"""ブラウザから渡された相対パスを実ファイルに解決する。
ナレッジベースディレクトリの外を指すパス(`../` など)や .md 以外は None を返す。
"""
root = _kb_dir().resolve()
try:
target = (root / rel_path).resolve()
except OSError:
return None
if not target.is_relative_to(root) or target.suffix.lower() != ".md":
return None
return target if target.is_file() else None
def list_kb_files() -> list[dict]:
"""ナレッジベース配下の Markdown を一覧にする。本文は先頭だけ読む。"""
root = _kb_dir()
if not root.is_dir():
return []
files = []
for file_path in sorted(root.rglob("*.md")):
rel_path = file_path.relative_to(root)
try:
stat = file_path.stat()
with file_path.open(encoding="utf-8", errors="replace") as f:
head = f.read(_PREVIEW_READ_CHARS)
except OSError:
logger.exception("Failed to read local knowledge file: %s", rel_path)
continue
files.append({
"path": rel_path.as_posix(),
"name": file_path.name,
"directory": rel_path.parent.as_posix() if rel_path.parent != Path(".") else "",
"title": _title_from_content(head, fallback=file_path.stem),
"preview": _preview_from_content(head),
"size": stat.st_size,
"updated_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
})
return files
def read_kb_file(rel_path: str) -> dict | None:
"""1ファイルの全文を返す。パスが不正・存在しない場合は None。"""
target = resolve_kb_path(rel_path)
if target is None:
return None
content = target.read_text(encoding="utf-8", errors="replace")
stat = target.stat()
normalized = target.resolve().relative_to(_kb_dir().resolve()).as_posix()
return {
"path": normalized,
"name": target.name,
"title": _title_from_content(content, fallback=target.stem),
"content": content,
"size": stat.st_size,
"updated_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
}
+32 -5
View File
@@ -1,3 +1,5 @@
from datetime import datetime, timezone
from sqlalchemy.orm import Session as DBSession
from app.db.models import Memory, Message, Session
from app.core.config import MEMORY_SUMMARIZE_THRESHOLD
@@ -11,7 +13,13 @@ from app.core import notes
async def get_context_messages(db: DBSession, session_id: str) -> list[dict]:
"""蓄積知識・長期メモリ(要約)・短期メモリ(最近のメッセージ)を結合してコンテキストを返す。"""
memories = db.query(Memory).filter(Memory.session_id == session_id).order_by(Memory.created_at).all()
messages = db.query(Message).filter(Message.session_id == session_id).order_by(Message.created_at).all()
# アーカイブ済み(要約に取り込まれた)メッセージは、その内容が長期メモリ側に入っているため除外する。
messages = (
db.query(Message)
.filter(Message.session_id == session_id, Message.archived_at.is_(None))
.order_by(Message.created_at)
.all()
)
context: list[dict] = []
@@ -20,10 +28,15 @@ async def get_context_messages(db: DBSession, session_id: str) -> list[dict]:
context.append({"role": "system", "content": knowledge_context})
# 共有ノートは「タイトル一覧」だけを常時見せ、本文は read_note ツールで読ませる
notes_context = notes.get_notes_context(db)
notes_context = notes.get_notes_context(db, session_id)
if 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:
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)
@@ -42,8 +55,18 @@ async def get_context_messages(db: DBSession, session_id: str) -> list[dict]:
async def maybe_summarize(db: DBSession, session: Session) -> None:
"""メッセージ数が閾値を超えたら古いメッセージを要約してMemoryに保存し、元メッセージを削除する。"""
messages = db.query(Message).filter(Message.session_id == session.id).order_by(Message.created_at).all()
"""メッセージ数が閾値を超えたら古いメッセージを要約してMemoryに保存し、元メッセージをアーカイブする。
アーカイブしたメッセージはLLMに渡すコンテキストからは外れるが、人間が後から履歴を
確認できるようDBには残す(物理削除はしない)。閾値の判定もアーカイブ済みを除いた
件数で行うため、一度要約した分が再び対象になることはない。
"""
messages = (
db.query(Message)
.filter(Message.session_id == session.id, Message.archived_at.is_(None))
.order_by(Message.created_at)
.all()
)
if len(messages) < MEMORY_SUMMARIZE_THRESHOLD:
return
@@ -60,8 +83,12 @@ async def maybe_summarize(db: DBSession, session: Session) -> None:
memory = Memory(session_id=session.id, summary=summary_text)
db.add(memory)
db.flush() # どの要約に取り込まれたかをメッセージ側に持たせるためIDを確定させる
archived_at = datetime.now(timezone.utc)
for msg in to_summarize:
db.delete(msg)
msg.archived_at = archived_at
msg.memory_id = memory.id
db.commit()
db.refresh(memory)
+7 -1
View File
@@ -185,10 +185,16 @@ async def backfill_tags(db: DBSession, model: str | None = None, limit: int | No
単体スクリプト・APIエンドポイントの両方から呼べるようにここに切り出している。
戻り値: タグ付けしたメッセージ数
"""
# アーカイブ済み(要約に取り込まれた)メッセージは対象外。話題選びには要約側のタグを使うため、
# 過去ログ全体にLLMを走らせる意味が薄い。
query = (
db.query(Message)
.outerjoin(MessageTag, MessageTag.message_id == Message.id)
.filter(Message.role.in_(["user", "assistant"]), MessageTag.id.is_(None))
.filter(
Message.role.in_(["user", "assistant"]),
MessageTag.id.is_(None),
Message.archived_at.is_(None),
)
.order_by(Message.created_at.asc())
)
if limit:
+248 -5
View File
@@ -2,14 +2,25 @@
システムプロンプトにはページの「タイトル一覧」だけを注入し、本文はモデルが
read_note ツールで必要なときだけ読み出す。書き込みは write_note ツールで行う。
例外として、ユーザーがセッションに紐付けたページ(SessionNotePage)は本文も
毎回そのままシステムプロンプトに載る。ツールの往復を待たずに確実に参照させたい
資料のための仕組みで、指定はセッション単位なので会話ごとに組み合わせを変えられる。
"""
import io
import logging
import re
import zipfile
from sqlalchemy.orm import Session as DBSession
from app.core.config import NOTES_TOOL_CONTENT_LIMIT
from app.db.models import NotePage
from app.core.config import (
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__)
@@ -75,17 +86,249 @@ def next_position(db: DBSession) -> int:
return (last.position + 1) if last else 0
def get_notes_context(db: DBSession) -> str | None:
"""共有ノートのタイトル一覧をシステムプロンプト用のテキストとして返す。"""
def set_section(db: DBSession, page_id: int, section: str | None) -> NotePage | None:
"""ページの所属セクションを変更し、同じセクションのページ群の直後に連続配置し直す。
タブバーはposition順に描画され、隣接して同じsectionが続く区間を1つのグループとして
まとめて表示する(app.api.notes / フロントのグルーピング表示を参照)。そのため
セクションを変更したページは、常に同じセクションの最後のページの直後に移動する。
"""
page = db.get(NotePage, page_id)
if not page:
return None
normalized = (section or "").strip() or None
ordered = [p for p in db.query(NotePage).order_by(NotePage.position, NotePage.id).all() if p.id != page_id]
if normalized is None:
insert_at = len(ordered)
else:
matching = [i for i, p in enumerate(ordered) if p.section == normalized]
insert_at = (matching[-1] + 1) if matching else len(ordered)
ordered.insert(insert_at, page)
page.section = normalized
for i, p in enumerate(ordered):
p.position = i
db.commit()
db.refresh(page)
return page
def reorder(db: DBSession, page_id: int, section: str | None, order: list[int]) -> bool:
"""タブのドラッグ&ドロップによる並べ替えを反映する。
order にはドラッグ後の全ページIDを新しい順序で渡す(フロント側で計算する)。
page_id のページには先に section を設定し、その後 order の順に position を振り直す。
こうすることで、別セクションのタブの前後にドロップした場合はそのセクションへの
移動を兼ね、同じセクション内でのドロップなら単純な並べ替えになる。
order が現在の全ページIDの集合と一致しない場合は何もせず False を返す。
"""
pages = {p.id: p for p in db.query(NotePage).all()}
if page_id not in pages or set(order) != set(pages.keys()):
return False
pages[page_id].section = (section or "").strip() or None
for i, pid in enumerate(order):
pages[pid].position = i
db.commit()
return True
def rename_section(db: DBSession, old_name: str, new_name: str | None) -> int:
"""あるセクションに属する全ページのセクション名を一括で変更する。new_name が空ならグループ解除になる。
戻り値: 変更したページ数。
"""
normalized_new = (new_name or "").strip() or None
pages = db.query(NotePage).filter(NotePage.section == old_name).order_by(NotePage.position, NotePage.id).all()
for page in pages:
page.section = normalized_new
db.commit()
return len(pages)
# 取り込めるテキストファイルの拡張子。これ以外は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()
attached = set(get_attached_page_ids(db, session_id)) if session_id else set()
lines = [
"【共有ノート】",
"ユーザーとあなたが共同で編集できるノートです。以下のページがあります。",
"ユーザーとあなたが共同で編集できるノートです。以下のページがあります"
"(セクションはページをグループ化する見出しで、あれば併記します)。",
"",
]
if pages:
current_section = "__unset__" # どのページのsectionとも一致しない番兵
for page in pages:
if page.section != current_section:
current_section = page.section
if current_section:
lines.append(f"## {current_section}")
if page.id in attached:
lines.append(f"- {page.title} — (本文は「この会話で参照するノート」に全文あり)")
continue
preview = " ".join(page.content.split())[:40]
suffix = f" — {preview}…" if preview else " — (空)"
lines.append(f"- {page.title}{suffix}")
+14 -4
View File
@@ -1,13 +1,22 @@
import logging
import os
import httpx
from app.core.config import TAVILY_API_KEY
logger = logging.getLogger(__name__)
TAVILY_SEARCH_URL = "https://api.tavily.com/search"
def _api_key() -> str | None:
"""GUIの設定画面から実行時に変更できるよう、起動時定数ではなく都度 os.environ を見に行く。
app.core.config の定数は import 時に評価されるため、DBに保存した値を
settings_store.load_into_env() が os.environ に入れても反映されない。
"""
return os.environ.get("TAVILY_API_KEY") or None
WEB_SEARCH_TOOL = {
"type": "function",
"function": {
@@ -27,7 +36,8 @@ WEB_SEARCH_TOOL = {
async def search_web(query: str, max_results: int = 5) -> list[dict]:
"""Tavily APIでWeb検索する。結果は [{title, url, content}, ...]。失敗時は空リスト。"""
if not TAVILY_API_KEY:
api_key = _api_key()
if not api_key:
logger.warning("TAVILY_API_KEY is not set; skipping web search")
return []
@@ -36,7 +46,7 @@ async def search_web(query: str, max_results: int = 5) -> list[dict]:
response = await client.post(
TAVILY_SEARCH_URL,
json={
"api_key": TAVILY_API_KEY,
"api_key": api_key,
"query": query,
"max_results": max_results,
},
+14 -1
View File
@@ -16,8 +16,18 @@ CONFIGURABLE_KEYS = [
"OLLAMA_API_BASE",
"TAVILY_API_KEY",
"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_defaults: dict[str, str | None] = {}
_snapshot_taken = False
@@ -47,15 +57,18 @@ def load_into_env(db: DBSession) -> None:
def list_masked(db: DBSession) -> list[dict]:
"""GUI設定画面に出す一覧。秘匿キーの値はマスクし、そうでないキーは現在値をそのまま見せる。"""
_ensure_snapshot()
saved = {row.key: row.value for row in db.query(AppSetting).all()}
result = []
for key in CONFIGURABLE_KEYS:
current = os.environ.get(key)
secret = key not in NON_SECRET_KEYS
result.append({
"key": key,
"is_set": bool(current),
"masked": _mask(current) if current else None,
"display_value": (_mask(current) if secret else current) if current else None,
"secret": secret,
"overridden": key in saved,
})
return result
+3 -2
View File
@@ -1,5 +1,5 @@
"""直近で取得した「明日の天気」データをメモリ上にキャッシュする。"""
from datetime import datetime
from datetime import datetime, timezone
_latest: dict | None = None
@@ -11,7 +11,8 @@ def set_latest(weather: str, pop: int | None, judgement: str, reason: str) -> No
"pop": pop,
"judgement": judgement,
"reason": reason,
"fetched_at": datetime.now().isoformat(),
# オフセット付きで返す(フロントの new Date() がローカル時刻と誤解しないようにするため)
"fetched_at": datetime.now(timezone.utc).isoformat(),
}
+23 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import create_engine
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from app.core.config import DATABASE_URL
@@ -18,6 +18,28 @@ def get_db():
db.close()
# 既存DBに後から追加されたカラム: (テーブル名, カラム名, 型)
_ADDED_COLUMNS = [
("note_pages", "section", "VARCHAR"),
("knowledge_entries", "headline", "VARCHAR"),
("messages", "archived_at", "DATETIME"),
("messages", "memory_id", "INTEGER"),
("local_knowledge_docs", "embedding_model", "VARCHAR"),
]
def _add_missing_columns() -> None:
"""マイグレーションフレームワークを使っていないため、既存DBに後から追加されたカラムを
ここで簡易的に補う。create_all() は既存テーブルへのカラム追加は行わないので必要。"""
with engine.connect() as conn:
for table, column, column_type in _ADDED_COLUMNS:
existing = {row[1] for row in conn.execute(text(f"PRAGMA table_info({table})"))}
if existing and column not in existing:
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {column_type}"))
conn.commit()
def init_db():
from app.db import models # noqa: F401
Base.metadata.create_all(bind=engine)
_add_missing_columns()
+57 -17
View File
@@ -1,7 +1,8 @@
from datetime import datetime, timezone
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Float
from sqlalchemy import String, Text, ForeignKey, Integer, Float, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.database import Base
from app.db.types import UtcDateTime
class Session(Base):
@@ -10,11 +11,12 @@ class Session(Base):
id: Mapped[str] = mapped_column(String, primary_key=True)
title: Mapped[str] = mapped_column(String, default="New Chat")
model: Mapped[str] = mapped_column(String, default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=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))
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")
note_links: Mapped[list["SessionNotePage"]] = relationship(back_populates="session", cascade="all, delete-orphan")
class Message(Base):
@@ -24,7 +26,14 @@ class Message(Base):
session_id: Mapped[str] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE"))
role: Mapped[str] = mapped_column(String) # "user" | "assistant" | "system"
content: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
# 要約されて長期メモリに移ったメッセージ。LLMに渡すコンテキストからは外れるが、
# 人間が後から履歴を確認できるようDBには残す(app.core.memory_kb.memory.maybe_summarize を参照)。
archived_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
# どの要約に取り込まれたか。要約を削除してもメッセージ自体は残す(SET NULL)。
memory_id: Mapped[int | None] = mapped_column(
ForeignKey("memories.id", ondelete="SET NULL"), nullable=True,
)
session: Mapped["Session"] = relationship(back_populates="messages")
tags: Mapped[list["MessageTag"]] = relationship(back_populates="message", cascade="all, delete-orphan")
@@ -36,7 +45,7 @@ class MessageTag(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
message_id: Mapped[int] = mapped_column(ForeignKey("messages.id", ondelete="CASCADE"))
tag: Mapped[str] = mapped_column(String)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
message: Mapped["Message"] = relationship(back_populates="tags")
@@ -48,7 +57,7 @@ class Memory(Base):
session_id: Mapped[str] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE"))
summary: Mapped[str] = mapped_column(Text)
embedding_json: Mapped[str | None] = mapped_column(Text, nullable=True) # 将来のベクトル検索用
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
session: Mapped["Session"] = relationship(back_populates="memories")
tags: Mapped[list["MemoryTag"]] = relationship(back_populates="memory", cascade="all, delete-orphan")
@@ -60,7 +69,7 @@ class MemoryTag(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
memory_id: Mapped[int] = mapped_column(ForeignKey("memories.id", ondelete="CASCADE"))
tag: Mapped[str] = mapped_column(String)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
memory: Mapped["Memory"] = relationship(back_populates="tags")
@@ -72,7 +81,10 @@ class KnowledgeEntry(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
query: Mapped[str] = mapped_column(String)
summary: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
# システムプロンプトには本文ではなくこの一行要約だけを並べる(app.core.memory_kb.knowledge を参照)。
# このカラム追加より前に作られた行では None になるため、表示側で summary から導出する。
headline: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
sources: Mapped[list["KnowledgeSource"]] = relationship(back_populates="entry", cascade="all, delete-orphan")
@@ -92,7 +104,7 @@ class LocalKnowledgeDoc(Base):
"""backend/knowledge_base/ 配下のMarkdownファイルとその埋め込みベクトル。
話題に応じて関連ファイルをシステムプロンプトに注入するために使う。
ファイル内容のハッシュが変わった時だけ埋め込みを再計算する。
ファイル内容のハッシュか埋め込みモデルが変わった時だけ埋め込みを再計算する。
"""
__tablename__ = "local_knowledge_docs"
@@ -101,23 +113,51 @@ class LocalKnowledgeDoc(Base):
title: Mapped[str] = mapped_column(String)
content_hash: Mapped[str] = mapped_column(String)
embedding_json: Mapped[str] = mapped_column(Text)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
# この埋め込みを計算したモデル名。モデルを切り替えた行を見分けて埋め直すために持つ
# (次元数の違うベクトル同士を比較しても意味のないスコアしか出ないため)。
# このカラムより前に作られた行は NULL で、モデル不明として埋め直し対象になる。
embedding_model: Mapped[str | None] = mapped_column(String, nullable=True)
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
class NotePage(Base):
"""ユーザーとAIが共同で編集する共有ノートの1ページ。UIではタブとして表示する。
ページのタイトル一覧は毎回システムプロンプトに注入され、本文はモデルが
read_note ツールで読み出す(app.core.notes を参照)。
read_note ツールで読み出す(app.core.notes を参照)。ただし SessionNotePage で
セッションに紐付けられたページは、本文もそのままシステムプロンプトに載る。
"""
__tablename__ = "note_pages"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String)
content: Mapped[str] = mapped_column(Text, default="")
position: Mapped[int] = mapped_column(Integer, default=0) # タブの並び順
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
position: Mapped[int] = mapped_column(Integer, default=0) # タブの並び順(セクションでまとめる際、同じセクションのページは連続させる)
section: Mapped[str | None] = mapped_column(String, nullable=True) # タブをグループ化するセクション名。未設定なら未分類
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))
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):
@@ -126,7 +166,7 @@ class AppSetting(Base):
key: Mapped[str] = mapped_column(String, primary_key=True)
value: Mapped[str] = mapped_column(Text)
updated_at: Mapped[datetime] = mapped_column(DateTime, 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))
class ModelPreset(Base):
@@ -136,7 +176,7 @@ class ModelPreset(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
label: Mapped[str] = mapped_column(String)
value: Mapped[str] = mapped_column(String, unique=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
class ApiLog(Base):
@@ -149,4 +189,4 @@ class ApiLog(Base):
response_text: Mapped[str | None] = mapped_column(Text, nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
latency_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
+38
View File
@@ -0,0 +1,38 @@
"""タイムゾーンを保ったまま日時を扱うためのカラム型。"""
from datetime import datetime, timezone
from sqlalchemy import DateTime
from sqlalchemy.types import TypeDecorator
class UtcDateTime(TypeDecorator):
"""常に UTC の aware な datetime として読み書きする DateTime。
SQLite の DATETIME は tzinfo を保存できず、`DateTime(timezone=True)` を指定しても
バインド時に黙って捨てられる(=naive な値が返ってくる)。そのままだと API のレスポンスが
オフセット無しの ISO 文字列になり、フロントの `new Date()` がローカル時刻として
解釈してしまうため、この型で入口と出口を揃える。
- 書き込み: aware な値は UTC に変換し、naive な値は UTC とみなす。いずれも naive な UTC として格納する
- 読み出し: 格納値を UTC の aware な datetime として返す
格納フォーマットは素の DateTime と同じ(naive な UTC)なので、
既存DBのデータはそのまま正しく解釈でき、マイグレーションは不要。
"""
impl = DateTime
cache_ok = True
def process_bind_param(self, value: datetime | None, dialect) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value
return value.astimezone(timezone.utc).replace(tzinfo=None)
def process_result_value(self, value: datetime | None, dialect) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
+2 -1
View File
@@ -5,7 +5,7 @@ from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import auth as auth_api
from app.api import chat, logs, memory, notes, system
from app.api import chat, knowledge_base, logs, memory, notes, system
from app.api import models as models_api
from app.api import settings as settings_api
from app.core import settings_store
@@ -94,6 +94,7 @@ app.include_router(auth_api.router)
app.include_router(chat.router, dependencies=[Depends(require_site_session)])
app.include_router(memory.router, dependencies=[Depends(require_site_session)])
app.include_router(notes.router, dependencies=[Depends(require_site_session)])
app.include_router(knowledge_base.router, dependencies=[Depends(require_site_session)])
app.include_router(logs.router, dependencies=[Depends(require_site_session)])
app.include_router(system.router, dependencies=[Depends(require_site_session)])
app.include_router(settings_api.router, dependencies=[Depends(require_site_session)])
+1
View File
@@ -19,6 +19,7 @@ dependencies = [
"discord-py>=2.7.1",
"janome>=0.5.0",
"trafilatura>=2.1.0",
"python-multipart>=0.0.9",
]
[project.optional-dependencies]
+11
View File
@@ -1300,6 +1300,7 @@ dependencies = [
{ name = "nvidia-ml-py" },
{ name = "psutil" },
{ name = "python-dotenv" },
{ name = "python-multipart" },
{ name = "sqlalchemy" },
{ name = "sse-starlette" },
{ name = "trafilatura" },
@@ -1336,6 +1337,7 @@ requires-dist = [
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "python-multipart", specifier = ">=0.0.9" },
{ name = "sqlalchemy", specifier = ">=2.0.0" },
{ name = "sse-starlette", 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" },
]
[[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]]
name = "pytz"
version = "2026.2"
+4
View File
@@ -8,6 +8,10 @@ services:
environment:
# SQLite ファイルと知識ベースを永続ボリュームに置く(.env の値を上書き)
DATABASE_URL: sqlite:////app/data/playchat.db
# コンテナのタイムゾーン。未設定だとUTCになり、天気通知のcron時刻・深夜スキップ判定・
# 「明日」の日付計算がすべて9時間ずれる。プロセス起動前に効かせる必要があるため
# .env ではなくここで指定する(後から os.environ に入れても libc には反映されない)。
TZ: Asia/Tokyo
volumes:
- backend_data:/app/data
- backend_knowledge_base:/app/knowledge_base
+215 -8
View File
@@ -15,15 +15,22 @@ SSE でストリーミング応答を返す。
{
"message": "こんにちは",
"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 イベント**
| type | value | タイミング |
|---|---|---|
| `session_id` | セッション UUID | 新規セッション作成直後 |
| `chunk` | テキスト断片 | LLM トークン生成ごと |
| `tool` | `{id, name, label, detail, status, result}` | ツール実行の開始時 (`status: running`) と完了時 (`status: done`)。同じ `id` で 2 回届くのでフロントはマージして表示する |
| `sources` | `[{title, url}]` | Web 検索した時のみ(出典一覧) |
| `notes_updated` | — | AI が共有ノートを書き換えた直後 |
| `done` | — | 正常完了 |
| `error` | エラーメッセージ | 例外発生時 |
@@ -48,18 +55,42 @@ SSE でストリーミング応答を返す。
---
### GET /chat/sessions/{session_id}/messages
セッションのメッセージ一覧を `created_at` 昇順で返す。
セッションのメッセージ一覧を `created_at` 昇順で返す。要約に取り込まれた(アーカイブ済みの)
メッセージも含めて返し、`archived_at` で区別する。アーカイブ済みのものは LLM に渡す
コンテキストには入らないが、履歴としては残っている。
**レスポンス**
```json
[
{ "id": 1, "role": "user", "content": "...", "created_at": "..." },
{ "id": 2, "role": "assistant", "content": "...", "created_at": "..." }
{ "id": 1, "role": "user", "content": "...", "created_at": "...", "archived_at": "2026-08-20T04:00:00+00:00", "tags": [] },
{ "id": 2, "role": "assistant", "content": "...", "created_at": "...", "archived_at": null, "tags": [] }
]
```
---
### 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}
セッションと紐づくメッセージ・メモリをすべて削除。`204 No Content` を返す。
@@ -93,9 +124,10 @@ SSE でストリーミング応答を返す。
ユーザーと AI が共同編集するノート。ページのタイトル一覧は毎回システムプロンプトに注入され、
本文は AI が `read_note` / `write_note` ツールで読み書きする。
`section` を設定すると、同じセクション名のページはタブバー上で常に連続してグループ表示される。
### GET /api/notes
全ページを `position` 昇順で返す。
全ページを `position` 昇順で返す。同じ `section` のページは常に連続する(隣接=同一グループとして扱われる)。
**レスポンス**
```json
@@ -106,6 +138,7 @@ SSE でストリーミング応答を返す。
"content": "- 牛乳
- 卵",
"position": 0,
"section": "生活",
"updated_at": "2026-08-19T12:00:00"
}
]
@@ -115,21 +148,63 @@ SSE でストリーミング応答を返す。
### POST /api/notes
ページを新規作成し、`201 Created` で作成したページを返す。
`section` を指定すると、作成直後にそのセクションの末尾へ配置される(他ページの `position` も再採番される)。
**リクエスト**
```json
{ "title": "新しいページ", "content": "" }
{ "title": "新しいページ", "content": "", "section": null }
```
---
### PATCH /api/notes/{page_id}
ページのタイトル・本文を更新する。指定したフィールドだけが変更される。
ページのタイトル・本文・セクションを更新する。指定したフィールドだけが変更される。
タイトルを空文字にしようとした場合は `400`、ページが存在しない場合は `404` を返す。
`section` はリクエストボディに**キーとして含まれているかどうか**で判定する(`null` を明示的に送るとグループ解除)。
セクションを変更すると、そのページは新しいセクションの末尾(該当ページが無ければ末尾)へ移動し、
全ページの `position` が振り直される。
**リクエスト**
```json
{ "content": "更新後の本文" }
{ "content": "更新後の本文", "section": "生活" }
```
---
### POST /api/notes/sections/rename
`old_name` セクションに属する全ページを `new_name` セクションへ一括で付け替える。
`new_name` を空文字/未指定にすると、そのセクションの全ページのグループを解除する。
ページの並び順(`position`)は変更しない。
**リクエスト**
```json
{ "old_name": "生活", "new_name": "生活雑貨" }
```
**レスポンス**
```json
{ "renamed": 2 }
```
---
### POST /api/notes/reorder
タブのドラッグ&ドロップによる並べ替えを反映する。`order` には**全ページID**をドラッグ後の
新しい順序で渡す(サーバー側では計算しない。フロントが `store.pages` から算出する)。
`page_id` のページには先に `section` を設定してから、`order` の順に `position` を振り直す。
別セクションのタブの前後にドロップした場合は、そのセクションへの移動も兼ねる。
`order` が現在の全ページIDの集合と一致しない場合は `400` を返す。
**リクエスト**
```json
{ "page_id": 3, "section": "生活", "order": [1, 3, 2, 4] }
```
**レスポンス**
```json
{ "reordered": 4 }
```
---
@@ -139,6 +214,138 @@ 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 /api/kb/embedding
埋め込みモデルの現状と、再埋め込みの進捗を返す。
```json
{
"model": "ollama/nomic-embed-text",
"dimensions": 768,
"file_count": 36,
"indexed": 36,
"stale": 0,
"running": false,
"total": 36,
"done": 36,
"failed": 0,
"started_at": "2026-08-23T13:50:23.196274Z",
"finished_at": "2026-08-23T13:50:29.951099Z",
"last_error": null
}
```
`indexed` は**現在のモデルで**埋め込まれている件数、`stale` は別モデル(またはモデル不明)のまま残っている件数。
`running` 以下は実行中の進捗で、プロセス内のメモリにしか無い(再起動で消える。ワーカーを複数立てるとワーカーごとに別の値になる)。
---
### POST /api/kb/embedding/test
短いテキストを実際に 1 回埋め込んでみて、モデルが応答するかを確かめる。失敗しても 200 で返し、`error` に理由が入る。
```json
{ "ok": true, "model": "ollama/nomic-embed-text", "dimensions": 768, "latency_ms": 412.3, "error": null }
```
---
### POST /api/kb/embedding/reindex
全ファイルの再埋め込みをバックグラウンドで開始し、開始直後の状態(`running: true`)を返す。
すでに実行中なら何も起こさず現在の進捗を返す。
---
## ログ
### GET /logs
+117 -19
View File
@@ -21,18 +21,36 @@
```
backend/
├── app/
│ ├── main.py # FastAPI インスタンス、CORS、ルーター登録、DB初期化
│ ├── main.py # FastAPI インスタンス、CORS、ルーター登録、DB初期化、起動時シード
│ ├── api/
│ │ ├── chat.py # チャット・セッション関連エンドポイント
│ │ ├── memory.py # 長期メモリ参照・削除
│ │ └── logs.py # API ログ参照・削除
│ │ ├── chat.py # チャット・セッション関連エンドポイント(SSE / export / import)
│ │ ├── memory.py # 長期メモリ参照・削除
│ │ ├── notes.py # 共有ノート CRUD
│ │ ├── knowledge_base.py # ローカル KB の Markdown を GUI から閲覧(読み取り専用)
│ │ ├── models.py # モデルプリセット CRUD
│ │ ├── settings.py # GUI 設定画面(APIキー等)
│ │ ├── system.py # タグ backfill・ワードプール参照などの運用系
│ │ ├── auth.py # サイトログイン
│ │ └── logs.py # API ログ参照・削除
│ ├── core/
│ │ ├── config.py # .env 読み込み・定数定義
│ │ ├── llm.py # LiteLLM ラッパー(ストリーミング・ログ記録)
│ │ └── memory.py # メモリ読み書きロジック(要約・コンテキスト生成)
│ │ ├── config.py # .env 読み込み・定数定義
│ │ ├── llm.py # LiteLLM ラッパー(ストリーミング・埋め込み・ツール解決・ログ記録)
│ │ ├── notes.py # 共有ノート(タイトル一覧の注入 / read_note・write_note ツール)
│ │ ├── memory_kb/ # コンテキスト管理まわり一式
│ │ │ ├── memory.py # コンテキスト合成・要約(旧 core/memory.py)
│ │ │ ├── knowledge.py # Web検索で得た知識の蓄積・要約・再生成
│ │ │ ├── local_knowledge.py # ローカル Markdown KB の埋め込み・類似検索
│ │ │ ├── tagging.py # メッセージ・要約への自動タグ付け
│ │ │ └── word_memory.py # 形態素解析による単語プール(Discord Bot 用)
│ │ ├── search/
│ │ │ └── websearch.py # Tavily による web_search ツール
│ │ ├── discord/ # Discord Bot・定期通知
│ │ ├── settings_store.py # GUI 設定を os.environ へ反映
│ │ └── auth.py / site_auth.py # 設定画面パスワード・サイトログイン
│ └── db/
│ ├── database.py # SQLite 接続・セッションファクトリ・テーブル自動作成
│ └── models.py # SQLAlchemy モデル定義
│ ├── database.py # SQLite 接続・セッションファクトリ・テーブル自動作成
│ └── models.py # SQLAlchemy モデル定義
├── knowledge_base/ # ローカルナレッジベース(Markdown。web_search/ 配下は自動生成)
└── pyproject.toml
```
@@ -42,30 +60,94 @@ backend/
|---|---|---|
| `sessions` | id (UUID), title, model, created_at, updated_at | チャットセッション |
| `messages` | id, session_id, role, content, created_at | 会話メッセージ(短期メモリ) |
| `message_tags` | id, message_id, tag, created_at | メッセージへの自動タグ |
| `memories` | id, session_id, summary, embedding_json, created_at | 要約による長期メモリ |
| `note_pages` | id, title, content, position, created_at, updated_at | ユーザーと AI が共同編集する共有ノートのページ |
| `memory_tags` | id, memory_id, tag, created_at | 要約への自動タグ |
| `knowledge_entries` | id, query, summary, created_at | Web 検索で得た知識(セッション横断) |
| `knowledge_sources` | id, entry_id, title, url | 上記知識の出典 URL |
| `local_knowledge_docs` | id, path, title, content_hash, embedding_json, embedding_model, updated_at | ローカル Markdown KB の埋め込みキャッシュ(`embedding_model` が現在の設定と違う行は埋め直す) |
| `note_pages` | id, title, content, position, section, created_at, updated_at | ユーザーと AI が共同編集する共有ノートのページ(`section` でタブをグループ化) |
| `session_note_pages` | id, session_id, page_id, created_at | 「このセッションではこのページの本文をプロンプトに載せる」指定(session_id × page_id は一意) |
| `app_settings` | key, value, updated_at | GUI 設定画面から編集した接続情報 |
| `model_presets` | id, label, value, created_at | モデル選択プルダウンの選択肢 |
| `api_logs` | id, session_id, model, request_json, response_text, error, latency_ms, created_at | LLM API 呼び出しログ |
### メモリ機構(`core/memory.py`)
### コンテキスト管理(`core/memory_kb/`)
1. **短期メモリ**: セッション内の全 `messages` をコンテキストとして送信
2. **長期メモリ**: メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(デフォルト 20)を超えると、古い半分を LLM で要約して `memories` に保存しメッセージを削除
3. **コンテキスト合成**: `get_context_messages()` が `memories` の要約をシステムメッセージとして先頭に付加し、残存メッセージと結合して返す
LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messages()` が毎リクエスト組み立てる。
以下の順に system メッセージを積み、最後に会話履歴を連結する。
| # | 層 | 中身 | 実装 | 量の制御 |
|---|---|---|---|---|
| 1 | 蓄積知識 | 過去の Web 検索で得た `knowledge_entries` の**見出し一覧のみ**(本文・出典は含めない) | `knowledge.get_knowledge_context()` | 最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件 |
| 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)以上 |
| 4 | 長期メモリ | `memories` の要約を全件連結 | `memory.get_context_messages()` | 上限なし |
| 5 | 短期メモリ | セッション内の `messages` 全件(要約済みで削除された分を除く) | 同上 | 上限なし(要約で間接的に制御) |
層 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()` がアシスタント応答の保存直後に走る。
1. メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)以上になったら発火
2. 古い側の半分(既定 10 件)を LLM で要約し `memories` に 1 行保存
3. **要約した元メッセージは `archived_at` / `memory_id` を立ててアーカイブ**する(物理削除はしない)。LLM に渡すコンテキストからも、次回以降の要約対象からも外れるが、DB には残るため履歴表示・エクスポートからは読める
4. 保存した要約に `tagging.tag_memory()` でタグを付ける
#### ツール経由の遅延読み込み
常時注入すると重い情報は、システムプロンプトに目次だけ置いてツールで取りに行かせる。
`llm.resolve_tool_calls()` が本番のストリーミング生成の**前に**別途 LLM を呼んでツール呼び出しを解決する(最大 2 ラウンド)。
- `read_note` / `write_note` … 共有ノート本文の読み書き(`db` を渡したときだけモデルに提示)
- `read_knowledge` … 層 1 の見出し一覧に出ている知識の本文を読む(同上)。層 3 として本文が既に注入されていれば呼ぶ必要はない
- `web_search` … Tavily 検索。結果は `knowledge.summarize_results()` で構造化要約にされ、
`knowledge_entries` と `knowledge_base/web_search/*.md` の両方に保存される。
→ 次回以降の層 1・層 3 に自動的に効いてくる
#### 自動タグ付け・ワードプール
- `tagging.py` … 各メッセージに `asyncio.create_task()` でバックグラウンドにタグを 3 つ生成する。
コンテキスト注入には使わず、Discord 雑談 Bot の話題選び(`find_related_tag()`)に使う
- `word_memory.py` … Janome で名詞を抽出し `deque(maxlen=200)` にプロセス内保持する。
**DB に永続化されず再起動で消える**(起動時に直近 200 件のメッセージから再シードされる)
#### 設計上の注意点
- トークン数ベースの制御は無く、すべて件数・文字数ベース。層 3(Markdown 全文 ×3)は会話が短くても
コンテキストを大きく押し上げる(層 1 は見出しのみなので件数を増やしても軽い)
- ツールを使わない会話でも `resolve_tool_calls()` が必ず 1 回フルコンテキストを投げるため、**LLM 呼び出しは最低 2 回**になる
- 層 4(長期メモリ)に上限が無く、長いセッションでは要約が積み上がる(要約の再要約はしない)
- `get_local_kb_context()` が毎リクエストで `sync_local_kb()`(ディレクトリスキャン)と埋め込み API 呼び出しを 1 回行う
### LLM 呼び出し(`core/llm.py`)
- `stream_chat()` / `complete()` が LiteLLM を薄くラップ
- `_log_context` で呼び出しを囲み、成功・失敗どちらも `api_logs` テーブルに記録
- `stream_chat()` / `complete()` / `embed()` が LiteLLM を薄くラップ
- `resolve_tool_calls()` が `web_search` / `read_note` / `write_note` / `read_knowledge` を解決し、結果を組み込んだメッセージ列を返す
- `_log_context` で呼び出しを囲み、成功・失敗どちらも `api_logs` テーブルに記録(`embed()` は対象外)
- `DEBUG=true` で `litellm._turn_on_debug()` が有効になり詳細ログが出力される
### 共有ノート(`core/notes.py` / `api/notes.py`)
ユーザーと AI が共同編集するノート。`note_pages` の 1 行が UI の 1 タブに対応する。
利用者向けの操作方法は `docs/notes-guide.md` を参照。
- `get_notes_context()` が**タイトル一覧のみ**をシステムメッセージとして生成し、`get_context_messages()` が会話コンテキストに差し込む(本文は含めない)
- `get_notes_context()` が**タイトル一覧のみ**をシステムメッセージとして生成し、`get_context_messages()` が会話コンテキストに差し込む(本文は含めない)。セクションがあれば `## セクション名` の見出しでグループ化して提示する
- 本文の読み書きはツール経由: `read_note`(本文取得)/ `write_note`(追記・置換・新規作成)
- `llm.resolve_tool_calls()` が `web_search` と同じループでこれらを解決する。ノート系ツールは `db` を渡したときのみモデルに提示される
- REST(`/api/notes`)はフロントのノートパネル用。GET 一覧 / POST 作成 / PATCH 更新 / DELETE 削除
- REST(`/api/notes`)はフロントのノートパネル用。GET 一覧 / POST 作成 / PATCH 更新 / DELETE 削除 / `POST /api/notes/sections/rename`(セクション名の一括変更)/ `POST /api/notes/reorder`(ドラッグ&ドロップの並べ替え)
- **セクションによるグルーピング**: `note_pages.section` に名前を持たせ、同じセクションのページは常に `position` 上で連続させる。`notes.set_section()` がページのセクションを変更するたびに全ページを再採番し、同じセクションの末尾に挿入し直す(隣接=同一グループという前提をタブ描画側・システムプロンプト側の両方が使っている)。`notes.rename_section()` は既に連続しているページ群のラベルだけを一括で書き換える
- **ドラッグ&ドロップの並べ替え**: `notes.reorder()` は `set_section()` と違い、位置の計算をサーバー側ではなくフロント(`NotePanel.vue`)に任せる。フロントがドラッグ後の全ページIDの順序を計算し、`{ page_id, section, order }` として一括送信、サーバーは検証(`order` が現在の全ページID集合と一致するか)した上でそのまま `position` に反映する。ドロップ先タブの `section` を渡すことで、並べ替えと同時にセクション間移動も1リクエストで完結する
- 既存 DB への追加カラムは Alembic 等の仕組みを使っておらず、`db/database.py` の `_add_missing_columns()` が起動時に `PRAGMA table_info` を見て `ALTER TABLE ... ADD COLUMN` を素朴に実行する(SQLite 専用の簡易マイグレーション)
### SSE ストリーミング(`api/chat.py`)
@@ -73,12 +155,21 @@ backend/
```
data: {"type":"session_id","value":"<uuid>"} # 新規セッション時のみ
data: {"type":"chunk","value":"テキスト断片"} # 0回以上
data: {"type":"tool","value":{...}} # ツール実行ごとに開始・完了の2回
data: {"type":"notes_updated"} # AIが共有ノートを更新した時のみ
data: {"type":"sources","value":[{...}]} # Web検索した時のみ(出典の title/url)
data: {"type":"chunk","value":"テキスト断片"} # 0回以上
data: {"type":"done"} # 正常完了
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` を付与する。
---
@@ -127,6 +218,13 @@ frontend/src/
- アシスタントメッセージのみ `marked` で HTML に変換後 `DOMPurify` でサニタイズ
- ストリーミング中(`pending=true`)は未閉じのコードフェンス(` ``` `)を自動補完してから描画
- 数式は `src/utils/math.ts` の marked 拡張が KaTeX で描画する(`mathExtension` / `mathSanitizeConfig`)
- ブロック: 行頭の `$$ ... $$` と `\[ ... \]`。インライン: `$ ... $`、`\( ... \)`、行中の `$$ ... $$`
- `$ ... $` は誤爆防止のため「開き `$` の直後が空白でない」「中身に改行を含まない」「閉じ `$` の直後が数字でない」を満たすものだけを数式にする(`$100 から $200` のような金額表記は素通しする)。リテラルのドル記号は `\$` でエスケープできる
- インラインコード・コードフェンス内の `$` は marked のトークナイザ順により数式にならない
- 構文エラーは例外にせず KaTeX の赤字表示にフォールバックする(`throwOnError: false`)
- KaTeX の MathML 出力に含まれる `semantics` / `annotation` は DOMPurify の既定では許可されないため、`mathSanitizeConfig` で明示的に許可している
- ストリーミング途中で閉じ記号が未到達の数式は、閉じるまでプレーンテキストとして表示される
### Vite プロキシ設定
+230
View File
@@ -0,0 +1,230 @@
# 機能一覧
このリポジトリにあるアプリ(PlayChat)が「何ができるか」を機能単位でまとめたもの。
内部設計は [architecture.md](architecture.md)、エンドポイント仕様は [api.md](api.md)、
Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は [notes-guide.md](notes-guide.md)、
デプロイ手順は [deploy-lightsail.md](deploy-lightsail.md) を参照。
## これは何か
個人用の LLM チャットアプリ。FastAPI(Python)+ Vue 3(TypeScript)で、会話・記憶・ノートを
すべて手元の SQLite に持つ。LLM は LiteLLM 経由なので OpenAI / Anthropic / Ollama / Amazon Bedrock を
同じ画面のプルダウンで切り替えられる。単なるチャット UI ではなく、
- 会話が長くなったら勝手に要約して長期記憶に落とす
- 知らないことは自分で Web 検索して、その結果を次回以降も使える知識として溜める
- ユーザーと AI が同じノートを共同編集する
- Discord 側にも住んでいて、天気を知らせたり雑談に乗ったりする
といった「動き続ける」部分を持つのが特徴。
## 機能サマリ
| # | 機能 | 概要 | 主な実装 |
|---|---|---|---|
| 1 | チャット(SSE ストリーミング) | 逐次表示・Markdown 描画・セッション管理 | `api/chat.py` / `ChatWindow.vue` |
| 2 | セッションの書き出し / 取り込み | 会話を JSON でダウンロード・復元 | `api/chat.py` / `SessionList.vue` |
| 3 | 5 層コンテキスト管理 | 蓄積知識・ノート・ローカル KB・長期/短期メモリを毎回合成 | `core/memory_kb/memory.py` |
| 4 | 自動要約による長期記憶 | 一定件数を超えたら古い会話を要約して圧縮 | `memory_kb/memory.py` |
| 5 | Web 検索と知識の蓄積 | Tavily で検索 →要約 → DB とローカル KB に保存 | `core/search/` / `memory_kb/knowledge.py` |
| 6 | ローカル Markdown ナレッジベース | 話題に近いファイルを埋め込み類似度で選んで全文注入 | `memory_kb/local_knowledge.py` |
| 6.5 | ナレッジベース閲覧パネル | KB の Markdown を一覧・全文表示。埋め込みの状態確認・接続テスト・再埋め込み | `api/knowledge_base.py` / `KnowledgeBasePanel.vue` |
| 7 | 共有ノート | タブ/セクション付きノートを人と AI の両方が編集 | `core/notes.py` / `NotePanel.vue` |
| 8 | 自動タグ付け | 発言・要約にタグを 3 つ付与(話題選びに使う) | `memory_kb/tagging.py` |
| 9 | ワードプール | 直近の名詞をインメモリに保持しタグクラウド表示 | `memory_kb/word_memory.py` / `WordPool.vue` |
| 10 | Discord Bot | メンションに返信。天気・雑談キーワードに反応 | `core/discord/discord_bot.py` |
| 11 | 定期通知 | 毎日の洗濯天気通知と、定期的な「どうでもいい豆知識」 | `core/discord/scheduler.py` |
| 12 | 天気予報(気象庁 API) | 明日の天気・降水確率から洗濯指数を判定 | `core/weather/` / `Clock.vue` |
| 13 | モデル切替とプリセット管理 | 選択肢を DB で管理し画面から追加・編集・削除 | `api/models.py` / `SettingsPanel.vue` |
| 14 | GUI 設定画面 | API キー等を再起動なしで変更(許可リスト制・マスク表示) | `core/settings_store.py` |
| 15 | API ログビューア | LLM 呼び出しの成功/失敗・レイテンシ・リクエスト全文 | `api/logs.py` / `LogViewer.vue` |
| 16 | システムモニタ | CPU・メモリ・GPU 使用率を 10 秒ごとに表示 | `api/system.py` / `MemoryMonitor.vue` |
| 17 | サイト認証 | 共有パスワード 1 つでサイト全体を保護 | `core/site_auth.py` / `LoginGate.vue` |
| 17.5 | 右サイドパネルのメニュー | ノート/ナレッジベース/設定/ログを 1 つのメニューから切替 | `panels.ts` / `SidePanelMenu.vue` |
| 18 | Docker 一式 | Caddy + nginx + FastAPI で HTTPS 公開 | `docker-compose.yml` / `Caddyfile` |
---
## 1. チャット
- `POST /chat/stream` が SSE を返し、フロントは fetch + ReadableStream で受けて 1 文字ずつ描画する。
イベントは `session_id` / `chunk` / `tool` / `sources` / `notes_updated` / `done` / `error` の 7 種。
- アシスタントの発言は `marked` + `DOMPurify` で描画する。ストリーミング途中で閉じていないコードフェンスは
自動で補完してから描画するので、生成中でもレイアウトが崩れない。
- セッションは左サイドバーの一覧から切り替え・削除できる。タイトルは最初の発言の先頭 40 文字。
- Web 検索が走った応答には出典(`sources`)が、各メッセージには自動生成されたタグが添えて表示される。
- Web 検索・ノートの読み書き・蓄積知識の読み出しといったツール実行は、バブルの上に
「🔍 Web検索 / クエリ / ✓ 5件の結果」の形でリアルタイムに表示される(`ToolActivityList.vue`)。
実行中はスピナーが回り、応答が途中で切れたツールは「⚠ 中断されました」になる。表示専用で永続化はしない。
- 失敗時は赤いバブルとトースト通知の両方で知らせる。
## 2. セッションの書き出し / 取り込み
- `GET /chat/sessions/{id}/export` がセッションのメタ情報と全メッセージ(タグ込み)を JSON で返す。
- `POST /chat/sessions/import` は同じ形式の JSON から**新しいセッションを作成**する(上書きではない)。
サイドバーの ⭱ ボタンからファイルを選ぶだけで取り込める。
- 要約済み(アーカイブ済み)のメッセージも `archived_at` 付きでそのまま書き出され、取り込み時もアーカイブ状態を保つ。
## 3. コンテキスト管理(5 層)
毎リクエスト `get_context_messages()` が以下の順に system メッセージを積み、最後に会話履歴を連ねる。
| 層 | 中身 | 量の制御 |
|---|---|---|
| 1 蓄積知識 | 過去の Web 検索結果の**見出し一覧のみ**(本文は `read_knowledge` で読む) | 最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件 |
| 2 共有ノート | ページのタイトル一覧+本文先頭 40 文字 | 全ページ(本文は入れない) |
| 2.5 参照指定ノート | このチャットに紐付けたページの**本文全文** | 紐付いたページのみ・1 ページ `SESSION_NOTES_CONTENT_LIMIT`(既定 8000)文字 |
| 3 ローカル KB | 選ばれた Markdown の**全文** | 類似度上位 `LOCAL_KB_TOP_K`(既定 3)件かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上 |
| 4 長期メモリ | 要約を全件連結 | 上限なし |
| 5 短期メモリ | セッション内の全メッセージ | 上限なし(要約で間接的に制御) |
層 2.5 だけは例外で、ユーザーが 📌 で選んだページの本文をツールを介さず直接載せる。
重い情報(ノート本文・蓄積知識の本文・Web 検索)は目次だけを渡し、本体はツール(`read_note` / `write_note` / `read_knowledge` / `web_search`)で
取りに行かせる遅延読み込み方式。`llm.resolve_tool_calls()` が本番生成の前に最大 2 ラウンドでこれを解決する。
## 4. 自動要約による長期記憶
- メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)を超えると、古い側の半分を LLM が要約して
`memories` に 1 行として保存する。
- **要約した元メッセージはアーカイブされる**(物理削除はしない)。LLM に渡るコンテキストからは外れるが、チャット画面では薄く表示され、区切り線から先だけがモデルに渡ることが分かるようになっている。
- 保存した要約にもタグが付き、Discord Bot の話題選びの材料になる。
- 溜まった要約は `GET /memory/{session_id}` で確認、`DELETE /memory/{id}` で個別削除できる(API のみ。専用の画面は無い)。
## 5. Web 検索と知識の蓄積
- LLM が必要と判断したときだけ `web_search` ツールが走る(Tavily。`TAVILY_API_KEY` 未設定なら空振り)。
- 結果は `SEARCH_SUMMARY_MODEL`(未設定なら `DEFAULT_MODEL`。設定画面から変更可)で構造化要約にされ、**2 か所**に保存される。
- `knowledge_entries` / `knowledge_sources` テーブル → 次回以降、層 1 として全セッションに効く
- `knowledge_base/web_search/*.md` → 層 3 の類似度検索の対象になる
- `POST /chat/knowledge/regenerate` で、既存の知識エントリを出典 URL から取り直して要約し直せる。
## 6. ローカル Markdown ナレッジベース
- `backend/knowledge_base/**.md` を置いておくだけで、話題が近いときに自動で全文が注入される。
- 直近のユーザー発言との埋め込みコサイン類似度で選ぶ。埋め込みは `local_knowledge_docs` に
内容ハッシュと**使用した埋め込みモデル名**付きでキャッシュされ、
ファイルが変わったときと、埋め込みモデルが変わったときだけ取り直す。
- 設定画面で `LOCAL_KB_EMBEDDING_MODEL` を変更すると、保存した時点でバックグラウンドの再埋め込みが走る
(モデルが違えば次元も意味空間も変わり、古いベクトルは比較しても無意味なため)。
パネルの「埋め直し」ボタンからも手動で開始できる。
- プロンプト上は「Web 検索結果より優先。これで答えられるなら検索するな」と明示している。
- ヘッダーのパネルメニュー →「ナレッジベース」で開くサイドパネル(`KnowledgeBasePanel.vue`)から中身を閲覧できる。
パネル上部には埋め込みの状態(モデル名・次元数・埋め込み済み件数・進捗バー)が出る。
「接続テスト」は短いテキストを 1 回埋め込んでみて、モデルが応答するかその場で確かめる。
フォルダ単位のグループ表示・折りたたみ、タイトル/パス/本文先頭での絞り込み、Markdown 描画に対応。
各ファイルの緑の点は埋め込み済み(=自動注入の対象)であることを示す。閲覧専用で、編集はできない。
## 7. 共有ノート
- 1 ページ=1 タブ。作成・改名・削除・本文編集(入力が止まると自動保存)ができる。
- **セクション**でページをグループ化でき、同じセクションのタブは常に隣り合って表示される。
- タブは**ドラッグ&ドロップ**で並べ替えでき、他セクションのタブにドロップすればそのまま移動になる。
- AI 側はタイトル一覧だけを常に見ており、`read_note` で本文を読み、`write_note` で追記/全置換/新規作成する。
AI が書き換えると SSE の `notes_updated` が飛び、開いているノートパネルが自動で最新化される。
- 自分が編集中(未保存)のページは AI の更新で上書きされない。
- **取り込み**: `.md` / `.txt` / `.zip` を本文エリアにドロップするか ⬆ ボタンで選ぶとページになる。
ZIP はフォルダ名がセクションになるので、⬇ で出した ZIP をそのまま戻せば構成ごと復元される。
タイトルが衝突したら `(2)` を足して別ページにするので、既存のページは壊れない。
- **チャットへの紐付け**: タブの 📌 を押すと、そのページの本文が**今開いているチャット**のシステム
プロンプトに毎回そのまま載る。AI が `read_note` を呼ぶのを待たずに確実に参照させたいときに使う。
指定はチャット単位で保存されるので、会話ごとに違う組み合わせを選べる。
- **ダウンロード**: パネル下部の ⬇ ボタンで、開いているページを `.md` として、または全ページを
ZIP(1 ページ 1 Markdown、セクションはフォルダ、ファイル名の通し番号でタブ順を保持)として
手元に保存できる。未保存の編集は書き出す前に保存される。
## 8-9. 自動タグ付けとワードプール
- 各メッセージと各要約に、バックグラウンドでタグが最大 3 つ付く(応答ストリームはブロックしない)。
タグはコンテキストには注入せず、Discord Bot が「近い話題」を選ぶために使う。
- `POST /api/system/tags/backfill` で、過去の未タグ分をまとめて処理できる。
- ワードプールは Janome で抽出した名詞を `deque(maxlen=200)` にプロセス内保持するもの。
**DB に永続化されず再起動で消える**が、起動時に直近 200 件のメッセージから再シードされる。
サイドバーに出現回数に応じた大きさのタグクラウドとして表示される。
## 10-11. Discord Bot と定期通知
- Bot はメンションされたときだけ反応する。本文に天気系キーワード(天気・洗濯・雨など)が含まれれば
気象庁データを添えて回答し、雑談系キーワード(どうでもいい・暇など)なら発言からタグを起こして
関連する話題の豆知識を即座に投げ返す。
- APScheduler による定期ジョブが 2 つ。
- **洗濯天気通知**: 毎日 `WEATHER_NOTIFY_HOUR` 時(既定 22 時)に、翌日の天気と洗濯指数を配信
- **どうでもいい豆知識**: `USELESS_TIPS_ENABLED=true` のときだけ登録され、`USELESS_TIPS_INTERVAL_HOURS`(既定 3 時間)ごとに実行。深夜 3 時台はスキップ
- どちらも `POST /api/system/weather-notify/test` / `POST /api/system/useless-tip/test` で即時実行して確認できる。
## 12. 天気予報
- 気象庁の無料 JSON API から明日の予報を取り、天気文言と降水確率から洗濯指数を判定する
(雨雪なら室内干し、降水確率 30% 以下なら外干し OK、それ以上は室内干し推奨)。
- 取得した最新データはメモリ上にキャッシュされ、画面右上の時計の横にも表示される(`GET /api/system/weather/latest`)。
- 対象地域は `WEATHER_AREA_CODE`(既定 120000=千葉県)と `WEATHER_AREA_KEYWORD`(既定「北西部」)で指定する。
## 13-14. モデル選択と設定画面
- モデルのプルダウンは `model_presets` テーブル由来で、初回起動時に既定の一覧がシードされる。
設定画面からラベル/モデル文字列を追加・編集・削除できる(重複は 400 で弾く)。
- 設定画面では API キー類を**再起動なしで**変更できる。
- 変更できるのは許可リストにあるキーだけ(`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / AWS 系 /
`OLLAMA_API_BASE` / `TAVILY_API_KEY` / `LOCAL_KB_EMBEDDING_MODEL` / `SEARCH_SUMMARY_MODEL`)
- API キー類は末尾 4 文字だけを見せるマスク表示。モデル名や URL(`settings_store.NON_SECRET_KEYS`)は
現在値をそのまま表示し、入力欄も伏せ字にしない(今どれが効いているか分からないと設定しづらいため)
- 空欄で保存すると「変更なし」扱い
- 保存すると `app_settings` テーブルに入り `os.environ` に反映される。削除すると `.env` 由来の起動時の値に戻る
- 保存した値は `backend_data` ボリュームに残るため、コンテナを作り直しても消えない。
起動時に `load_into_env()` が `os.environ` へ流し込むので、`.env` の同名の値より優先される
- **アプリ側は `config.py` の定数ではなく `os.environ` を都度読むこと**。定数は import 時に評価され、
`load_into_env()` より前に確定してしまうため、GUI で保存した値が反映されない
(`websearch._api_key()` / `knowledge._summary_model()` / `local_knowledge._embedding_model()` がその形)
## 15-16. ログビューアとシステムモニタ
- LLM 呼び出しは成功・失敗どちらも `api_logs` に記録される(モデル・リクエスト全文・応答・
レイテンシ・エラー)。ログパネルからセッション別/成功失敗別に絞り込み、個別削除ができる。
- サイドバーの監視パネルは CPU 使用率・メモリ使用量を 10 秒ごとに更新する。
`pynvml` が使える環境なら GPU メモリも表示する(無ければその欄が出ないだけ)。
## 17. 認証
パスワードが 2 種類あり、役割が違う。
| 変数 | 守る対象 | 方式 |
|---|---|---|
| `SITE_PASSWORD` | チャット含むサイト全体 | ログイン画面 → HMAC 署名付きセッション Cookie(既定 30 日) |
| `SETTINGS_PASSWORD` | 設定画面と モデルプリセットの更新系のみ | HTTP Basic(リクエストごとに送る) |
- どちらも未設定時の挙動が逆なので注意。`SITE_PASSWORD` 未設定=**誰でも使える**、
`SETTINGS_PASSWORD` 未設定=**設定画面は常に 403**(空パスワードでの突破を防ぐため)。
- `SESSION_SECRET_KEY` を設定しないと起動ごとに鍵が作り直され、再起動のたびに全員ログインし直しになる。
- ローカルの http 環境でログインが効かない場合のみ `SESSION_COOKIE_SECURE=false` にする。
## 17.5. 右サイドパネル(1 つのメニューに集約)
- ノート / ナレッジベース / 設定 / ログは、ヘッダー右端のメニュー 1 つから切り替える。
- 開いているパネルのアイコンと名前がボタンに出る。同じ項目をもう一度選ぶか「パネルを閉じる」で閉じる。
- メニューの外のクリックと `Esc` でメニューだけを閉じられる(パネルは開いたまま)。
- パネルを増やすときは `frontend/src/panels.ts` に 1 行足すだけ。
メニューの項目と、開いたときに描画するコンポーネントをこの配列が兼ねている。
## 18. デプロイ
- `docker-compose.yml` に backend(FastAPI)/ frontend(nginx で静的配信+API リバースプロキシ)/
caddy(Let's Encrypt 自動取得と HTTPS 終端)の 3 コンテナが定義済み。
- SQLite と `knowledge_base` は Docker ボリュームに永続化される。
- フロントは API をすべて相対パスで呼ぶため、同一オリジン配信なら CORS 設定は実質不要。
---
## 制約・注意点
このアプリを触るときに引っかかりやすい点。
- **1 応答あたり LLM 呼び出しは最低 2 回**。ツールを使わない会話でも `resolve_tool_calls()` が
フルコンテキストを 1 回投げてから本番生成に入る。
- **トークン数ベースの制御が一切ない**。すべて件数・文字数ベースなので、層 3(Markdown 全文 ×3)は
会話が短くてもコンテキストを大きく押し上げる(層 1 は見出しのみなので軽い)。
- **長期メモリに上限がない**。長いセッションでは要約が積み上がり続ける(要約の再要約はしない)。
- **要約自体は不可逆**。アーカイブされた元メッセージは残るが、要約をやり直したり元の状態に戻す手段は無い。
- **ワードプールは揮発性**。再起動でリセットされ、直近 200 件から作り直される。
- **DB マイグレーションは簡易実装**。Alembic は依存に入っているが使っておらず、起動時に
`PRAGMA table_info` を見て不足カラムを `ALTER TABLE` で足すだけ(SQLite 前提)。
- **自動テストは無い**。`pytest` は dev 依存に入っているが `backend/tests/` は存在しない。
+121
View File
@@ -0,0 +1,121 @@
# 共有ノートの使い方
ユーザーとAIが同じノートを一緒に編集できる機能。ページをタブで切り替え、関連するページ同士は
セクションでグループ化できる。実装の詳細は `docs/architecture.md` の「共有ノート」節、
API 仕様は `docs/api.md` の「共有ノート」節を参照。
## 開き方
画面右上ヘッダーの **「ノート」** ボタンをクリックすると、右側にノートパネルが開く。
もう一度クリックすると閉じる。
## ページの操作
| 操作 | 方法 |
|---|---|
| ページを開く | タブをクリック |
| ページを新規作成 | タブ列右端の **+** をクリック(末尾に「新しいページ」が追加される) |
| タイトルを変更 | タブを**ダブルクリック**すると入力欄になるので書き換えて Enter(または欄外をクリック) |
| ページを削除 | タブ内の **×** をクリック(確認ダイアログあり) |
| 本文を編集 | ページを開いた状態で下の編集エリアに直接入力 |
| ファイルを取り込む | 本文エリアにファイルをドロップ、またはパネル下部の **⬆ 取り込み** から選択 |
| チャットに紐付ける | タブ内の **📌** をクリック(もう一度押すと解除) |
| ページを1つ保存 | パネル下部の **⬇ このページ (.md)** をクリック |
| 全ページを保存 | パネル下部の **⬇ すべて (.zip)** をクリック |
本文は入力を止めてから少し待つと自動保存される。保存待ちの間はタブに **●**(未保存マーク)が付く。
ページが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つのグループとしてタブ上にまとめられる。
| 操作 | 方法 |
|---|---|
| ページをセクションに入れる | タブにマウスを乗せると出てくる **🏷** をクリックし、セクション名を入力 |
| セクション名を変更する | セクションのラベル(タブ群の左上に表示される名前)をクリックし、新しい名前を入力 |
| セクションを解除する | 🏷 またはセクションラベルをクリックした際、名前を**空欄**にして確定 |
同じセクションに入れたページは、タブ列上で必ず隣り合って表示される
(新しくセクションに追加したページは、そのセクションの末尾に自動的に移動する)。
セクション名を変更すると、そのグループに属する全ページのラベルが一括で変わる。
## タブをドラッグで動かす
タブは**ドラッグ&ドロップ**でも操作できる(🏷 やセクションラベルのクリックと同等の結果になる)。
| 操作 | 方法 |
|---|---|
| 同じグループ内で順番を変える | タブを掴んで、移動先のタブの左半分/右半分にドロップ(左=そのタブの前、右=後ろに入る) |
| 別のセクションへ移動する | 移動先のセクション内のタブにドロップ(そのタブと同じセクションに入る) |
| セクションから外す | 未分類のタブにドロップ、またはタブ列の一番右(+ボタンの手前の細い枠)にドロップ |
ドラッグ中はドロップ位置に青い線が表示される。タイトル編集中(ダブルクリックで入力欄になっている間)のタブはドラッグできない。
## AIとの連携
- 全ページの**タイトル一覧**は、会話のたびに自動でAIに渡される(セクションがあれば見出し付きで)。
ただし本文はここには含まれない(📌 を付けたページを除く)。
- AIは `read_note` ツールでページ本文を読み、`write_note` ツールで追記・置き換え・新規ページ作成ができる。
「ノートに書いておいて」「〇〇について記録して」のように頼むと、AIが該当ページを判断して書き込む。
- AIがノートを書き換えると、開いているノートパネルは自動的に最新の内容へ更新される。
- 自分が編集中(未保存)のページは、AIの更新で上書きされない。
+22 -282
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"dompurify": "^3.1.0",
"katex": "^0.16.47",
"marked": "^12.0.0",
"pinia": "^2.1.7",
"vue": "^3.4.0",
@@ -1117,6 +1118,15 @@
"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": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1130,18 +1140,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/dompurify": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
@@ -1254,278 +1252,20 @@
"he": "bin/he"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"optional": true,
"peer": true,
"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": {
"detect-libc": "^2.0.3"
"commander": "^8.3.0"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
"bin": {
"katex": "cli.js"
}
},
"node_modules/magic-string": {
+1
View File
@@ -11,6 +11,7 @@
},
"dependencies": {
"dompurify": "^3.1.0",
"katex": "^0.16.47",
"marked": "^12.0.0",
"pinia": "^2.1.7",
"vue": "^3.4.0",
+15 -31
View File
@@ -1,27 +1,23 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import SessionList from './components/SessionList.vue'
import ChatWindow from './components/ChatWindow.vue'
import MessageInput from './components/MessageInput.vue'
import ModelSelector from './components/ModelSelector.vue'
import LogViewer from './components/LogViewer.vue'
import SettingsPanel from './components/SettingsPanel.vue'
import NotePanel from './components/NotePanel.vue'
import SidePanelMenu from './components/SidePanelMenu.vue'
import ToastList from './components/ToastList.vue'
import MemoryMonitor from './components/MemoryMonitor.vue'
import Clock from './components/Clock.vue'
import WordPool from './components/WordPool.vue'
import LoginGate from './components/LoginGate.vue'
import { SIDE_PANELS, type PanelId } from './panels'
import { useAuthStore } from './stores/auth'
const authStore = useAuthStore()
type SidePanel = 'none' | 'logs' | 'settings' | 'notes'
const activePanel = ref<SidePanel>('none')
function togglePanel(panel: SidePanel) {
activePanel.value = activePanel.value === panel ? 'none' : panel
}
/** 右サイドに開いているパネル。null はどれも開いていない状態。 */
const activePanel = ref<PanelId | null>(null)
const activePanelDef = computed(() => SIDE_PANELS.find((p) => p.id === activePanel.value) ?? null)
function handleUnauthorized() {
authStore.markUnauthenticated()
@@ -51,32 +47,20 @@ onUnmounted(() => {
<span class="title">PlayChat</span>
<ModelSelector />
<Clock />
<button class="log-toggle" :class="{ active: activePanel === 'notes' }" @click="togglePanel('notes')">
ノート
</button>
<button class="log-toggle" :class="{ active: activePanel === 'settings' }" @click="togglePanel('settings')">
設定
</button>
<button class="log-toggle" :class="{ active: activePanel === 'logs' }" @click="togglePanel('logs')">
ログ
</button>
<button v-if="authStore.required" class="log-toggle" @click="authStore.logout()">
ログアウト
</button>
<div class="header-actions">
<SidePanelMenu :active="activePanel" @select="activePanel = $event" />
<button v-if="authStore.required" class="log-toggle" @click="authStore.logout()">
ログアウト
</button>
</div>
</header>
<div class="body">
<div class="chat-pane">
<ChatWindow />
<MessageInput />
</div>
<div v-if="activePanel === 'logs'" class="log-pane">
<LogViewer />
</div>
<div v-else-if="activePanel === 'settings'" class="log-pane">
<SettingsPanel />
</div>
<div v-else-if="activePanel === 'notes'" class="log-pane">
<NotePanel />
<div v-if="activePanelDef" class="log-pane">
<component :is="activePanelDef.component" />
</div>
</div>
</div>
@@ -101,11 +85,11 @@ header {
border-bottom: 1px solid #333; background: #1a1a1a; flex-shrink: 0;
}
.title { font-size: 16px; font-weight: 600; color: #fff; }
.header-actions { display: flex; align-items: center; gap: 8px; }
.log-toggle {
background: #2a2a2a; color: #aaa; border: 1px solid #444;
border-radius: 6px; padding: 4px 14px; font-size: 13px; cursor: pointer;
}
.log-toggle.active { background: #1d3557; color: #93c5fd; border-color: #2563eb; }
.body { flex: 1; display: flex; overflow: hidden; }
.chat-pane { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.log-pane { width: 400px; min-width: 300px; border-left: 1px solid #2a2a2a; overflow: hidden; }
+186 -7
View File
@@ -21,9 +21,23 @@ export interface Source {
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 {
type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources' | 'notes_updated'
value?: string | Source[]
type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources' | 'notes_updated' | 'tool'
value?: string | Source[] | ToolActivity
}
const BASE = ''
@@ -51,6 +65,8 @@ export interface MessageRecord {
role: 'user' | 'assistant' | 'system'
content: string
created_at: string
// 要約に取り込まれたメッセージ。モデルには渡らないが履歴としては残っている
archived_at: string | null
tags: string[]
}
@@ -59,6 +75,24 @@ export async function fetchMessages(sessionId: string): Promise<MessageRecord[]>
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> {
await fetch(`${BASE}/chat/sessions/${sessionId}`, { method: 'DELETE' })
}
@@ -83,6 +117,7 @@ export interface ImportPayload {
role: string
content: string
created_at?: string | null
archived_at?: string | null
tags?: string[]
}[]
}
@@ -141,7 +176,10 @@ export async function logout(): Promise<void> {
export interface SettingEntry {
key: string
is_set: boolean
masked: string | null
/** 秘匿キーはマスク済みの値、それ以外は現在値そのもの。未設定なら null。 */
display_value: string | null
/** APIキーなど、入力欄をパスワード扱いにするキーか。 */
secret: boolean
overridden: boolean
}
@@ -208,6 +246,8 @@ export function streamChat(
message: string,
sessionId: string | null,
model: string,
/** 本文をプロンプトに載せるノートのページID。新規セッションではここでしか指定できない。 */
notePageIds: number[],
onChunk: (chunk: SSEChunk) => void,
onError: (err: Error) => void,
): () => void {
@@ -216,7 +256,7 @@ export function streamChat(
fetch(`${BASE}/chat/stream`, {
method: 'POST',
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,
})
.then(async (res) => {
@@ -263,6 +303,7 @@ export interface NotePage {
title: string
content: string
position: number
section: string | null
updated_at: string
}
@@ -271,18 +312,18 @@ export async function fetchNotePages(): Promise<NotePage[]> {
return parseJson<NotePage[]>(res, 'ノート取得')
}
export async function createNotePage(title: string): Promise<NotePage> {
export async function createNotePage(title: string, section?: string | null): Promise<NotePage> {
const res = await fetch(`${BASE}/api/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
body: JSON.stringify({ title, section: section ?? null }),
})
return parseJson<NotePage>(res, 'ページ作成')
}
export async function updateNotePage(
pageId: number,
patch: { title?: string; content?: string },
patch: { title?: string; content?: string; section?: string | null },
): Promise<NotePage> {
const res = await fetch(`${BASE}/api/notes/${pageId}`, {
method: 'PATCH',
@@ -295,3 +336,141 @@ export async function updateNotePage(
export async function deleteNotePage(pageId: number): Promise<void> {
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ならグループ解除。 */
export async function renameNoteSection(oldName: string, newName: string | null): Promise<{ renamed: number }> {
const res = await fetch(`${BASE}/api/notes/sections/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ old_name: oldName, new_name: newName }),
})
return parseJson<{ renamed: number }>(res, 'セクション名変更')
}
/**
* タブのドラッグ&ドロップによる並べ替えをサーバーへ反映する。
* order には全ページIDをドラッグ後の新しい順序で渡す。pageId のページは section へ移動する
* (ドロップ先のタブと同じセクションを渡せば、その場でセクション間の移動を兼ねる)。
*/
export async function reorderNotePages(
pageId: number,
section: string | null,
order: number[],
): Promise<{ reordered: number }> {
const res = await fetch(`${BASE}/api/notes/reorder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page_id: pageId, section, order }),
})
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, 'ナレッジベースファイル取得')
}
/** ローカルナレッジベースの埋め込み状況。GUI の状態表示・進捗バーに使う。 */
export interface KbEmbeddingStatus {
model: string
/** 現在のモデルで埋め込まれたベクトルの次元数(1件も無ければ null)。 */
dimensions: number | null
file_count: number
indexed: number
/** 別のモデルで埋め込まれていて埋め直しが必要な件数。 */
stale: number
running: boolean
total: number
done: number
failed: number
started_at: string | null
finished_at: string | null
last_error: string | null
}
/** 埋め込みモデルに実際に1回リクエストしてみた結果。 */
export interface KbEmbeddingProbe {
ok: boolean
model: string
dimensions: number | null
latency_ms: number
error: string | null
}
export async function fetchKbEmbeddingStatus(): Promise<KbEmbeddingStatus> {
const res = await fetch(`${BASE}/api/kb/embedding`)
return parseJson<KbEmbeddingStatus>(res, '埋め込み状態取得')
}
export async function testKbEmbedding(): Promise<KbEmbeddingProbe> {
const res = await fetch(`${BASE}/api/kb/embedding/test`, { method: 'POST' })
return parseJson<KbEmbeddingProbe>(res, '埋め込みテスト')
}
/** 全ファイルの再埋め込みをバックグラウンドで開始する。戻り値は開始直後の状態。 */
export async function reindexKbEmbeddings(): Promise<KbEmbeddingStatus> {
const res = await fetch(`${BASE}/api/kb/embedding/reindex`, { method: 'POST' })
return parseJson<KbEmbeddingStatus>(res, '再埋め込み開始')
}
+35 -4
View File
@@ -1,12 +1,21 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { computed, ref, watch, nextTick } from 'vue'
import { useChatStore } from '../stores/chat'
import MarkdownContent from './MarkdownContent.vue'
import ToolActivityList from './ToolActivityList.vue'
import type { Source } from '../api/client'
const store = useChatStore()
const scrollEl = ref<HTMLElement | null>(null)
// 要約済みメッセージの最後の位置。ここに「ここから先がモデルに渡る」区切りを描く。
const lastArchivedIndex = computed(() => {
for (let i = store.messages.length - 1; i >= 0; i--) {
if (store.messages[i].archived) return i
}
return -1
})
function uniqueSources(sources: Source[]): Source[] {
const seen = new Set<string>()
return sources.filter((s) => {
@@ -30,11 +39,12 @@ watch(
<div v-if="store.messages.length === 0" class="empty">
メッセージを送信してチャットを開始してください
</div>
<template v-for="(msg, i) in store.messages" :key="i">
<div
v-for="(msg, i) in store.messages"
:key="i"
:class="['message-wrap', msg.role]"
:class="['message-wrap', msg.role, { archived: msg.archived }]"
: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="bubble">
<!-- ユーザーメッセージはプレーンテキスト -->
@@ -65,6 +75,10 @@ watch(
>{{ src.title || src.url }}</a>
</div>
</div>
<div v-if="i === lastArchivedIndex" class="archive-divider">
<span>ここまでは要約済み — 履歴として残っていますが、モデルには要約だけが渡ります</span>
</div>
</template>
</div>
</template>
@@ -85,6 +99,23 @@ watch(
.message-wrap { display: flex; flex-direction: column; gap: 4px; }
.message-wrap.user { align-items: flex-end; }
.message-wrap.assistant { align-items: flex-start; }
/* 要約済み(モデルには渡らない)メッセージ。読めるが現役の発言と区別できる程度に落とす */
.message-wrap.archived { opacity: 0.45; }
.message-wrap.archived:hover { opacity: 0.75; }
.archive-divider {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 0;
color: #777;
font-size: 11px;
}
.archive-divider::before,
.archive-divider::after {
content: '';
flex: 1;
border-top: 1px dashed #3a3a3a;
}
.message { display: flex; width: 100%; }
.message.user { justify-content: flex-end; }
.message.assistant { justify-content: flex-start; }
@@ -0,0 +1,256 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'
import MarkdownContent from './MarkdownContent.vue'
import { useKnowledgeBaseStore } from '../stores/knowledgeBase'
const store = useKnowledgeBaseStore()
onMounted(() => {
store.load()
store.loadEmbedding()
})
// パネルを閉じている間もポーリングが回り続けないように止める
onUnmounted(() => store.stopPolling())
const progressPercent = computed(() => {
const e = store.embedding
if (!e || e.total === 0) return 0
return Math.round(((e.done + e.failed) / e.total) * 100)
})
function formatSize(bytes: number): string {
return bytes < 1024 ? `${bytes} B` : `${Math.round(bytes / 1024)} KB`
}
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 v-if="store.embedding" class="embedding">
<div class="embedding-head">
<span class="embedding-model" :title="`埋め込みモデル: ${store.embedding.model}`">
🧠 {{ store.embedding.model }}
</span>
<button class="mini-btn" :disabled="store.testing" title="このモデルに実際に1回リクエストして応答を確かめる"
@click="store.testEmbedding()">
{{ store.testing ? 'テスト中…' : '接続テスト' }}
</button>
<button class="mini-btn" :disabled="store.embedding.running" title="全ファイルを現在のモデルで埋め込み直す"
@click="store.reindex()">
埋め直し
</button>
</div>
<div class="embedding-stats">
<span :class="{ warn: store.embedding.indexed < store.embedding.file_count }">
埋め込み済み {{ store.embedding.indexed }} / {{ store.embedding.file_count }}
</span>
<span v-if="store.embedding.stale > 0" class="warn">
・別モデルのまま {{ store.embedding.stale }}
</span>
<span v-if="store.embedding.dimensions">・{{ store.embedding.dimensions }} 次元</span>
</div>
<template v-if="store.embedding.running">
<div class="progress"><div class="progress-bar" :style="{ width: `${progressPercent}%` }" /></div>
<div class="embedding-note">
<template v-if="store.embedding.total === 0">準備中…</template>
<template v-else>
埋め込み中 {{ store.embedding.done + store.embedding.failed }} / {{ store.embedding.total }}
<span v-if="store.embedding.failed > 0" class="ng">(失敗 {{ store.embedding.failed }})</span>
</template>
</div>
</template>
<div v-if="store.probe" class="probe" :class="store.probe.ok ? 'ok' : 'ng'">
<template v-if="store.probe.ok">
✅ 応答あり — {{ store.probe.dimensions }} 次元 / {{ Math.round(store.probe.latency_ms) }} ms
</template>
<template v-else>❌ {{ store.probe.error }}</template>
</div>
<div v-if="store.embedding.last_error" class="probe ng">
直近の埋め込みエラー: {{ store.embedding.last_error }}
</div>
</div>
<div class="search">
<input v-model="store.filter" type="search" placeholder="タイトル・パス・本文の先頭で絞り込み">
<span class="count">{{ store.filteredFiles.length }} / {{ store.files.length }}</span>
</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; }
.embedding {
padding: 8px 12px; border-bottom: 1px solid #1e1e1e; flex-shrink: 0;
display: flex; flex-direction: column; gap: 6px;
}
.embedding-head { display: flex; align-items: center; gap: 6px; }
.embedding-model {
flex: 1; color: #cbd5e1; font-size: 12px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.mini-btn {
background: #2a2a2a; color: #aaa; border: 1px solid #444;
border-radius: 5px; padding: 2px 8px; font-size: 11px; cursor: pointer; white-space: nowrap;
}
.mini-btn:hover:not(:disabled) { color: #fff; }
.mini-btn:disabled { opacity: 0.5; cursor: default; }
.embedding-stats { color: #6b7280; font-size: 11px; }
.embedding-stats .warn { color: #fbbf24; }
.embedding-note { color: #6b7280; font-size: 11px; }
.progress { height: 4px; background: #262626; border-radius: 2px; overflow: hidden; }
.progress-bar { height: 100%; background: #2563eb; transition: width 0.3s; }
.probe { font-size: 11px; line-height: 1.5; word-break: break-word; }
.probe.ok { color: #4ade80; }
.probe.ng, .ng { color: #f87171; }
.search {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border-bottom: 1px solid #1e1e1e; flex-shrink: 0;
}
.search input {
flex: 1; background: #1a1a1a; color: #ddd; border: 1px solid #333;
border-radius: 5px; padding: 4px 8px; font-size: 12px;
}
.search input:focus { outline: none; border-color: #2563eb; }
.count { font-size: 11px; color: #555; white-space: nowrap; }
.list { flex: 1; overflow-y: auto; padding-bottom: 4px; }
.group-header {
display: flex; align-items: center; gap: 6px; width: 100%;
background: #171717; border: none; border-bottom: 1px solid #1e1e1e;
color: #888; font-size: 11px; padding: 5px 12px; cursor: pointer;
position: sticky; top: 0;
}
.group-header:hover { color: #ccc; }
.arrow { font-size: 9px; }
.dir { flex: 1; text-align: left; font-family: 'Consolas', monospace; }
.group-count { color: #555; }
.file {
display: block; width: 100%; text-align: left;
background: none; border: none; border-bottom: 1px solid #1a1a1a;
padding: 8px 12px; cursor: pointer;
}
.file:hover { background: #1a1a1a; }
.file-title { display: flex; align-items: center; gap: 7px; }
.dot {
width: 6px; height: 6px; border-radius: 50%; background: #444; flex-shrink: 0;
}
.dot.indexed { background: #22c55e; }
.name {
flex: 1; color: #ddd; font-size: 12px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.size { color: #555; font-size: 11px; white-space: nowrap; }
.file-preview {
margin-top: 3px; padding-left: 13px; color: #666; font-size: 11px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.file-meta {
display: flex; align-items: center; gap: 10px; justify-content: space-between;
padding: 6px 12px; border-bottom: 1px solid #1e1e1e;
color: #666; font-size: 11px; flex-shrink: 0;
}
.path {
color: #7dd3fc; font-family: 'Consolas', monospace;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.content { flex: 1; overflow-y: auto; padding: 12px 14px; color: #ddd; font-size: 13px; }
.empty { padding: 20px; text-align: center; color: #555; font-size: 12px; line-height: 1.8; }
.hint {
padding: 8px 12px; border-top: 1px solid #1e1e1e;
color: #666; font-size: 11px; line-height: 1.6; flex-shrink: 0;
}
.hint code { color: #9ca3af; font-family: 'Consolas', monospace; }
</style>
+13 -1
View File
@@ -2,6 +2,8 @@
import { computed } from 'vue'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import 'katex/dist/katex.min.css'
import { mathExtension, mathSanitizeConfig } from '../utils/math'
const props = defineProps<{
content: string
@@ -9,6 +11,7 @@ const props = defineProps<{
}>()
marked.setOptions({ breaks: true, gfm: true })
marked.use(mathExtension)
function closeOpenFences(src: string): string {
const fenceCount = (src.match(/^```/gm) ?? []).length
@@ -18,7 +21,7 @@ function closeOpenFences(src: string): string {
const html = computed(() => {
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>
@@ -104,6 +107,15 @@ const html = computed(() => {
/* 水平線 */
.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:hover) { text-decoration: underline; }
+348 -39
View File
@@ -1,15 +1,82 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, nextTick } from 'vue'
import { onMounted, onUnmounted, ref, nextTick, computed } from 'vue'
import { useNotesStore } from '../stores/notes'
import { useChatStore } from '../stores/chat'
import type { NotePage } from '../api/client'
interface TabGroup {
section: string | null
pages: NotePage[]
}
const store = useNotesStore()
const chat = useChatStore()
const renamingId = ref<number | null>(null)
const renameInput = ref<HTMLInputElement | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
/** ファイルを本文エリアの上までドラッグしてきているか(取り込み用のハイライト表示) */
const fileOver = ref(false)
// ref="renameInput" は入れ子の v-for の中にあるため、Vue 3 では単一要素ではなく配列になる
const renameInput = ref<HTMLInputElement[]>([])
/** ドラッグ中のページID */
const draggingId = ref<number | null>(null)
/** ドラッグ中に今どのタブの前/後にドロップされるかの表示用インジケータ */
const dropTarget = ref<{ id: number; place: 'before' | 'after' } | null>(null)
/** タブ列の末尾(+ボタンの手前)にドロップしようとしているか */
const dropAtEnd = ref(false)
/** 隣接して同じsectionが続く区間を1グループとして表示する(並び順はサーバー側が保証) */
const groups = computed<TabGroup[]>(() => {
const result: TabGroup[] = []
for (const page of store.pages) {
const last = result[result.length - 1]
if (last && last.section === page.section) {
last.pages.push(page)
} else {
result.push({ section: page.section, pages: [page] })
}
}
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) {
renamingId.value = id
await nextTick()
renameInput.value?.select()
renameInput.value[0]?.select()
}
function onTitleInput(id: number, e: Event) {
@@ -31,6 +98,73 @@ async function confirmRemove(id: number, title: string) {
await store.removePage(id)
}
async function assignSection(page: NotePage) {
const input = window.prompt('このページが属するセクション名(空にするとグループ解除)', page.section ?? '')
if (input === null) return
await store.setSection(page.id, input.trim())
}
async function renameSectionGroup(currentName: string) {
const input = window.prompt('セクション名を変更(空にするとグループを解除)', currentName)
if (input === null) return
await store.renameSection(currentName, input.trim())
}
function onDragStart(id: number, e: DragEvent) {
draggingId.value = id
e.dataTransfer?.setData('text/plain', String(id)) // Firefoxはdataが無いとドラッグを許可しない
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'
}
function onDragEnd() {
draggingId.value = null
dropTarget.value = null
dropAtEnd.value = false
}
function onDragOverTab(page: NotePage, e: DragEvent) {
if (draggingId.value === null || draggingId.value === page.id) return
dropAtEnd.value = false
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const place: 'before' | 'after' = e.clientX - rect.left < rect.width / 2 ? 'before' : 'after'
dropTarget.value = { id: page.id, place }
}
function onDragOverEnd() {
if (draggingId.value === null) return
dropTarget.value = null
dropAtEnd.value = true
}
/** ドラッグ後の新しい並びを計算してサーバーへ送る。section にドロップ先タブの所属セクションを渡すと、並べ替えと同時にセクション間の移動も反映される。 */
async function moveTab(id: number, targetId: number | null, place: 'before' | 'after', section: string | null) {
const order = store.pages.map((p) => p.id)
const fromIdx = order.indexOf(id)
if (fromIdx === -1) return
order.splice(fromIdx, 1)
const insertAt = targetId === null
? order.length
: order.indexOf(targetId) + (place === 'after' ? 1 : 0)
order.splice(insertAt, 0, id)
await store.reorder(id, section, order)
}
async function onDropTab(page: NotePage, e: DragEvent) {
const id = draggingId.value
onDragEnd()
if (id === null || id === page.id) return
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const place: 'before' | 'after' = e.clientX - rect.left < rect.width / 2 ? 'before' : 'after'
await moveTab(id, page.id, place, page.section)
}
async function onDropEnd() {
const id = draggingId.value
onDragEnd()
if (id === null) return
await moveTab(id, null, 'after', null)
}
onMounted(() => {
store.load()
})
@@ -43,51 +177,137 @@ onUnmounted(() => {
<div class="note-panel">
<div class="tabs">
<div
v-for="page in store.pages"
:key="page.id"
class="tab"
:class="{ active: page.id === store.activeId }"
@click="store.selectPage(page.id)"
@dblclick="startRename(page.id)"
v-for="group in groups"
:key="`${group.section ?? '_'}-${group.pages[0].id}`"
class="tab-group"
:class="{ named: group.section }"
>
<input
v-if="renamingId === page.id"
ref="renameInput"
class="tab-rename"
:value="page.title"
@input="onTitleInput(page.id, $event)"
@blur="finishRename"
@keydown.enter="finishRename"
@keydown.esc="finishRename"
@click.stop
<button
v-if="group.section"
class="section-label"
:title="`「${group.section}」セクション。クリックで名前変更・解除`"
@click="renameSectionGroup(group.section)"
>{{ group.section }}</button>
<div
v-for="page in group.pages"
:key="page.id"
class="tab"
:class="{
active: page.id === store.activeId,
attached: store.attachedIds.has(page.id),
dragging: draggingId === page.id,
'drop-before': dropTarget?.id === page.id && dropTarget.place === 'before',
'drop-after': dropTarget?.id === page.id && dropTarget.place === 'after',
}"
:draggable="renamingId !== page.id"
@click="store.selectPage(page.id)"
@dblclick="startRename(page.id)"
@dragstart="onDragStart(page.id, $event)"
@dragend="onDragEnd"
@dragover.prevent="onDragOverTab(page, $event)"
@drop.prevent="onDropTab(page, $event)"
>
<template v-else>
<span class="tab-title" :title="page.title">{{ page.title }}</span>
<span v-if="store.dirtyIds.has(page.id)" class="dot" title="未保存">●</span>
<button class="tab-del" title="削除" @click.stop="confirmRemove(page.id, page.title)">×</button>
</template>
<input
v-if="renamingId === page.id"
ref="renameInput"
class="tab-rename"
:value="page.title"
@input="onTitleInput(page.id, $event)"
@blur="finishRename"
@keydown.enter="finishRename"
@keydown.esc="finishRename"
@click.stop
>
<template v-else>
<span class="tab-title" :title="page.title">{{ page.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-del" title="削除" @click.stop="confirmRemove(page.id, page.title)">×</button>
</template>
</div>
</div>
<div
v-if="draggingId !== null"
class="drop-end"
:class="{ active: dropAtEnd }"
@dragover.prevent="onDragOverEnd"
@drop.prevent="onDropEnd"
/>
<button class="tab-add" title="ページを追加" @click="store.addPage()">+</button>
</div>
<div v-if="store.error" class="error">⚠ {{ store.error }}</div>
<textarea
v-if="store.activePage"
class="editor"
:value="store.activePage.content"
placeholder="ここに書いた内容はAIと共有されます。AIも read_note / write_note でこのページを読み書きできます。"
spellcheck="false"
@input="onContentInput"
@blur="store.flush()"
/>
<div v-else class="empty">
<p>ページがありません。</p>
<button class="create-btn" @click="store.addPage()">+ 最初のページを作成</button>
<div
class="body"
:class="{ 'file-over': fileOver }"
@dragover="onFileDragOver"
@dragleave="fileOver = false"
@drop="onFileDrop"
>
<textarea
v-if="store.activePage"
class="editor"
:value="store.activePage.content"
placeholder="ここに書いた内容はAIと共有されます。AIも read_note / write_note でこのページを読み書きできます。&#10;.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 class="hint">
ページのタイトル一覧はAIのシステムプロンプトに常に含まれます。本文はAIが必要に応じて読み出します。
🏷 でページをセクションにまとめられます(セクション名をクリックすると変更・解除)。
タブはドラッグで並べ替え・他のセクションへの移動ができます。
⬇ でノートを手元にダウンロードできます(未保存の編集は保存してから書き出します)。
⬆ または本文エリアへのドロップで .md / .txt / .zip を取り込めます(⬇ で出した ZIP を戻せばセクションごと復元されます)。
<span class="hint-pin">
📌 を付けたページは、<strong>今開いているチャット</strong>のプロンプトに本文がそのまま載ります
(AIが read_note で読みに行くのを待たずに確実に参照されます)。指定はチャットごとに保存されます。
</span>
</div>
</div>
</template>
@@ -101,13 +321,40 @@ onUnmounted(() => {
}
.tabs {
display: flex;
align-items: stretch;
align-items: flex-end;
gap: 2px;
padding: 6px 6px 0;
overflow-x: auto;
flex-shrink: 0;
border-bottom: 1px solid #333;
}
.tab-group {
display: flex;
align-items: flex-end;
gap: 2px;
}
.tab-group.named {
background: #161d2e;
border: 1px solid #29314a;
border-bottom: none;
border-radius: 8px 8px 0 0;
padding: 3px 3px 0;
margin-right: 4px;
}
.section-label {
background: none;
border: none;
color: #7fa8e0;
font-size: 10px;
font-weight: 600;
cursor: pointer;
padding: 2px 5px 4px;
white-space: nowrap;
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
}
.section-label:hover { color: #a8c8f0; text-decoration: underline; }
.tab {
display: flex;
align-items: center;
@@ -124,9 +371,22 @@ onUnmounted(() => {
max-width: 160px;
}
.tab.active { background: #2a2a2a; color: #fff; border-color: #444; }
/* プロンプトに本文を載せているページ。タブ上端の線で「積んでいる」ことを示す */
.tab.attached { box-shadow: inset 0 2px 0 #eab308; }
.tab.dragging { opacity: 0.4; }
.tab.drop-before { box-shadow: inset 2px 0 0 #93c5fd; }
.tab.drop-after { box-shadow: inset -2px 0 0 #93c5fd; }
.drop-end {
align-self: stretch;
width: 14px;
flex-shrink: 0;
margin: 0 2px;
border-radius: 3px;
}
.drop-end.active { background: #93c5fd; opacity: 0.5; }
.tab-title { overflow: hidden; text-overflow: ellipsis; }
.dot { color: #eab308; font-size: 8px; }
.tab-del, .tab-add {
.tab-pin, .tab-tag, .tab-del, .tab-add {
background: none;
border: none;
color: #666;
@@ -135,8 +395,15 @@ onUnmounted(() => {
padding: 0 2px;
line-height: 1;
}
.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-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-add { color: #888; padding: 0 8px; }
.tab-add { color: #888; padding: 0 8px; align-self: center; }
.tab-add:hover { color: #93c5fd; }
.tab-rename {
background: #111;
@@ -154,6 +421,25 @@ onUnmounted(() => {
background: #2a1414;
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 {
flex: 1;
resize: none;
@@ -167,6 +453,7 @@ onUnmounted(() => {
line-height: 1.6;
}
.editor::placeholder { color: #555; }
.empty-hint { color: #555; font-size: 11px; }
.empty {
flex: 1;
display: flex;
@@ -186,6 +473,28 @@ onUnmounted(() => {
font-size: 12px;
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 {
flex-shrink: 0;
padding: 8px 10px;
+3 -5
View File
@@ -46,11 +46,9 @@ const KEY_LABELS: Record<string, string> = {
OLLAMA_API_BASE: 'Ollama API Base URL',
TAVILY_API_KEY: 'Tavily APIキー',
LOCAL_KB_EMBEDDING_MODEL: '埋め込みモデル(ローカルナレッジベース検索用)',
SEARCH_SUMMARY_MODEL: '要約モデル(Web検索結果のまとめ用)',
}
// 秘匿情報ではないため、マスクせず通常のテキスト入力として表示するキー
const PLAIN_TEXT_KEYS = new Set(['LOCAL_KB_EMBEDDING_MODEL', 'OLLAMA_API_BASE', 'AWS_REGION_NAME'])
async function submitPassword() {
const ok = await store.unlock(passwordInput.value)
if (ok) passwordInput.value = ''
@@ -106,8 +104,8 @@ onMounted(() => {
<div class="row">
<input
v-model="drafts[entry.key]"
:type="PLAIN_TEXT_KEYS.has(entry.key) ? 'text' : 'password'"
:placeholder="entry.is_set ? `設定済み ${entry.masked}` : '未設定'"
:type="entry.secret ? 'password' : 'text'"
:placeholder="entry.is_set ? `現在: ${entry.display_value}` : '未設定'"
autocomplete="off"
/>
<button
+118
View File
@@ -0,0 +1,118 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { SIDE_PANELS, type PanelId } from '../panels'
const props = defineProps<{ active: PanelId | null }>()
const emit = defineEmits<{ (e: 'select', panel: PanelId | null): void }>()
const open = ref(false)
const root = ref<HTMLElement | null>(null)
const activePanel = computed(() => SIDE_PANELS.find((p) => p.id === props.active) ?? null)
/** 項目を選ぶ。開いているパネルをもう一度選んだら閉じる(従来のトグルと同じ操作感)。 */
function select(id: PanelId) {
emit('select', props.active === id ? null : id)
open.value = false
}
function close() {
emit('select', null)
open.value = false
}
function onDocumentPointerDown(e: PointerEvent) {
if (open.value && !root.value?.contains(e.target as Node)) open.value = false
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') open.value = false
}
onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown)
document.addEventListener('keydown', onKeydown)
})
onUnmounted(() => {
document.removeEventListener('pointerdown', onDocumentPointerDown)
document.removeEventListener('keydown', onKeydown)
})
</script>
<template>
<div ref="root" class="panel-menu">
<button
class="menu-btn"
:class="{ active: activePanel !== null, open }"
:title="activePanel ? `${activePanel.label}を表示中` : 'パネルを開く'"
@click="open = !open"
>
<span class="menu-icon">{{ activePanel ? activePanel.icon : '☰' }}</span>
<span class="menu-label">{{ activePanel ? activePanel.label : 'パネル' }}</span>
<span class="caret">▾</span>
</button>
<div v-if="open" class="dropdown">
<button
v-for="panel in SIDE_PANELS"
:key="panel.id"
class="item"
:class="{ current: panel.id === active }"
@click="select(panel.id)"
>
<span class="item-icon">{{ panel.icon }}</span>
<span class="item-text">
<span class="item-label">{{ panel.label }}</span>
<span class="item-desc">{{ panel.description }}</span>
</span>
<span class="check">{{ panel.id === active ? '✓' : '' }}</span>
</button>
<button v-if="activePanel" class="item close-item" @click="close">
<span class="item-icon">✕</span>
<span class="item-text"><span class="item-label">パネルを閉じる</span></span>
<span class="check" />
</button>
</div>
</div>
</template>
<style scoped>
.panel-menu { position: relative; }
.menu-btn {
display: flex; align-items: center; gap: 6px;
background: #2a2a2a; color: #aaa; border: 1px solid #444;
border-radius: 6px; padding: 4px 12px; font-size: 13px; cursor: pointer;
}
.menu-btn:hover { color: #ddd; }
.menu-btn.active { background: #1d3557; color: #93c5fd; border-color: #2563eb; }
.menu-btn.open { border-color: #666; }
.menu-btn.active.open { border-color: #3b82f6; }
.menu-icon { font-size: 13px; line-height: 1; }
.menu-label { white-space: nowrap; }
.caret { font-size: 9px; color: #777; }
.dropdown {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 50;
min-width: 260px;
background: #1c1c1c; border: 1px solid #3a3a3a; border-radius: 8px;
padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}
.item {
display: flex; align-items: center; gap: 10px; width: 100%;
background: none; border: none; border-radius: 6px;
padding: 7px 9px; cursor: pointer; text-align: left;
}
.item:hover { background: #262626; }
.item.current { background: #1d3557; }
.item-icon { font-size: 14px; width: 18px; text-align: center; flex-shrink: 0; }
.item-text { flex: 1; display: flex; flex-direction: column; gap: 1px; overflow: hidden; }
.item-label { color: #ddd; font-size: 13px; }
.item.current .item-label { color: #93c5fd; }
.item-desc {
color: #6b7280; font-size: 11px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.check { color: #93c5fd; font-size: 11px; width: 10px; flex-shrink: 0; }
.close-item { border-top: 1px solid #2a2a2a; border-radius: 0 0 6px 6px; margin-top: 2px; }
.close-item .item-label { color: #888; }
</style>
+4 -3
View File
@@ -9,10 +9,10 @@ const store = useChatStore()
<div
v-for="toast in store.toasts"
:key="toast.id"
class="toast"
:class="['toast', toast.kind]"
@click="store.dismissToast(toast.id)"
>
<span class="icon">⚠</span>
<span class="icon">{{ toast.kind === 'success' ? '✓' : '⚠' }}</span>
<span class="msg">{{ toast.message }}</span>
<button class="close">✕</button>
</div>
@@ -46,9 +46,10 @@ const store = useChatStore()
animation: slide-in 0.2s ease;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
.toast.success { background: #0a1f14; border-color: #166534; color: #6ee7b7; }
.icon { flex-shrink: 0; }
.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 {
from { opacity: 0; transform: translateY(8px); }
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>
+28
View File
@@ -0,0 +1,28 @@
import type { Component } from 'vue'
import KnowledgeBasePanel from './components/KnowledgeBasePanel.vue'
import LogViewer from './components/LogViewer.vue'
import NotePanel from './components/NotePanel.vue'
import SettingsPanel from './components/SettingsPanel.vue'
/** 右サイドに開けるパネル。'none' はどれも開いていない状態。 */
export type PanelId = 'notes' | 'kb' | 'settings' | 'logs'
export interface SidePanelDef {
id: PanelId
label: string
icon: string
/** メニューに添える一行説明 */
description: string
component: Component
}
/**
* 右サイドパネルの一覧。ヘッダーのメニューの並びと、開いたときに描画されるコンポーネントを兼ねる。
* パネルを増やすときはここに1行足すだけでよい。
*/
export const SIDE_PANELS: SidePanelDef[] = [
{ id: 'notes', label: 'ノート', icon: '📝', description: 'AIと共有するノートの編集', component: NotePanel },
{ id: 'kb', label: 'ナレッジベース', icon: '📚', description: 'knowledge_base/ の Markdown を閲覧', component: KnowledgeBasePanel },
{ id: 'settings', label: '設定', icon: '⚙️', description: 'APIキー・モデルプリセット', component: SettingsPanel },
{ id: 'logs', label: 'ログ', icon: '📜', description: 'LLM API 呼び出しの履歴', component: LogViewer },
]
+46 -5
View File
@@ -1,20 +1,24 @@
import { defineStore } from 'pinia'
import { useNotesStore } from './notes'
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 {
role: 'user' | 'assistant'
content: string
pending?: boolean
error?: boolean
archived?: boolean
tags?: string[]
sources?: Source[]
/** この応答を作る際に実行したツール(Web検索・ノート読み書きなど)。表示専用で永続化はしない。 */
tools?: ToolActivity[]
}
export interface Toast {
id: number
message: string
kind: 'error' | 'success'
}
let toastSeq = 0
@@ -31,9 +35,9 @@ export const useChatStore = defineStore('chat', () => {
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
toasts.value.push({ id, message })
toasts.value.push({ id, message, kind })
setTimeout(() => {
toasts.value = toasts.value.filter((t) => t.id !== id)
}, durationMs)
@@ -50,12 +54,19 @@ export const useChatStore = defineStore('chat', () => {
async function selectSession(sessionId: string | null) {
currentSessionId.value = sessionId
messages.value = []
// プロンプトに載せるノートはセッションごとの設定なので、切り替えのたびに読み直す
useNotesStore().loadAttached(sessionId).catch(() => {})
if (!sessionId) return
try {
const records = await fetchMessages(sessionId)
messages.value = records
.filter((r) => r.role === 'user' || r.role === 'assistant')
.map((r) => ({ role: r.role as 'user' | 'assistant', content: r.content, tags: r.tags }))
.map((r) => ({
role: r.role as 'user' | 'assistant',
content: r.content,
tags: r.tags,
archived: r.archived_at !== null,
}))
} catch (e) {
pushToast(`メッセージ取得失敗: ${e instanceof Error ? e.message : e}`)
}
@@ -90,12 +101,32 @@ export const useChatStore = defineStore('chat', () => {
const session = await importSession(payload)
await loadSessions()
await selectSession(session.id)
pushToast(`「${session.title}」をインポートしました`)
pushToast(`「${session.title}」をインポートしました`, 'success')
} catch (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 {
if (isStreaming.value || !text.trim()) return
@@ -104,26 +135,35 @@ export const useChatStore = defineStore('chat', () => {
const msgIdx = messages.value.length - 1 // reactive proxy 経由で更新するためインデックスを保持
isStreaming.value = true
const notesStore = useNotesStore()
streamChat(
text,
currentSessionId.value,
selectedModel.value,
[...notesStore.attachedIds],
(chunk) => {
if (chunk.type === 'session_id' && chunk.value) {
currentSessionId.value = chunk.value as string
// 新規セッションはこの時点で作られる。送信時に渡したノートの紐付けが
// サーバー側に保存されているので、その保存先を控え直す
notesStore.loadAttached(chunk.value as string).catch(() => {})
loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`))
} else if (chunk.type === 'chunk' && chunk.value) {
messages.value[msgIdx].content += chunk.value as string
} else if (chunk.type === 'sources' && chunk.value) {
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') {
// AIが共有ノートを書き換えたので、開いているノートパネルに反映する
useNotesStore().load().catch((e) => pushToast(`ノート更新失敗: ${e.message}`))
} else if (chunk.type === 'done') {
settleTools(msgIdx)
messages.value[msgIdx].pending = false
isStreaming.value = false
loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`))
} else if (chunk.type === 'error') {
settleTools(msgIdx)
messages.value[msgIdx].content = (chunk.value as string) ?? 'バックエンドでエラーが発生しました。'
messages.value[msgIdx].pending = false
messages.value[msgIdx].error = true
@@ -131,6 +171,7 @@ export const useChatStore = defineStore('chat', () => {
}
},
(err) => {
settleTools(msgIdx)
// 途中まで届いていた内容は残しつつ、エラーメッセージを付記する
const partial = messages.value[msgIdx].content
messages.value[msgIdx].content = partial ? `${partial}\n\n⚠️ ${err.message}` : err.message
+167
View File
@@ -0,0 +1,167 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import {
fetchKbEmbeddingStatus,
fetchKbFile,
fetchKbFiles,
reindexKbEmbeddings,
testKbEmbedding,
type KbEmbeddingProbe,
type KbEmbeddingStatus,
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 embedding = ref<KbEmbeddingStatus | null>(null)
/** 「接続テスト」の結果。 */
const probe = ref<KbEmbeddingProbe | null>(null)
const testing = ref(false)
/** 埋め込み実行中だけ状態をポーリングするタイマー。 */
let pollTimer: number | null = null
const POLL_INTERVAL_MS = 1500
const filteredFiles = computed(() => {
const q = filter.value.trim().toLowerCase()
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
}
/** 埋め込みの状態を取り直す。実行中ならポーリングを始め、終わっていれば止める。 */
async function loadEmbedding(): Promise<void> {
try {
embedding.value = await fetchKbEmbeddingStatus()
} catch (e) {
error.value = e instanceof Error ? e.message : '埋め込み状態の取得に失敗しました'
stopPolling()
return
}
if (embedding.value.running && pollTimer === null) {
pollTimer = window.setInterval(() => void loadEmbedding(), POLL_INTERVAL_MS)
} else if (!embedding.value.running && pollTimer !== null) {
stopPolling()
// 埋め込みが終わったので、一覧の「埋め込み済み」表示も更新する
await load()
}
}
function stopPolling(): void {
if (pollTimer !== null) {
clearInterval(pollTimer)
pollTimer = null
}
}
/** 埋め込みモデルに実際にリクエストして、応答するか確かめる。 */
async function testEmbedding(): Promise<void> {
testing.value = true
probe.value = null
try {
probe.value = await testKbEmbedding()
} catch (e) {
error.value = e instanceof Error ? e.message : '埋め込みテストに失敗しました'
} finally {
testing.value = false
}
}
/** 全ファイルの再埋め込みをバックグラウンドで開始する。 */
async function reindex(): Promise<void> {
error.value = null
try {
embedding.value = await reindexKbEmbeddings()
if (embedding.value.running && pollTimer === null) {
pollTimer = window.setInterval(() => void loadEmbedding(), POLL_INTERVAL_MS)
}
} catch (e) {
error.value = e instanceof Error ? e.message : '再埋め込みの開始に失敗しました'
}
}
function toggleGroup(directory: string): void {
const next = new Set(collapsed.value)
if (next.has(directory)) next.delete(directory)
else next.add(directory)
collapsed.value = next
}
return {
files,
openFile,
loading,
error,
filter,
collapsed,
embedding,
probe,
testing,
filteredFiles,
groups,
load,
open,
close,
toggleGroup,
loadEmbedding,
stopPolling,
testEmbedding,
reindex,
}
})
+142 -6
View File
@@ -5,6 +5,13 @@ import {
createNotePage,
updateNotePage,
deleteNotePage,
exportNotePage,
exportAllNotePages,
renameNoteSection,
reorderNotePages,
fetchSessionNotes,
updateSessionNotes,
importNotePages,
type NotePage,
} from '../api/client'
@@ -16,6 +23,13 @@ export const useNotesStore = defineStore('notes', () => {
const error = ref<string | null>(null)
/** 未保存の編集があるページID。AIによる更新で上書きしないために使う。 */
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
@@ -44,22 +58,101 @@ export const useNotesStore = defineStore('notes', () => {
activeId.value = id
}
async function addPage(title = '新しいページ'): Promise<void> {
async function addPage(title = '新しいページ', section: string | null = null): Promise<void> {
error.value = null
try {
const page = await createNotePage(title)
pages.value.push(page)
const page = await createNotePage(title, section)
// サーバー側でセクションの並びに合わせて再配置されている可能性があるため一覧を取り直す
await load()
activeId.value = page.id
} catch (e) {
error.value = e instanceof Error ? e.message : 'ページの作成に失敗しました'
}
}
/** ページの所属セクションを変更する。section が空/nullならグループ解除。並び順もサーバー側で調整されるため再取得する。 */
async function setSection(id: number, section: string | null): Promise<void> {
error.value = null
flush()
try {
await updateNotePage(id, { section })
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : 'セクションの変更に失敗しました'
}
}
/** oldName セクションに属する全ページを newName セクションへ一括で付け替える。newName が空ならグループ解除。 */
async function renameSection(oldName: string, newName: string | null): Promise<void> {
error.value = null
flush()
try {
await renameNoteSection(oldName, newName)
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : 'セクション名の変更に失敗しました'
}
}
/**
* タブのドラッグ&ドロップ用。order には全ページIDをドラッグ後の新しい順序で渡す。
* section にドロップ先タブの所属セクションを渡せば、並べ替えと同時にセクション間の移動も反映される。
*/
async function reorder(pageId: number, section: string | null, order: number[]): Promise<void> {
error.value = null
flush()
try {
await reorderNotePages(pageId, section, order)
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '並べ替えに失敗しました'
}
}
/** セッションに紐付いたページを読み込む。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> {
error.value = null
try {
await deleteNotePage(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)
if (activeId.value === id) activeId.value = pages.value[0]?.id ?? null
} catch (e) {
@@ -98,13 +191,46 @@ export const useNotesStore = defineStore('notes', () => {
}
}
/** 保留中の保存を直ちに実行する(タブ切替・パネルを閉じる時など)。 */
function flush(): void {
/**
* 保留中の保存を直ちに実行する(タブ切替・パネルを閉じる時など)。
* await すれば保存の完了まで待てる(save() は自分でエラーを握るので reject はしない)。
*/
function flush(): Promise<void> {
if (saveTimer !== null) {
clearTimeout(saveTimer)
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 {
@@ -113,11 +239,21 @@ export const useNotesStore = defineStore('notes', () => {
activePage,
error,
dirtyIds,
attachedIds,
attachedSessionId,
loadAttached,
toggleAttached,
load,
selectPage,
addPage,
removePage,
edit,
setSection,
renameSection,
reorder,
flush,
importFiles,
downloadPage,
downloadAll,
}
})
+103
View File
@@ -0,0 +1,103 @@
import katex from 'katex'
import type { MarkedExtension, TokenizerAndRendererExtension, Tokens } from 'marked'
/**
* marked 拡張として数式 (KaTeX) を描画する。
*
* 対応する区切り記号:
* - ブロック: `$$ ... $$` / `\[ ... \]`(単独行)
* - インライン: `$ ... $` / `\( ... \)` / 行中の `$$ ... $$`
*
* `$` はドル記号としても書かれるため、インラインの `$ ... $` だけは
* 誤爆を避けるための条件を付けている(下記 INLINE_DOLLAR 参照)。
* 明示的にドル記号を書きたい場合は Markdown のエスケープ `\$` が使える。
*/
// ブロック: 行頭(インデント 3 まで)から始まる $$ ... $$ / \[ ... \]
const BLOCK_DOLLAR = /^ {0,3}\$\$([\s\S]+?)\$\$[ \t]*(?:\n+|$)/
const BLOCK_BRACKET = /^ {0,3}\\\[([\s\S]+?)\\\][ \t]*(?:\n+|$)/
// インライン: \( ... \) と、段落の途中に置かれた $$ ... $$
const INLINE_PAREN = /^\\\(((?:\\[^]|[^\\])+?)\\\)/
const INLINE_DOUBLE = /^\$\$((?:\\[^]|[^\\])+?)\$\$/
// インライン: $ ... $
// - 開き `$` の直後に空白・`$` が来るものは対象外
// - 中身に改行と裸の `$` を含まない
// - 閉じ `$` の直後が数字のものは対象外("$100から$200" のような金額表記の除外)
// - 中身が空白で終わるものは対象外(tokenizer 側で判定)
const INLINE_DOLLAR = /^\$(?![\s$])((?:\\.|[^\\$\n])+?)\$(?!\d)/
function escapeHtml(src: string): string {
return src
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function renderMath(tex: string, displayMode: boolean, raw: string): string {
try {
return katex.renderToString(tex.trim(), {
displayMode,
throwOnError: false, // 構文エラーは例外にせず赤字で表示する
errorColor: '#f87171', // ダーク背景で読める色にする
strict: false, // 日本語混じりなどの警告を出さない
output: 'htmlAndMathml',
})
} catch {
// KaTeX が想定外の失敗をした場合は元の文字列をそのまま見せる
return escapeHtml(raw)
}
}
const blockMath: TokenizerAndRendererExtension = {
name: 'blockMath',
level: 'block',
start(src: string) {
return src.match(/(?:^|\n) {0,3}(?:\$\$|\\\[)/)?.index
},
tokenizer(src: string) {
const match = BLOCK_DOLLAR.exec(src) ?? BLOCK_BRACKET.exec(src)
if (!match) return undefined
return { type: 'blockMath', raw: match[0], text: match[1] }
},
renderer(token: Tokens.Generic) {
return renderMath(token.text as string, true, token.raw as string)
},
}
const inlineMath: TokenizerAndRendererExtension = {
name: 'inlineMath',
level: 'inline',
start(src: string) {
// エスケープされた \$ は数式の開始とみなさない
const match = /(^|[^\\])(\$|\\\()/.exec(src)
return match ? match.index + match[1].length : undefined
},
tokenizer(src: string) {
const paren = INLINE_PAREN.exec(src)
if (paren) return { type: 'inlineMath', raw: paren[0], text: paren[1], display: false }
const double = INLINE_DOUBLE.exec(src)
if (double) return { type: 'inlineMath', raw: double[0], text: double[1], display: true }
const single = INLINE_DOLLAR.exec(src)
if (single && !/[\s\\]$/.test(single[1])) {
return { type: 'inlineMath', raw: single[0], text: single[1], display: false }
}
return undefined
},
renderer(token: Tokens.Generic) {
return renderMath(token.text as string, token.display === true, token.raw as string)
},
}
export const mathExtension: MarkedExtension = {
extensions: [blockMath, inlineMath],
}
/** KaTeX が出力する MathML 用に DOMPurify へ追加で許可するタグ・属性 */
export const mathSanitizeConfig = {
ADD_TAGS: ['semantics', 'annotation'],
ADD_ATTR: ['encoding'],
}