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