diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8b69b93..8e5aaf2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -15,6 +15,11 @@ "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:8000/health --max-time 5)", "Bash(curl -s -N -X POST http://localhost:8000/chat/stream -H \"Content-Type: application/json\" -d '{\"message\":\"latest news about python 3.14 release\",\"model\":\"bedrock/jp.anthropic.claude-sonnet-4-6\"}' --max-time 60)", "Bash(curl -s -N -X POST http://localhost:8000/chat/stream -H \"Content-Type: application/json\" -d '{\"message\":\"latest news about python 3.14 release\",\"model\":\"bedrock/jp.anthropic.claude-sonnet-4-6\"}' --max-time 90)" + ], + "deny": [ + "Read(**/.env)", + "Edit(**/.env)", + "Bash(*.env)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 8779567..d6f580d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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)件。セッションを横断する 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 検索結果より優先する扱い 4. **長期メモリ**: `memories` の要約を全件連結。上限なし 5. **短期メモリ**: セッション内の `messages` 全件(要約で削除された分を除く) **要約**: `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 件から再シード)。 @@ -97,11 +98,14 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte - **ツール実行の流れ**: `llm.resolve_tool_calls()` が本番の応答生成前にツール呼び出しを解決する。`web_search` と共通の仕組みで、ノート系ツールは `db` が渡されたときだけモデルに提示される - **UI への反映**: AI がノートを書き換えた場合、SSE で `notes_updated` を送ってフロント側のノートパネルを再読み込みさせる - **編集の衝突回避**: フロントは未保存の編集があるページ(`dirtyIds`)をサーバーの値で上書きしない +- **チャットへの紐付け**: `session_note_pages`(session_id × page_id)に登録したページは、本文全文がそのセッションのシステムプロンプトに毎回入る。`GET/PUT /chat/sessions/{id}/notes` で読み書きする。新規セッションではフロントがまだ session_id を持てないため、`POST /chat/stream` の `note_page_ids` でも同じ指定を送れ、コンテキスト組み立て前にサーバー側で反映される +- **取り込み**: `POST /api/notes/import`(multipart)が `.md` / `.txt` を 1 ファイル 1 ページ、`.zip` を中身ごと複数ページに変換する(ZIP 第 1 階層のフォルダ名がセクション)。タイトル衝突時は `(2)` を足して別ページを作り、対象外・サイズ超過・壊れた ZIP は黙って飛ばす。1 件も取り込めなければ 400。`python-multipart` が必要 +- **ダウンロード**: `GET /api/notes/{page_id}/export` が 1 ページを Markdown、`GET /api/notes/export` が全ページを ZIP(1 ページ 1 Markdown、セクションはフォルダ、ファイル名の通し番号で `position` 順を保持)で返す。ファイル名はサーバーが決めるのでフロントは `download` 属性を付けず `Content-Disposition`(日本語タイトルのため RFC 5987 を併記)に任せる - **セクションによるグルーピング**: `note_pages.section` にセクション名を持たせ、同じセクションのページは `position` 上で常に連続させる(`core/notes.set_section()` が割り当て時に並べ替える)。タブバーは `position` 順に描画し、隣接して同じ `section` が続く区間を1グループとして表示する。`get_notes_context()` はセクションごとに `## セクション名` の見出しを付けてタイトル一覧を出す。セクション名の一括変更は `POST /api/notes/sections/rename`(`core/notes.rename_section()`) ### SSE ストリーミング -`POST /chat/stream` が SSE を返す。イベント種別: `session_id`(新規セッション ID)/ `chunk`(テキスト断片)/ `sources`(Web 検索の出典)/ `notes_updated`(AI が共有ノートを更新)/ `done`(完了)/ `error`(エラー)。 +`POST /chat/stream` が SSE を返す。イベント種別: `session_id`(新規セッション ID)/ `chunk`(テキスト断片)/ `tool`(ツール実行の開始・完了)/ `sources`(Web 検索の出典)/ `notes_updated`(AI が共有ノートを更新)/ `done`(完了)/ `error`(エラー)。 フロントは `src/api/client.ts` の `streamChat()` で fetch + ReadableStream により受信し、`messages.value[msgIdx]` を reactive proxy 経由で更新する。 ### Markdown レンダリング @@ -145,6 +149,9 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte | `ANTHROPIC_API_KEY` | — | Anthropic API キー | | `OLLAMA_API_BASE` | `http://localhost:11434` | Ollama エンドポイント | | `NOTES_TOOL_CONTENT_LIMIT` | `4000` | `read_note` が 1 回に返す本文の最大文字数 | +| `SESSION_NOTES_CONTENT_LIMIT` | `8000` | セッションに紐付けたノート 1 ページあたり、システムプロンプトに載せる本文の最大文字数 | +| `NOTES_IMPORT_MAX_PAGES` | `100` | ノート取り込みで一度に作れるページ数の上限 | +| `NOTES_IMPORT_MAX_BYTES` | `2000000` | 取り込む 1 ファイル(ZIP 内は 1 エントリ)あたりの最大バイト数 | | `KNOWLEDGE_TOOL_CONTENT_LIMIT` | `4000` | `read_knowledge` が 1 回に返す本文の最大文字数 | | `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) | | `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 | diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index 0c63980..7e1994a 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -12,6 +12,7 @@ from app.core.memory_kb import memory as mem from app.core import llm from app.core.memory_kb import knowledge from app.core.memory_kb import local_knowledge +from app.core import notes from app.core.notes import NOTE_TOOLS from app.core.search.websearch import WEB_SEARCH_TOOL from app.core.config import DEFAULT_MODEL @@ -25,6 +26,13 @@ class ChatRequest(BaseModel): session_id: str | None = None message: str model: str | None = None + # このセッションで本文をプロンプトに載せるノートのページID。 + # 新規セッションではまだ session_id が無く PUT できないため、送信時にまとめて反映する。 + note_page_ids: list[int] | None = None + + +class SessionNotesRequest(BaseModel): + page_ids: list[int] class SessionResponse(BaseModel): @@ -161,6 +169,22 @@ def import_session(req: ImportRequest, db: DBSession = Depends(get_db)): return session +@router.get("/sessions/{session_id}/notes") +def get_session_notes(session_id: str, db: DBSession = Depends(get_db)): + """このセッションで本文をプロンプトに載せるよう指定された共有ノートのページIDを返す。""" + if not db.get(Session, session_id): + raise HTTPException(status_code=404, detail="Session not found") + return {"page_ids": notes.get_attached_page_ids(db, session_id)} + + +@router.put("/sessions/{session_id}/notes") +def put_session_notes(session_id: str, req: SessionNotesRequest, db: DBSession = Depends(get_db)): + """このセッションに紐付けるノートのページIDを丸ごと置き換える。存在しないIDは無視する。""" + if not db.get(Session, session_id): + raise HTTPException(status_code=404, detail="Session not found") + return {"page_ids": notes.set_attached_pages(db, session_id, req.page_ids)} + + @router.delete("/sessions/{session_id}", status_code=204) def delete_session(session_id: str, db: DBSession = Depends(get_db)): session = db.get(Session, session_id) @@ -202,6 +226,11 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)): record_message(req.message) asyncio.create_task(tag_message_by_id(user_msg.id)) + # 紐付けるノートは送信のたびに送られてくる。新規セッションだとフロントは事前に + # PUT できないため、コンテキストを組み立てる前にここで反映しておく。 + if req.note_page_ids is not None: + notes.set_attached_pages(db, session.id, req.note_page_ids) + context = await mem.get_context_messages(db, session.id) async def generate(): @@ -216,9 +245,18 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)): used_model = req.model or session.model full_response = [] try: - resolved_context, searches, notes_changed = await llm.resolve_tool_calls( + # ツールの実行状況は tool イベントとしてそのままフロントへ流し、GUIに進行状況を出す + resolved_context, searches, notes_changed = context, [], False + async for event in llm.stream_tool_calls( context, model=used_model, session_id=session.id, db=db, - ) + ): + if event["type"] == "resolved": + resolved_context = event["messages"] + searches = event["searches"] + notes_changed = event["notes_changed"] + else: + yield sse({"type": "tool", "value": {k: v for k, v in event.items() if k != "type"}}) + if notes_changed: # AIがノートを書き換えたので、フロントのノートパネルを再読み込みさせる yield sse({"type": "notes_updated"}) @@ -228,11 +266,25 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)): for s in searches for r in s["results"] ] yield sse({"type": "sources", "value": sources}) + yield sse({"type": "tool", "value": { + "id": "save_knowledge", + "name": "save_knowledge", + "label": "検索結果を要約して保存", + "detail": " / ".join(s["query"] for s in searches), + "status": "running", + }}) + saved = 0 for s in searches: headline, summary = await knowledge.summarize_results(s["query"], s["results"]) if summary: knowledge.save_knowledge(db, s["query"], summary, s["results"], headline=headline) local_knowledge.save_search_result(s["query"], summary, s["results"]) + saved += 1 + yield sse({"type": "tool", "value": { + "id": "save_knowledge", + "status": "done", + "result": f"{saved}件を蓄積知識に保存" if saved else "保存できる要約なし", + }}) # 履歴にtool_callsが含まれる場合、最終呼び出しにも同じツール定義を渡す必要がある used_tools = [WEB_SEARCH_TOOL, *NOTE_TOOLS] if len(resolved_context) > len(context) else None diff --git a/backend/app/api/notes.py b/backend/app/api/notes.py index 67d4638..b144150 100644 --- a/backend/app/api/notes.py +++ b/backend/app/api/notes.py @@ -1,10 +1,14 @@ -from datetime import datetime +import io +import zipfile +from datetime import datetime, timezone +from urllib.parse import quote -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi.responses import Response from pydantic import BaseModel from sqlalchemy.orm import Session as DBSession -from app.core.notes import next_position, 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.models import NotePage @@ -46,11 +50,73 @@ class ReorderRequest(BaseModel): 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]) def list_pages(db: DBSession = Depends(get_db)): return db.query(NotePage).order_by(NotePage.position, NotePage.id).all() +@router.get("/export") +def export_all_pages(db: DBSession = Depends(get_db)): + """全ページを 1ページ1ファイルの Markdown にして ZIP でダウンロードさせる。 + + 本文はそのまま書き出す(1つのファイルに連結すると、ページ内の見出しの階層が + ページ間の見出しと混ざってしまうため)。タブの並び順を残すためファイル名には + 通し番号を付け、セクションはフォルダにする。 + """ + pages = db.query(NotePage).order_by(NotePage.position, NotePage.id).all() + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for i, page in enumerate(pages, start=1): + entry = f"{i:02d}_{_safe_filename(page.title, f'page_{page.id}')}.md" + if page.section: + entry = f"{_safe_filename(page.section, 'section')}/{entry}" + archive.writestr(entry, page.content) + + filename = f"notes_{datetime.now(timezone.utc).strftime('%Y%m%d')}.zip" + return Response( + content=buffer.getvalue(), + media_type="application/zip", + headers=_attachment_headers(filename), + ) + + +@router.get("/{page_id}/export") +def export_page(page_id: int, db: DBSession = Depends(get_db)): + """1ページを Markdown ファイルとしてダウンロードさせる。本文はそのまま、ファイル名がタイトルになる。""" + page = db.get(NotePage, page_id) + if not page: + raise HTTPException(status_code=404, detail="Note page not found") + filename = f"{_safe_filename(page.title, f'page_{page.id}')}.md" + return Response( + content=page.content, + media_type="text/markdown; charset=utf-8", + headers=_attachment_headers(filename), + ) + + @router.post("", response_model=NotePageResponse, status_code=201) def create_page(req: NotePageCreate, db: DBSession = Depends(get_db)): title = req.title.strip() or "新しいページ" @@ -63,6 +129,24 @@ def create_page(req: NotePageCreate, db: DBSession = Depends(get_db)): return page +@router.post("/import", response_model=list[NotePageResponse], status_code=201) +async def import_pages(files: list[UploadFile] = File(...), db: DBSession = Depends(get_db)): + """アップロードされたファイルをページとして取り込む。 + + .md / .txt はそのまま1ページ、.zip は中のテキストファイルを1つずつページにする + (ZIP内のフォルダ名がセクションになるので、エクスポートしたZIPをそのまま戻せる)。 + 対象外の拡張子や大きすぎるファイルは黙って飛ばすので、1つも取り込めなければ 400。 + """ + payload = [(f.filename or "無題", await f.read()) for f in files] + created = import_files(db, payload) + if not created: + raise HTTPException( + status_code=400, + detail="取り込めるファイルがありませんでした(.md / .txt / .zip のみ)", + ) + return created + + @router.post("/sections/rename") def rename_section_endpoint(req: SectionRenameRequest, db: DBSession = Depends(get_db)): """old_name セクションに属する全ページを new_name セクションへ一括で付け替える。new_name が空ならグループ解除。""" diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 5e9e903..ab44e91 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -41,6 +41,13 @@ LOCAL_KB_MIN_SCORE: float = float(os.getenv("LOCAL_KB_MIN_SCORE", "0.75")) # read_note ツールが1回に返す本文の最大文字数 NOTES_TOOL_CONTENT_LIMIT: int = int(os.getenv("NOTES_TOOL_CONTENT_LIMIT", "4000")) +# アップロードで一度に取り込めるページ数と、1ファイル(ZIP内は1エントリ)あたりの最大バイト数 +NOTES_IMPORT_MAX_PAGES: int = int(os.getenv("NOTES_IMPORT_MAX_PAGES", "100")) +NOTES_IMPORT_MAX_BYTES: int = int(os.getenv("NOTES_IMPORT_MAX_BYTES", "2000000")) + +# セッションに紐付けたページ1枚あたり、システムプロンプトに載せる本文の最大文字数 +SESSION_NOTES_CONTENT_LIMIT: int = int(os.getenv("SESSION_NOTES_CONTENT_LIMIT", "8000")) + # read_knowledge ツールが1回に返す蓄積知識の本文の最大文字数 KNOWLEDGE_TOOL_CONTENT_LIMIT: int = int(os.getenv("KNOWLEDGE_TOOL_CONTENT_LIMIT", "4000")) diff --git a/backend/app/core/llm.py b/backend/app/core/llm.py index 2818a87..69fed5b 100644 --- a/backend/app/core/llm.py +++ b/backend/app/core/llm.py @@ -107,19 +107,45 @@ async def embed(texts: list[str], model: str) -> list[list[float]]: return [item["embedding"] for item in response.data] -async def resolve_tool_calls( +def _tool_display(name: str, args: dict) -> tuple[str, str]: + """ツール呼び出しをGUI表示用の (ラベル, 詳細) に変換する。""" + if name == "web_search": + return "Web検索", args.get("query", "") + if name == "read_note": + return "ノートを読む", args.get("title", "") + if name == "write_note": + label = "ノートを上書き" if args.get("mode") == "replace" else "ノートに追記" + return label, args.get("title", "") + if name == "read_knowledge": + return "蓄積知識を読む", args.get("query", "") + return name, "" + + +def _first_line(text: str, limit: int = 80) -> str: + """ツール結果の要点だけをGUIに出すため、1行目を切り出して長さを詰める。""" + line = text.strip().splitlines()[0] if text.strip() else "" + return line[:limit] + "…" if len(line) > limit else line + + +async def stream_tool_calls( messages: list[dict], model: str | None = None, session_id: str | None = None, db: "DBSession | None" = None, max_rounds: int = 2, -) -> tuple[list[dict], list[dict], bool]: - """必要に応じてLLMにツールを呼ばせ、その結果を会話に組み込んだ上でメッセージ列を返す。 +) -> AsyncIterator[dict]: + """必要に応じてLLMにツールを呼ばせ、その進行状況をイベントとして逐次 yield する。 扱うツールは web_search、共有ノート(read_note / write_note)、蓄積知識(read_knowledge)。 DBを見るものは db が渡されたときだけ提示する。モデルがtool callingに対応していない場合は 何もせず元の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 しているため、 # モジュールレベルで読むと循環 import になる(_save_log と同じ理由で関数内 import する)。 @@ -142,7 +168,8 @@ async def resolve_tool_calls( ) except Exception: logger.info("Model %s does not support tool calling; skipping tools", used_model) - return messages, searches, notes_changed + yield {"type": "resolved", "messages": messages, "searches": searches, "notes_changed": notes_changed} + return choice_message = response.choices[0].message tool_calls = getattr(choice_message, "tool_calls", None) @@ -169,19 +196,35 @@ async def resolve_tool_calls( args = {} name = tc.function.name + label, detail = _tool_display(name, args) + yield { + "type": "tool", + "id": tc.id, + "name": name, + "label": label, + "detail": detail, + "status": "running", + } + if name in ("read_note", "write_note") and db is not None: tool_text, changed = execute_note_tool(db, name, args) notes_changed = notes_changed or changed + result = _first_line(tool_text) elif name == "read_knowledge" and db is not None: tool_text = execute_knowledge_tool(db, name, args) + result = _first_line(tool_text) else: query = args.get("query", "") results = await search_web(query) if results: tool_text = "\n\n".join(f"[{r['title']}]({r['url']})\n{r['content']}" for r in results) searches.append({"query": query, "results": results}) + result = f"{len(results)}件の結果" else: tool_text = "検索結果が見つかりませんでした。" + result = "結果なし" + + yield {"type": "tool", "id": tc.id, "status": "done", "result": result} working_messages.append({ "role": "tool", @@ -189,4 +232,27 @@ async def resolve_tool_calls( "content": tool_text, }) - return working_messages, searches, notes_changed + yield { + "type": "resolved", + "messages": working_messages, + "searches": searches, + "notes_changed": notes_changed, + } + + +async def resolve_tool_calls( + messages: list[dict], + model: str | None = None, + session_id: str | None = None, + db: "DBSession | None" = None, + max_rounds: int = 2, +) -> tuple[list[dict], list[dict], bool]: + """stream_tool_calls の進行状況イベントを捨て、最終結果だけを返すラッパー。 + + 戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト, ノートを変更したか) + """ + resolved = (messages, [], False) + async for event in stream_tool_calls(messages, model, session_id, db, max_rounds): + if event["type"] == "resolved": + resolved = (event["messages"], event["searches"], event["notes_changed"]) + return resolved diff --git a/backend/app/core/memory_kb/memory.py b/backend/app/core/memory_kb/memory.py index 25c8b25..9473cbe 100644 --- a/backend/app/core/memory_kb/memory.py +++ b/backend/app/core/memory_kb/memory.py @@ -28,10 +28,15 @@ async def get_context_messages(db: DBSession, session_id: str) -> list[dict]: context.append({"role": "system", "content": knowledge_context}) # 共有ノートは「タイトル一覧」だけを常時見せ、本文は read_note ツールで読ませる - notes_context = notes.get_notes_context(db) + notes_context = notes.get_notes_context(db, session_id) if notes_context: context.append({"role": "system", "content": notes_context}) + # ただしユーザーがこのセッションに紐付けたページは、本文もそのまま載せる + session_notes_context = notes.get_session_notes_context(db, session_id) + if session_notes_context: + context.append({"role": "system", "content": session_notes_context}) + if messages: latest_user_message = next((m.content for m in reversed(messages) if m.role == "user"), "") local_kb_context = await local_knowledge.get_local_kb_context(db, latest_user_message) diff --git a/backend/app/core/notes.py b/backend/app/core/notes.py index 2604801..b05fb54 100644 --- a/backend/app/core/notes.py +++ b/backend/app/core/notes.py @@ -2,14 +2,25 @@ システムプロンプトにはページの「タイトル一覧」だけを注入し、本文はモデルが read_note ツールで必要なときだけ読み出す。書き込みは write_note ツールで行う。 + +例外として、ユーザーがセッションに紐付けたページ(SessionNotePage)は本文も +毎回そのままシステムプロンプトに載る。ツールの往復を待たずに確実に参照させたい +資料のための仕組みで、指定はセッション単位なので会話ごとに組み合わせを変えられる。 """ +import io import logging import re +import zipfile from sqlalchemy.orm import Session as DBSession -from app.core.config import NOTES_TOOL_CONTENT_LIMIT -from app.db.models import NotePage +from app.core.config import ( + NOTES_IMPORT_MAX_BYTES, + NOTES_IMPORT_MAX_PAGES, + NOTES_TOOL_CONTENT_LIMIT, + SESSION_NOTES_CONTENT_LIMIT, +) +from app.db.models import NotePage, SessionNotePage logger = logging.getLogger(__name__) @@ -138,9 +149,169 @@ def rename_section(db: DBSession, old_name: str, new_name: str | None) -> int: 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() + attached = set(get_attached_page_ids(db, session_id)) if session_id else set() lines = [ "【共有ノート】", @@ -155,6 +326,9 @@ def get_notes_context(db: DBSession) -> str | None: current_section = page.section if current_section: lines.append(f"## {current_section}") + if page.id in attached: + lines.append(f"- {page.title} — (本文は「この会話で参照するノート」に全文あり)") + continue preview = " ".join(page.content.split())[:40] suffix = f" — {preview}…" if preview else " — (空)" lines.append(f"- {page.title}{suffix}") diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 451b7b1..f98c64e 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -1,5 +1,5 @@ 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 app.db.database import Base 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") 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): @@ -119,7 +120,8 @@ class NotePage(Base): """ユーザーとAIが共同で編集する共有ノートの1ページ。UIではタブとして表示する。 ページのタイトル一覧は毎回システムプロンプトに注入され、本文はモデルが - read_note ツールで読み出す(app.core.notes を参照)。 + read_note ツールで読み出す(app.core.notes を参照)。ただし SessionNotePage で + セッションに紐付けられたページは、本文もそのままシステムプロンプトに載る。 """ __tablename__ = "note_pages" @@ -131,6 +133,28 @@ class NotePage(Base): created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc)) updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + session_links: Mapped[list["SessionNotePage"]] = relationship(back_populates="page", cascade="all, delete-orphan") + + +class SessionNotePage(Base): + """「このセッションではこのページの本文をプロンプトに載せる」という指定。 + + 通常、共有ノートはタイトル一覧だけがシステムプロンプトに入り、本文はモデルが + read_note ツールで取りに行く。ここに登録されたページはそれを待たず、本文が毎回 + そのままシステムプロンプトに入る(app.core.notes.get_session_notes_context を参照)。 + セッション単位なので、会話ごとに違う組み合わせを選べる。 + """ + __tablename__ = "session_note_pages" + __table_args__ = (UniqueConstraint("session_id", "page_id", name="uq_session_note_page"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + session_id: Mapped[str] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE")) + page_id: Mapped[int] = mapped_column(ForeignKey("note_pages.id", ondelete="CASCADE")) + created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc)) + + session: Mapped["Session"] = relationship(back_populates="note_links") + page: Mapped["NotePage"] = relationship(back_populates="session_links") + class AppSetting(Base): """GUIの設定画面から編集する接続情報(APIキー等)。app.core.settings_store が読み書きする。""" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7a77692..062d565 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "discord-py>=2.7.1", "janome>=0.5.0", "trafilatura>=2.1.0", + "python-multipart>=0.0.9", ] [project.optional-dependencies] diff --git a/backend/uv.lock b/backend/uv.lock index 8b49e32..916717b 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1300,6 +1300,7 @@ dependencies = [ { name = "nvidia-ml-py" }, { name = "psutil" }, { name = "python-dotenv" }, + { name = "python-multipart" }, { name = "sqlalchemy" }, { name = "sse-starlette" }, { name = "trafilatura" }, @@ -1336,6 +1337,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "python-multipart", specifier = ">=0.0.9" }, { name = "sqlalchemy", specifier = ">=2.0.0" }, { name = "sse-starlette", specifier = ">=2.1.0" }, { name = "trafilatura", specifier = ">=2.1.0" }, @@ -1642,6 +1644,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "pytz" version = "2026.2" diff --git a/docs/api.md b/docs/api.md index ffcb827..d4f44b6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -15,15 +15,22 @@ SSE でストリーミング応答を返す。 { "message": "こんにちは", "session_id": "uuid(省略時は新規セッション作成)", - "model": "gpt-5.4(省略時は DEFAULT_MODEL)" + "model": "gpt-5.4(省略時は DEFAULT_MODEL)", + "note_page_ids": [1, 2] } ``` +`note_page_ids` は「このセッションで本文をシステムプロンプトに載せる共有ノートのページ ID」。 +渡すとコンテキスト組み立ての前にサーバー側の指定を丸ごと置き換える(`PUT /chat/sessions/{id}/notes` と同じ内容)。 +新規セッションではフロントがまだ session_id を持てないため、初回はここでしか指定できない。省略すると現在の指定を変更しない。 + **SSE イベント** | type | value | タイミング | |---|---|---| | `session_id` | セッション UUID | 新規セッション作成直後 | | `chunk` | テキスト断片 | LLM トークン生成ごと | +| `tool` | `{id, name, label, detail, status, result}` | ツール実行の開始時 (`status: running`) と完了時 (`status: done`)。同じ `id` で 2 回届くのでフロントはマージして表示する | +| `sources` | `[{title, url}]` | Web 検索した時のみ(出典一覧) | | `notes_updated` | — | AI が共有ノートを書き換えた直後 | | `done` | — | 正常完了 | | `error` | エラーメッセージ | 例外発生時 | @@ -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} セッションと紐づくメッセージ・メモリをすべて削除。`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 diff --git a/docs/architecture.md b/docs/architecture.md index e50c228..243c8ad 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -66,6 +66,7 @@ backend/ | `knowledge_sources` | id, entry_id, title, url | 上記知識の出典 URL | | `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` でタブをグループ化) | +| `session_note_pages` | id, session_id, page_id, created_at | 「このセッションではこのページの本文をプロンプトに載せる」指定(session_id × page_id は一意) | | `app_settings` | key, value, updated_at | GUI 設定画面から編集した接続情報 | | `model_presets` | id, label, value, created_at | モデル選択プルダウンの選択肢 | | `api_logs` | id, session_id, model, request_json, response_text, error, latency_ms, created_at | LLM API 呼び出しログ | @@ -79,12 +80,19 @@ LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messag |---|---|---|---|---| | 1 | 蓄積知識 | 過去の Web 検索で得た `knowledge_entries` の**見出し一覧のみ**(本文・出典は含めない) | `knowledge.get_knowledge_context()` | 最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件 | | 2 | 共有ノート | ページの**タイトル一覧+本文先頭 40 文字**のみ | `notes.get_notes_context()` | 全ページ(本文は入れない) | +| 2.5 | 参照指定ノート | ユーザーがこのセッションに紐付けたページの**本文全文** | `notes.get_session_notes_context()` | 紐付いたページのみ。1 ページ `SESSION_NOTES_CONTENT_LIMIT`(既定 8000)文字で打ち切り | | 3 | ローカル KB | `knowledge_base/**.md` から選ばれた**ファイル全文** | `local_knowledge.get_local_kb_context()` | 直近ユーザー発言との cos 類似度上位 `LOCAL_KB_TOP_K`(既定 3)件、かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上 | | 4 | 長期メモリ | `memories` の要約を全件連結 | `memory.get_context_messages()` | 上限なし | | 5 | 短期メモリ | セッション内の `messages` 全件(要約済みで削除された分を除く) | 同上 | 上限なし(要約で間接的に制御) | 層 3 のプロンプトは「Web 検索結果より優先度が高い」「これで答えられるなら検索するな」と明示しており、層 1 より優先される。 +層 2.5 は「重い本文はツールで取りに行かせる」という原則の唯一の例外で、ユーザーが明示的に選んだページだけが対象。 +`session_note_pages` に行がある間だけ効き、`GET/PUT /chat/sessions/{id}/notes` で読み書きする。 +新規セッションではフロントがまだ session_id を持てないため、`POST /chat/stream` の `note_page_ids` +でも同じ指定を送れるようにしてあり、コンテキストを組み立てる前にサーバー側で反映される。 +層 2 の一覧では紐付け済みのページに印が付き、同じ本文を `read_note` で読み直させないようにしている。 + #### 要約(長期メモリ化) `maybe_summarize()` がアシスタント応答の保存直後に走る。 @@ -146,6 +154,7 @@ LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messag ``` data: {"type":"session_id","value":""} # 新規セッション時のみ +data: {"type":"tool","value":{...}} # ツール実行ごとに開始・完了の2回 data: {"type":"notes_updated"} # AIが共有ノートを更新した時のみ data: {"type":"sources","value":[{...}]} # Web検索した時のみ(出典の title/url) data: {"type":"chunk","value":"テキスト断片"} # 0回以上 @@ -153,6 +162,13 @@ data: {"type":"done"} # 正常完了 data: {"type":"error","value":"エラー内容"} # 例外発生時 ``` +`tool` イベントは `llm.stream_tool_calls()`(`resolve_tool_calls()` の逐次版)が yield する進行状況を +そのまま中継したもので、`{id, name, label, detail, status}` で始まり、同じ `id` の +`{id, status:"done", result}` で終わる。検索結果の要約・蓄積知識への保存も +`id: "save_knowledge"` の同形式のイベントとして送る。フロント(`ToolActivityList.vue`)は +`id` をキーにマージし、アシスタントバブルの上に実行中スピナーつきで一覧表示する。 +表示専用なので DB には保存せず、履歴を読み直すと消える。 + バッファリング防止のため `Cache-Control: no-cache` と `X-Accel-Buffering: no` を付与する。 --- diff --git a/docs/features.md b/docs/features.md index bd7030d..3ea568c 100644 --- a/docs/features.md +++ b/docs/features.md @@ -46,11 +46,14 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は ## 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` で描画する。ストリーミング途中で閉じていないコードフェンスは 自動で補完してから描画するので、生成中でもレイアウトが崩れない。 - セッションは左サイドバーの一覧から切り替え・削除できる。タイトルは最初の発言の先頭 40 文字。 - Web 検索が走った応答には出典(`sources`)が、各メッセージには自動生成されたタグが添えて表示される。 +- Web 検索・ノートの読み書き・蓄積知識の読み出しといったツール実行は、バブルの上に + 「🔍 Web検索 / クエリ / ✓ 5件の結果」の形でリアルタイムに表示される(`ToolActivityList.vue`)。 + 実行中はスピナーが回り、応答が途中で切れたツールは「⚠ 中断されました」になる。表示専用で永続化はしない。 - 失敗時は赤いバブルとトースト通知の両方で知らせる。 ## 2. セッションの書き出し / 取り込み @@ -68,10 +71,13 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は |---|---|---| | 1 蓄積知識 | 過去の Web 検索結果の**見出し一覧のみ**(本文は `read_knowledge` で読む) | 最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件 | | 2 共有ノート | ページのタイトル一覧+本文先頭 40 文字 | 全ページ(本文は入れない) | +| 2.5 参照指定ノート | このチャットに紐付けたページの**本文全文** | 紐付いたページのみ・1 ページ `SESSION_NOTES_CONTENT_LIMIT`(既定 8000)文字 | | 3 ローカル KB | 選ばれた Markdown の**全文** | 類似度上位 `LOCAL_KB_TOP_K`(既定 3)件かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上 | | 4 長期メモリ | 要約を全件連結 | 上限なし | | 5 短期メモリ | セッション内の全メッセージ | 上限なし(要約で間接的に制御) | +層 2.5 だけは例外で、ユーザーが 📌 で選んだページの本文をツールを介さず直接載せる。 + 重い情報(ノート本文・蓄積知識の本文・Web 検索)は目次だけを渡し、本体はツール(`read_note` / `write_note` / `read_knowledge` / `web_search`)で 取りに行かせる遅延読み込み方式。`llm.resolve_tool_calls()` が本番生成の前に最大 2 ラウンドでこれを解決する。 @@ -106,6 +112,15 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は - AI 側はタイトル一覧だけを常に見ており、`read_note` で本文を読み、`write_note` で追記/全置換/新規作成する。 AI が書き換えると SSE の `notes_updated` が飛び、開いているノートパネルが自動で最新化される。 - 自分が編集中(未保存)のページは AI の更新で上書きされない。 +- **取り込み**: `.md` / `.txt` / `.zip` を本文エリアにドロップするか ⬆ ボタンで選ぶとページになる。 + ZIP はフォルダ名がセクションになるので、⬇ で出した ZIP をそのまま戻せば構成ごと復元される。 + タイトルが衝突したら `(2)` を足して別ページにするので、既存のページは壊れない。 +- **チャットへの紐付け**: タブの 📌 を押すと、そのページの本文が**今開いているチャット**のシステム + プロンプトに毎回そのまま載る。AI が `read_note` を呼ぶのを待たずに確実に参照させたいときに使う。 + 指定はチャット単位で保存されるので、会話ごとに違う組み合わせを選べる。 +- **ダウンロード**: パネル下部の ⬇ ボタンで、開いているページを `.md` として、または全ページを + ZIP(1 ページ 1 Markdown、セクションはフォルダ、ファイル名の通し番号でタブ順を保持)として + 手元に保存できる。未保存の編集は書き出す前に保存される。 ## 8-9. 自動タグ付けとワードプール diff --git a/docs/notes-guide.md b/docs/notes-guide.md index 9ce22e3..51146d2 100644 --- a/docs/notes-guide.md +++ b/docs/notes-guide.md @@ -18,10 +18,73 @@ API 仕様は `docs/api.md` の「共有ノート」節を参照。 | タイトルを変更 | タブを**ダブルクリック**すると入力欄になるので書き換えて Enter(または欄外をクリック) | | ページを削除 | タブ内の **×** をクリック(確認ダイアログあり) | | 本文を編集 | ページを開いた状態で下の編集エリアに直接入力 | +| ファイルを取り込む | 本文エリアにファイルをドロップ、またはパネル下部の **⬆ 取り込み** から選択 | +| チャットに紐付ける | タブ内の **📌** をクリック(もう一度押すと解除) | +| ページを1つ保存 | パネル下部の **⬇ このページ (.md)** をクリック | +| 全ページを保存 | パネル下部の **⬇ すべて (.zip)** をクリック | 本文は入力を止めてから少し待つと自動保存される。保存待ちの間はタブに **●**(未保存マーク)が付く。 ページが1つも無い場合は「+ 最初のページを作成」から始められる。 +## ファイルを取り込む + +手元のファイルをページとして取り込める。本文エリアに**ドラッグ&ドロップ**するか、 +パネル下部の **⬆ 取り込み** からファイルを選ぶ(複数まとめて可)。 + +| ファイル | どうなるか | +|---|---| +| `.md` / `.txt` | 1 ファイルが 1 ページになる。ファイル名がタイトル | +| `.zip` | 中のテキストファイルが 1 つずつページになる。フォルダ名がセクションになる | + +**⬇ すべて (.zip) で出した ZIP をそのまま戻せる**ので、バックアップや別環境への引っ越しに使える。 +ファイル名の `01_` のような通し番号は取り込み時に落とされる。 + +既存のページと同じタイトルだった場合は上書きせず、`設計メモ (2)` のように連番を足して別ページを作る。 +要らない方はタブの × で消せばよい。 + +取り込めない拡張子(PDF や画像など)や大きすぎるファイル(1 ファイル 2MB 超)は飛ばされる。 +1 つも取り込めなかったときだけエラーが表示される。 + +## チャットに紐付けて必ず読ませる + +普段、AIに渡っているのは**ページのタイトル一覧だけ**で、本文はAIが必要だと判断したときに +`read_note` で読みに行く。つまり「読みに行ってくれないと参照されない」。 + +タブの **📌** を押すと、そのページの本文が**今開いているチャット**のプロンプトに毎回そのまま載る。 +AIの判断を待たずに確実に参照させたい資料(仕様書、設定、前提条件など)に使う。 + +📌 が付いたタブは上端に黄色い線が入る。もう一度押すと解除。 + +**チャットごとの設定**であることに注意。 + +| チャット | 📌 を付けたページ | +|---|---| +| 「新機能の相談」 | 設計メモ / APIメモ | +| 「夕飯どうする」 | 買い物リスト | + +チャットを切り替えると 📌 の状態もそのチャットのものに切り替わる。新規チャットでは +📌 を付けてから最初のメッセージを送れば、その1通目からちゃんと反映される。 + +長いページは 1 ページ 8000 文字(`SESSION_NOTES_CONTENT_LIMIT`)で打ち切られ、 +残りが必要ならAIが `read_note` で読み足せる。あれもこれも 📌 すると毎回のリクエストが +その分重くなるので、本当に読ませたいページだけに絞るのがよい。 + +## 手元にダウンロードする + +パネル下部の ⬇ ボタンでノートをファイルとして保存できる。未保存の編集があっても、 +保存してから書き出すので取りこぼしはない。 + +| ボタン | 出てくるもの | +|---|---| +| **⬇ このページ (.md)** | 開いているページの本文そのままの Markdown ファイル。ファイル名はページのタイトル | +| **⬇ すべて (.zip)** | 全ページをまとめた ZIP。1 ページ 1 Markdown で、セクションはフォルダになる | + +ZIP の中のファイル名には `01_`, `02_` … と通し番号が付く。タブの並び順を残すためで、 +展開したときも画面と同じ順番で並ぶ。ファイル名に使えない文字(`/` や `:` など)は `_` に置き換わる。 + +複数ページを 1 つのファイルに連結せず ZIP にしているのは、ページ内の見出し(`#`)が +ページ間の見出しと混ざって階層が壊れるのを避けるため。 + ## セクションでまとめる 複数のページを1つのグループとしてタブ上にまとめられる。 @@ -51,7 +114,7 @@ API 仕様は `docs/api.md` の「共有ノート」節を参照。 ## AIとの連携 - 全ページの**タイトル一覧**は、会話のたびに自動でAIに渡される(セクションがあれば見出し付きで)。 - ただし本文はここには含まれない。 + ただし本文はここには含まれない(📌 を付けたページを除く)。 - AIは `read_note` ツールでページ本文を読み、`write_note` ツールで追記・置き換え・新規ページ作成ができる。 「ノートに書いておいて」「〇〇について記録して」のように頼むと、AIが該当ページを判断して書き込む。 - AIがノートを書き換えると、開いているノートパネルは自動的に最新の内容へ更新される。 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a1bcade..4d53f5b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -21,9 +21,23 @@ export interface Source { url: string } +/** ツール実行1件の進行状況。開始時に status='running' で届き、完了時に同じ id で status='done' が届く。 */ +export interface ToolActivity { + id: string + /** バックエンドのツール名 (web_search / read_note / write_note / read_knowledge / save_knowledge)。 */ + name?: string + /** 「Web検索」などの表示用ラベル。 */ + label?: string + /** 検索クエリやページタイトルなど、何に対して実行したか。 */ + detail?: string + status: 'running' | 'done' | 'error' + /** 完了時の短い結果(「5件の結果」など)。 */ + result?: string +} + export interface SSEChunk { - type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources' | 'notes_updated' - value?: string | Source[] + type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources' | 'notes_updated' | 'tool' + value?: string | Source[] | ToolActivity } const BASE = '' @@ -61,6 +75,24 @@ export async function fetchMessages(sessionId: string): Promise return parseJson(res, 'メッセージ取得') } +/** このセッションで本文をプロンプトに載せるよう指定されたノートのページID。 */ +export async function fetchSessionNotes(sessionId: string): Promise { + 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 { + 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 { await fetch(`${BASE}/chat/sessions/${sessionId}`, { method: 'DELETE' }) } @@ -211,6 +243,8 @@ export function streamChat( message: string, sessionId: string | null, model: string, + /** 本文をプロンプトに載せるノートのページID。新規セッションではここでしか指定できない。 */ + notePageIds: number[], onChunk: (chunk: SSEChunk) => void, onError: (err: Error) => void, ): () => void { @@ -219,7 +253,7 @@ export function streamChat( fetch(`${BASE}/chat/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message, session_id: sessionId, model }), + body: JSON.stringify({ message, session_id: sessionId, model, note_page_ids: notePageIds }), signal: controller.signal, }) .then(async (res) => { @@ -300,6 +334,40 @@ export async function deleteNotePage(pageId: number): Promise { await fetch(`${BASE}/api/notes/${pageId}`, { method: 'DELETE' }) } +/** + * ファイルをページとして取り込む。.md / .txt は1ファイル1ページ、.zip は中身を展開して複数ページ。 + * 取り込めるファイルが1つも無ければサーバーが 400 を返す。 + */ +export async function importNotePages(files: File[]): Promise { + 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(res, 'ノート取り込み') +} + +/** + * ダウンロードのためだけにアンカーをクリックする。 + * ファイル名はサーバーの Content-Disposition に任せたいので download 属性は付けない + * (ノートのタイトルには日本語やスラッシュが入りうるため、名前付けはサーバー側に一本化している)。 + */ +function triggerDownload(url: string): void { + const a = document.createElement('a') + a.href = url + document.body.appendChild(a) + a.click() + a.remove() +} + +/** 1ページを Markdown ファイルとしてダウンロードする。 */ +export function exportNotePage(pageId: number): void { + triggerDownload(`${BASE}/api/notes/${pageId}/export`) +} + +/** 全ページを ZIP(1ページ1 Markdown、セクションはフォルダ)としてダウンロードする。 */ +export function exportAllNotePages(): void { + triggerDownload(`${BASE}/api/notes/export`) +} + /** section = old のページ全体を section = new へ一括で付け替える。new が空/nullならグループ解除。 */ export async function renameNoteSection(oldName: string, newName: string | null): Promise<{ renamed: number }> { const res = await fetch(`${BASE}/api/notes/sections/rename`, { diff --git a/frontend/src/components/ChatWindow.vue b/frontend/src/components/ChatWindow.vue index b71ca52..bed187c 100644 --- a/frontend/src/components/ChatWindow.vue +++ b/frontend/src/components/ChatWindow.vue @@ -2,6 +2,7 @@ import { computed, ref, watch, nextTick } from 'vue' import { useChatStore } from '../stores/chat' import MarkdownContent from './MarkdownContent.vue' +import ToolActivityList from './ToolActivityList.vue' import type { Source } from '../api/client' const store = useChatStore() @@ -43,6 +44,7 @@ watch( :class="['message-wrap', msg.role, { archived: msg.archived }]" :title="msg.archived ? '要約済み: 内容は長期メモリに引き継がれ、この発言自体はモデルに渡りません' : undefined" > +
diff --git a/frontend/src/components/NotePanel.vue b/frontend/src/components/NotePanel.vue index 35ff910..5c65785 100644 --- a/frontend/src/components/NotePanel.vue +++ b/frontend/src/components/NotePanel.vue @@ -1,6 +1,7 @@ + + + + diff --git a/frontend/src/stores/chat.ts b/frontend/src/stores/chat.ts index 8e73a6c..c34998d 100644 --- a/frontend/src/stores/chat.ts +++ b/frontend/src/stores/chat.ts @@ -1,7 +1,7 @@ import { defineStore } from 'pinia' import { useNotesStore } from './notes' import { ref, computed } from 'vue' -import { fetchSessions, fetchMessages, deleteSession, exportSession, importSession, streamChat, type SessionInfo, type Source, type ImportPayload } from '../api/client' +import { fetchSessions, fetchMessages, deleteSession, exportSession, importSession, streamChat, type SessionInfo, type Source, type ToolActivity, type ImportPayload } from '../api/client' export interface Message { role: 'user' | 'assistant' @@ -11,11 +11,14 @@ export interface Message { archived?: boolean tags?: string[] sources?: Source[] + /** この応答を作る際に実行したツール(Web検索・ノート読み書きなど)。表示専用で永続化はしない。 */ + tools?: ToolActivity[] } export interface Toast { id: number message: string + kind: 'error' | 'success' } let toastSeq = 0 @@ -32,9 +35,9 @@ export const useChatStore = defineStore('chat', () => { sessions.value.find((s) => s.id === currentSessionId.value) ?? null, ) - function pushToast(message: string, durationMs = 5000) { + function pushToast(message: string, kind: Toast['kind'] = 'error', durationMs = 5000) { const id = ++toastSeq - toasts.value.push({ id, message }) + toasts.value.push({ id, message, kind }) setTimeout(() => { toasts.value = toasts.value.filter((t) => t.id !== id) }, durationMs) @@ -51,6 +54,8 @@ export const useChatStore = defineStore('chat', () => { async function selectSession(sessionId: string | null) { currentSessionId.value = sessionId messages.value = [] + // プロンプトに載せるノートはセッションごとの設定なので、切り替えのたびに読み直す + useNotesStore().loadAttached(sessionId).catch(() => {}) if (!sessionId) return try { const records = await fetchMessages(sessionId) @@ -96,12 +101,32 @@ export const useChatStore = defineStore('chat', () => { const session = await importSession(payload) await loadSessions() await selectSession(session.id) - pushToast(`「${session.title}」をインポートしました`) + pushToast(`「${session.title}」をインポートしました`, 'success') } catch (e) { pushToast(`インポート失敗: ${e instanceof Error ? e.message : e}`) } } + /** tool イベントを同じ id の行にマージする(開始時に追加し、完了時に上書きする)。 */ + function mergeTool(msgIdx: number, activity: ToolActivity) { + const msg = messages.value[msgIdx] + if (!msg) return + const tools = msg.tools ?? (msg.tools = []) + const existing = tools.find((t) => t.id === activity.id) + if (existing) Object.assign(existing, activity) + else tools.push({ ...activity }) + } + + /** ストリームが終わった時点で running のまま残っているツールを中断扱いにする。 */ + function settleTools(msgIdx: number) { + for (const tool of messages.value[msgIdx]?.tools ?? []) { + if (tool.status === 'running') { + tool.status = 'error' + tool.result = '中断されました' + } + } + } + function sendMessage(text: string): void { if (isStreaming.value || !text.trim()) return @@ -110,26 +135,35 @@ export const useChatStore = defineStore('chat', () => { const msgIdx = messages.value.length - 1 // reactive proxy 経由で更新するためインデックスを保持 isStreaming.value = true + const notesStore = useNotesStore() streamChat( text, currentSessionId.value, selectedModel.value, + [...notesStore.attachedIds], (chunk) => { if (chunk.type === 'session_id' && chunk.value) { currentSessionId.value = chunk.value as string + // 新規セッションはこの時点で作られる。送信時に渡したノートの紐付けが + // サーバー側に保存されているので、その保存先を控え直す + notesStore.loadAttached(chunk.value as string).catch(() => {}) loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`)) } else if (chunk.type === 'chunk' && chunk.value) { messages.value[msgIdx].content += chunk.value as string } else if (chunk.type === 'sources' && chunk.value) { messages.value[msgIdx].sources = chunk.value as Source[] + } else if (chunk.type === 'tool' && chunk.value) { + mergeTool(msgIdx, chunk.value as ToolActivity) } else if (chunk.type === 'notes_updated') { // AIが共有ノートを書き換えたので、開いているノートパネルに反映する useNotesStore().load().catch((e) => pushToast(`ノート更新失敗: ${e.message}`)) } else if (chunk.type === 'done') { + settleTools(msgIdx) messages.value[msgIdx].pending = false isStreaming.value = false loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`)) } else if (chunk.type === 'error') { + settleTools(msgIdx) messages.value[msgIdx].content = (chunk.value as string) ?? 'バックエンドでエラーが発生しました。' messages.value[msgIdx].pending = false messages.value[msgIdx].error = true @@ -137,6 +171,7 @@ export const useChatStore = defineStore('chat', () => { } }, (err) => { + settleTools(msgIdx) // 途中まで届いていた内容は残しつつ、エラーメッセージを付記する const partial = messages.value[msgIdx].content messages.value[msgIdx].content = partial ? `${partial}\n\n⚠️ ${err.message}` : err.message diff --git a/frontend/src/stores/notes.ts b/frontend/src/stores/notes.ts index 892f8f8..75658bd 100644 --- a/frontend/src/stores/notes.ts +++ b/frontend/src/stores/notes.ts @@ -5,8 +5,13 @@ import { createNotePage, updateNotePage, deleteNotePage, + exportNotePage, + exportAllNotePages, renameNoteSection, reorderNotePages, + fetchSessionNotes, + updateSessionNotes, + importNotePages, type NotePage, } from '../api/client' @@ -18,6 +23,13 @@ export const useNotesStore = defineStore('notes', () => { const error = ref(null) /** 未保存の編集があるページID。AIによる更新で上書きしないために使う。 */ const dirtyIds = ref>(new Set()) + /** + * 本文をシステムプロンプトに載せるページID。チャットセッションごとの設定で、 + * セッションを切り替えるたびに loadAttached() で読み直す。 + */ + const attachedIds = ref>(new Set()) + /** attachedIds がどのセッションのものか。null は「まだ保存先が無い新規チャット」。 */ + const attachedSessionId = ref(null) let saveTimer: number | null = null @@ -97,11 +109,50 @@ export const useNotesStore = defineStore('notes', () => { } } + /** セッションに紐付いたページを読み込む。sessionId が null(新規チャット)なら選択を空にする。 */ + async function loadAttached(sessionId: string | null): Promise { + 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 { + 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 { error.value = null try { await deleteNotePage(id) dirtyIds.value.delete(id) + // 紐付けはサーバー側でもカスケード削除される + if (attachedIds.value.has(id)) { + const next = new Set(attachedIds.value) + next.delete(id) + attachedIds.value = next + } pages.value = pages.value.filter((p) => p.id !== id) if (activeId.value === id) activeId.value = pages.value[0]?.id ?? null } catch (e) { @@ -140,13 +191,46 @@ export const useNotesStore = defineStore('notes', () => { } } - /** 保留中の保存を直ちに実行する(タブ切替・パネルを閉じる時など)。 */ - function flush(): void { + /** + * 保留中の保存を直ちに実行する(タブ切替・パネルを閉じる時など)。 + * await すれば保存の完了まで待てる(save() は自分でエラーを握るので reject はしない)。 + */ + function flush(): Promise { if (saveTimer !== null) { clearTimeout(saveTimer) saveTimer = null } - for (const id of [...dirtyIds.value]) void save(id) + return Promise.all([...dirtyIds.value].map((id) => save(id))).then(() => undefined) + } + + /** + * ファイルをページとして取り込み、最初に作られたページを開く。 + * 戻り値は作成されたページ(失敗時は空配列)。 + */ + async function importFiles(files: File[]): Promise { + 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 { + await flush() + exportNotePage(id) + } + + /** 編集中の内容を保存してから、全ページを ZIP としてダウンロードする。 */ + async function downloadAll(): Promise { + await flush() + exportAllNotePages() } return { @@ -155,6 +239,10 @@ export const useNotesStore = defineStore('notes', () => { activePage, error, dirtyIds, + attachedIds, + attachedSessionId, + loadAttached, + toggleAttached, load, selectPage, addPage, @@ -164,5 +252,8 @@ export const useNotesStore = defineStore('notes', () => { renameSection, reorder, flush, + importFiles, + downloadPage, + downloadAll, } })