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:
@@ -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():
|
||||
|
||||
@@ -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 が空ならグループ解除。"""
|
||||
|
||||
Reference in New Issue
Block a user