From 0e58a07af4dbcae78d038b0a012705ae9f4050f1 Mon Sep 17 00:00:00 2001 From: zukki259 Date: Sun, 23 Aug 2026 16:04:41 +0900 Subject: [PATCH] Pin notes to a chat, and import notes from files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 6 + backend/app/api/chat.py | 29 ++++ backend/app/api/notes.py | 22 +++- backend/app/core/config.py | 7 + backend/app/core/memory_kb/memory.py | 7 +- backend/app/core/notes.py | 182 +++++++++++++++++++++++++- backend/app/db/models.py | 28 +++- backend/pyproject.toml | 1 + backend/uv.lock | 11 ++ docs/api.md | 45 ++++++- docs/architecture.md | 8 ++ docs/features.md | 9 ++ docs/notes-guide.md | 47 ++++++- frontend/src/api/client.ts | 33 ++++- frontend/src/components/NotePanel.vue | 134 ++++++++++++++++--- frontend/src/components/ToastList.vue | 7 +- frontend/src/stores/chat.ts | 14 +- frontend/src/stores/notes.ts | 72 ++++++++++ 18 files changed, 629 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 78657c1..171dcff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,7 @@ LLM に枡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte 1. **蓄積知識** (`memory_kb/knowledge.py`): 過去の Web 怜玢で埗た `knowledge_entries` の**芋出し䞀芧のみ**`- 「ク゚リ」: 䞀行芁玄`。本文ず出兞は含めず、必芁なら `read_knowledge` ツヌルで読み出す。最新 `KNOWLEDGE_CONTEXT_LIMIT`既定 15件。セッションを暪断する 2. **共有ノヌト** (`core/notes.py`): タむトル䞀芧本文先頭 40 文字のプレビュヌのみ +2.5. **参照指定ノヌト** (`core/notes.py`): ナヌザヌがそのセッションに玐付けたGUI のタブで 📌 を抌したペヌゞの**本文党文**。`session_note_pages` テヌブルで管理し、1 ペヌゞ `SESSION_NOTES_CONTENT_LIMIT`既定 8000文字で打ち切る。局 2 の䞀芧偎には印が付き、同じ本文を `read_note` で読み盎させない 3. **ロヌカル KB** (`memory_kb/local_knowledge.py`): `backend/knowledge_base/**.md` のうち、盎近ナヌザヌ発蚀ず埋め蟌みコサむン類䌌床が高い䞊䜍 `LOCAL_KB_TOP_K`既定 3件か぀ `LOCAL_KB_MIN_SCORE`既定 0.75以䞊を**å…šæ–‡**泚入。プロンプト䞊は Web 怜玢結果より優先する扱い 4. **長期メモリ**: `memories` の芁玄を党件連結。䞊限なし 5. **短期メモリ**: セッション内の `messages` 党件芁玄で削陀された分を陀く @@ -97,6 +98,8 @@ 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()` @@ -145,6 +148,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 364b125..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(): diff --git a/backend/app/api/notes.py b/backend/app/api/notes.py index 24903d2..b144150 100644 --- a/backend/app/api/notes.py +++ b/backend/app/api/notes.py @@ -3,12 +3,12 @@ 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 @@ -129,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/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 d7d8112..d4f44b6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -15,10 +15,15 @@ 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 | タむミング | |---|---|---| @@ -64,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` を返す。 @@ -187,6 +214,22 @@ 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` diff --git a/docs/architecture.md b/docs/architecture.md index c94bcc0..ac79a8a 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()` がアシスタント応答の保存盎埌に走る。 diff --git a/docs/features.md b/docs/features.md index d777d22..3ea568c 100644 --- a/docs/features.md +++ b/docs/features.md @@ -71,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 ラりンドでこれを解決する。 @@ -109,6 +112,12 @@ 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、セクションはフォルダ、ファむル名の通し番号でタブ順を保持ずしお 手元に保存できる。未保存の線集は曞き出す前に保存される。 diff --git a/docs/notes-guide.md b/docs/notes-guide.md index d81c728..51146d2 100644 --- a/docs/notes-guide.md +++ b/docs/notes-guide.md @@ -18,12 +18,57 @@ 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` で読み足せる。あれもこれも 📌 するず毎回のリク゚ストが +その分重くなるので、本圓に読たせたいペヌゞだけに絞るのがよい。 + ## 手元にダりンロヌドする パネル䞋郚の ⬇ ボタンでノヌトをファむルずしお保存できる。未保存の線集があっおも、 @@ -69,7 +114,7 @@ ZIP の䞭のファむル名には `01_`, `02_` 
 ず通し番号が付く。 ## AIずの連携 - 党ペヌゞの**タむトル䞀芧**は、䌚話のたびに自動でAIに枡されるセクションがあれば芋出し付きで。 - ただし本文はここには含たれない。 + ただし本文はここには含たれない📌 を付けたペヌゞを陀く。 - AIは `read_note` ツヌルでペヌゞ本文を読み、`write_note` ツヌルで远蚘・眮き換え・新芏ペヌゞ䜜成ができる。 「ノヌトに曞いおおいお」「〇〇に぀いお蚘録しお」のように頌むず、AIが該圓ペヌゞを刀断しお曞き蟌む。 - AIがノヌトを曞き換えるず、開いおいるノヌトパネルは自動的に最新の内容ぞ曎新される。 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a2641f5..4d53f5b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -75,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' }) } @@ -225,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 { @@ -233,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) => { @@ -314,6 +334,17 @@ 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 属性は付けない diff --git a/frontend/src/components/NotePanel.vue b/frontend/src/components/NotePanel.vue index a6cd613..5c65785 100644 --- a/frontend/src/components/NotePanel.vue +++ b/frontend/src/components/NotePanel.vue @@ -1,6 +1,7 @@