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>
This commit is contained in:
2026-08-23 16:04:41 +09:00
co-authored by Claude Opus 5
parent 216d38c7bd
commit 0e58a07af4
18 changed files with 629 additions and 33 deletions
+29
View File
@@ -12,6 +12,7 @@ from app.core.memory_kb import memory as mem
from app.core import llm
from app.core.memory_kb import knowledge
from app.core.memory_kb import local_knowledge
from app.core import notes
from app.core.notes import NOTE_TOOLS
from app.core.search.websearch import WEB_SEARCH_TOOL
from app.core.config import DEFAULT_MODEL
@@ -25,6 +26,13 @@ class ChatRequest(BaseModel):
session_id: str | None = None
message: str
model: str | None = None
# このセッションで本文をプロンプトに載せるノートのページID。
# 新規セッションではまだ session_id が無く PUT できないため、送信時にまとめて反映する。
note_page_ids: list[int] | None = None
class SessionNotesRequest(BaseModel):
page_ids: list[int]
class SessionResponse(BaseModel):
@@ -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():
+20 -2
View File
@@ -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 が空ならグループ解除。"""
+7
View File
@@ -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"))
+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})
# 共有ノートは「タイトル一覧」だけを常時見せ、本文は 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)
+178 -4
View File
@@ -2,14 +2,25 @@
システムプロンプトにはページの「タイトル一覧」だけを注入し、本文はモデルが
read_note ツールで必要なときだけ読み出す。書き込みは write_note ツールで行う。
例外として、ユーザーがセッションに紐付けたページ(SessionNotePage)は本文も
毎回そのままシステムプロンプトに載る。ツールの往復を待たずに確実に参照させたい
資料のための仕組みで、指定はセッション単位なので会話ごとに組み合わせを変えられる。
"""
import io
import logging
import re
import zipfile
from sqlalchemy.orm import Session as DBSession
from app.core.config import NOTES_TOOL_CONTENT_LIMIT
from app.db.models import NotePage
from app.core.config import (
NOTES_IMPORT_MAX_BYTES,
NOTES_IMPORT_MAX_PAGES,
NOTES_TOOL_CONTENT_LIMIT,
SESSION_NOTES_CONTENT_LIMIT,
)
from app.db.models import NotePage, SessionNotePage
logger = logging.getLogger(__name__)
@@ -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}")
+26 -2
View File
@@ -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 が読み書きする。"""
+1
View File
@@ -19,6 +19,7 @@ dependencies = [
"discord-py>=2.7.1",
"janome>=0.5.0",
"trafilatura>=2.1.0",
"python-multipart>=0.0.9",
]
[project.optional-dependencies]
+11
View File
@@ -1300,6 +1300,7 @@ dependencies = [
{ name = "nvidia-ml-py" },
{ name = "psutil" },
{ name = "python-dotenv" },
{ name = "python-multipart" },
{ name = "sqlalchemy" },
{ name = "sse-starlette" },
{ name = "trafilatura" },
@@ -1336,6 +1337,7 @@ requires-dist = [
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "python-multipart", specifier = ">=0.0.9" },
{ name = "sqlalchemy", specifier = ">=2.0.0" },
{ name = "sse-starlette", specifier = ">=2.1.0" },
{ name = "trafilatura", specifier = ">=2.1.0" },
@@ -1642,6 +1644,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]]
name = "pytz"
version = "2026.2"