Feature/shared notes #1

Merged
colcaroli merged 2 commits from feature/shared-notes into main 2026-08-18 16:29:57 +00:00
17 changed files with 952 additions and 31 deletions
+14 -3
View File
@@ -59,8 +59,8 @@ Copy-Item .env.example .env
frontend (Vue3 + Pinia, :5173) frontend (Vue3 + Pinia, :5173)
↕ REST + SSE (Vite proxy) ↕ REST + SSE (Vite proxy)
backend (FastAPI, :8000) backend (FastAPI, :8000)
├── app/api/ チャット・メモリ・ログ のルーター ├── app/api/ チャット・メモリ・ノート・ログ のルーター
├── app/core/ LLM ラッパー・メモリロジック・設定 ├── app/core/ LLM ラッパー・メモリロジック・共有ノート・設定
└── app/db/ SQLAlchemy モデル・DB 接続 └── app/db/ SQLAlchemy モデル・DB 接続
↕ ↕
SQLite (playchat.db) + LiteLLM → 任意の LLM SQLite (playchat.db) + LiteLLM → 任意の LLM
@@ -71,9 +71,19 @@ SQLite (playchat.db) + LiteLLM → 任意の LLM
- **短期**: セッション内の全メッセージをコンテキストに含める - **短期**: セッション内の全メッセージをコンテキストに含める
- **長期**: メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(デフォルト 20)を超えると古い半分を LLM で要約 → `memories` テーブルに保存 → 次回以降のシステムプロンプトに注入 - **長期**: メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(デフォルト 20)を超えると古い半分を LLM で要約 → `memories` テーブルに保存 → 次回以降のシステムプロンプトに注入
### 共有ノート
ユーザーと AI が共同編集するノート。`note_pages` テーブルに 1 ページ 1 行で保存し、UI ではタブとして切り替える。
- **モデルから見える範囲**: システムプロンプトに入るのは**ページのタイトル一覧**(と本文の先頭 40 文字程度のプレビュー)だけ。本文全体は含めない
- **モデルによる読み書き**: `read_note` / `write_note` ツール(`app/core/notes.py`)で行う。`write_note` は存在しないタイトルを指定するとページを新規作成し、`mode` が `append`(既定)なら追記、`replace` なら全置換
- **ツール実行の流れ**: `llm.resolve_tool_calls()` が本番の応答生成前にツール呼び出しを解決する。`web_search` と共通の仕組みで、ノート系ツールは `db` が渡されたときだけモデルに提示される
- **UI への反映**: AI がノートを書き換えた場合、SSE で `notes_updated` を送ってフロント側のノートパネルを再読み込みさせる
- **編集の衝突回避**: フロントは未保存の編集があるページ(`dirtyIds`)をサーバーの値で上書きしない
### SSE ストリーミング ### SSE ストリーミング
`POST /chat/stream` が SSE を返す。イベント種別: `session_id`(新規セッション ID)/ `chunk`(テキスト断片)/ `done`(完了)/ `error`(エラー)。 `POST /chat/stream` が SSE を返す。イベント種別: `session_id`(新規セッション ID)/ `chunk`(テキスト断片)/ `sources`(Web 検索の出典)/ `notes_updated`(AI が共有ノートを更新)/ `done`(完了)/ `error`(エラー)。
フロントは `src/api/client.ts` の `streamChat()` で fetch + ReadableStream により受信し、`messages.value[msgIdx]` を reactive proxy 経由で更新する。 フロントは `src/api/client.ts` の `streamChat()` で fetch + ReadableStream により受信し、`messages.value[msgIdx]` を reactive proxy 経由で更新する。
### Markdown レンダリング ### Markdown レンダリング
@@ -107,5 +117,6 @@ SQLite (playchat.db) + LiteLLM → 任意の LLM
| `OPENAI_API_KEY` | — | OpenAI API キー | | `OPENAI_API_KEY` | — | OpenAI API キー |
| `ANTHROPIC_API_KEY` | — | Anthropic API キー | | `ANTHROPIC_API_KEY` | — | Anthropic API キー |
| `OLLAMA_API_BASE` | `http://localhost:11434` | Ollama エンドポイント | | `OLLAMA_API_BASE` | `http://localhost:11434` | Ollama エンドポイント |
| `NOTES_TOOL_CONTENT_LIMIT` | `4000` | `read_note` が 1 回に返す本文の最大文字数 |
| `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) | | `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) |
| `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 | | `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 |
+97 -6
View File
@@ -2,16 +2,17 @@ import asyncio
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse, JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session as DBSession from sqlalchemy.orm import Session as DBSession
from app.db.database import get_db from app.db.database import get_db
from app.db.models import Session, Message from app.db.models import Session, Message, MessageTag
from app.core.memory_kb import memory as mem from app.core.memory_kb import memory as mem
from app.core import llm from app.core import llm
from app.core.memory_kb import knowledge from app.core.memory_kb import knowledge
from app.core.memory_kb import local_knowledge from app.core.memory_kb import local_knowledge
from app.core.notes import NOTE_TOOLS
from app.core.search.websearch import WEB_SEARCH_TOOL from app.core.search.websearch import WEB_SEARCH_TOOL
from app.core.config import DEFAULT_MODEL from app.core.config import DEFAULT_MODEL
from app.core.memory_kb.tagging import tag_message_by_id from app.core.memory_kb.tagging import tag_message_by_id
@@ -47,6 +48,25 @@ class MessageResponse(BaseModel):
from_attributes = True from_attributes = True
class ImportMessage(BaseModel):
role: str
content: str
created_at: datetime | None = None
tags: list[str] = []
class ImportSessionMeta(BaseModel):
title: str | None = None
model: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class ImportRequest(BaseModel):
session: ImportSessionMeta = ImportSessionMeta()
messages: list[ImportMessage]
@router.get("/sessions", response_model=list[SessionResponse]) @router.get("/sessions", response_model=list[SessionResponse])
def list_sessions(db: DBSession = Depends(get_db)): def list_sessions(db: DBSession = Depends(get_db)):
return db.query(Session).order_by(Session.updated_at.desc()).all() return db.query(Session).order_by(Session.updated_at.desc()).all()
@@ -69,6 +89,73 @@ def get_messages(session_id: str, db: DBSession = Depends(get_db)):
] ]
@router.get("/sessions/{session_id}/export")
def export_session(session_id: str, db: DBSession = Depends(get_db)):
"""セッションのメタデータと全メッセージをJSONファイルとしてダウンロードする。"""
session = db.get(Session, session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
data = {
"session": {
"id": session.id,
"title": session.title,
"model": session.model,
"created_at": session.created_at.isoformat(),
"updated_at": session.updated_at.isoformat(),
},
"messages": [
{
"id": m.id,
"role": m.role,
"content": m.content,
"created_at": m.created_at.isoformat(),
"tags": [t.tag for t in m.tags],
}
for m in session.messages
],
}
return JSONResponse(
content=data,
headers={"Content-Disposition": f'attachment; filename="session_{session.id}.json"'},
)
@router.post("/sessions/import", response_model=SessionResponse, status_code=201)
def import_session(req: ImportRequest, db: DBSession = Depends(get_db)):
"""export で出力したJSON(またはその形式に沿ったJSON)から新規セッションを作成する。"""
if not req.messages:
raise HTTPException(status_code=400, detail="messages is empty")
for m in req.messages:
if m.role not in ("user", "assistant", "system"):
raise HTTPException(status_code=400, detail=f"invalid role: {m.role}")
title = req.session.title or req.messages[0].content[:40]
session = Session(
id=str(uuid.uuid4()),
title=title,
model=req.session.model or DEFAULT_MODEL,
)
if req.session.created_at:
session.created_at = req.session.created_at
session.updated_at = req.session.updated_at or datetime.now(timezone.utc)
db.add(session)
db.flush()
for m in req.messages:
msg = Message(session_id=session.id, role=m.role, content=m.content)
if m.created_at:
msg.created_at = m.created_at
db.add(msg)
db.flush()
for tag in m.tags:
db.add(MessageTag(message_id=msg.id, tag=tag))
db.commit()
db.refresh(session)
return session
@router.delete("/sessions/{session_id}", status_code=204) @router.delete("/sessions/{session_id}", status_code=204)
def delete_session(session_id: str, db: DBSession = Depends(get_db)): def delete_session(session_id: str, db: DBSession = Depends(get_db)):
session = db.get(Session, session_id) session = db.get(Session, session_id)
@@ -124,9 +211,12 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
used_model = req.model or session.model used_model = req.model or session.model
full_response = [] full_response = []
try: try:
resolved_context, searches = await llm.resolve_web_search( resolved_context, searches, notes_changed = await llm.resolve_tool_calls(
context, model=used_model, session_id=session.id, context, model=used_model, session_id=session.id, db=db,
) )
if notes_changed:
# AIがノートを書き換えたので、フロントのノートパネルを再読み込みさせる
yield sse({"type": "notes_updated"})
if searches: if searches:
sources = [ sources = [
{"title": r["title"], "url": r["url"]} {"title": r["title"], "url": r["url"]}
@@ -139,9 +229,10 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
knowledge.save_knowledge(db, s["query"], summary, s["results"]) knowledge.save_knowledge(db, s["query"], summary, s["results"])
local_knowledge.save_search_result(s["query"], summary, s["results"]) local_knowledge.save_search_result(s["query"], summary, s["results"])
search_tools = [WEB_SEARCH_TOOL] if len(resolved_context) > len(context) else None # 履歴にtool_callsが含まれる場合、最終呼び出しにも同じツール定義を渡す必要がある
used_tools = [WEB_SEARCH_TOOL, *NOTE_TOOLS] if len(resolved_context) > len(context) else None
async for chunk in llm.stream_chat( async for chunk in llm.stream_chat(
resolved_context, model=used_model, session_id=session.id, tools=search_tools, resolved_context, model=used_model, session_id=session.id, tools=used_tools,
): ):
full_response.append(chunk) full_response.append(chunk)
yield sse({"type": "chunk", "value": chunk}) yield sse({"type": "chunk", "value": chunk})
+73
View File
@@ -0,0 +1,73 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session as DBSession
from app.core.notes import next_position
from app.db.database import get_db
from app.db.models import NotePage
router = APIRouter(prefix="/api/notes", tags=["notes"])
class NotePageResponse(BaseModel):
id: int
title: str
content: str
position: int
updated_at: datetime
class Config:
from_attributes = True
class NotePageCreate(BaseModel):
title: str = "新しいページ"
content: str = ""
class NotePageUpdate(BaseModel):
title: str | None = None
content: str | None = None
@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.post("", response_model=NotePageResponse, status_code=201)
def create_page(req: NotePageCreate, db: DBSession = Depends(get_db)):
title = req.title.strip() or "新しいページ"
page = NotePage(title=title, content=req.content, position=next_position(db))
db.add(page)
db.commit()
db.refresh(page)
return page
@router.patch("/{page_id}", response_model=NotePageResponse)
def update_page(page_id: int, req: NotePageUpdate, db: DBSession = Depends(get_db)):
page = db.get(NotePage, page_id)
if not page:
raise HTTPException(status_code=404, detail="Note page not found")
if req.title is not None:
title = req.title.strip()
if not title:
raise HTTPException(status_code=400, detail="title is empty")
page.title = title
if req.content is not None:
page.content = req.content
db.commit()
db.refresh(page)
return page
@router.delete("/{page_id}", status_code=204)
def delete_page(page_id: int, db: DBSession = Depends(get_db)):
page = db.get(NotePage, page_id)
if not page:
raise HTTPException(status_code=404, detail="Note page not found")
db.delete(page)
db.commit()
+4
View File
@@ -37,6 +37,10 @@ LOCAL_KB_EMBEDDING_MODEL: str = os.getenv("LOCAL_KB_EMBEDDING_MODEL", "text-embe
LOCAL_KB_TOP_K: int = int(os.getenv("LOCAL_KB_TOP_K", "3")) LOCAL_KB_TOP_K: int = int(os.getenv("LOCAL_KB_TOP_K", "3"))
LOCAL_KB_MIN_SCORE: float = float(os.getenv("LOCAL_KB_MIN_SCORE", "0.75")) LOCAL_KB_MIN_SCORE: float = float(os.getenv("LOCAL_KB_MIN_SCORE", "0.75"))
# 共有ノート(ユーザーとAIが共同編集するノート)
# read_note ツールが1回に返す本文の最大文字数
NOTES_TOOL_CONTENT_LIMIT: int = int(os.getenv("NOTES_TOOL_CONTENT_LIMIT", "4000"))
# Amazon Bedrock # Amazon Bedrock
# 以下はすべて LiteLLM(boto3) が os.environ から直接読み取るため、.env に書くだけで自動的に有効になる。 # 以下はすべて LiteLLM(boto3) が os.environ から直接読み取るため、.env に書くだけで自動的に有効になる。
# IAM経由(推奨): AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY を設定する。 # IAM経由(推奨): AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY を設定する。
+32 -16
View File
@@ -2,13 +2,17 @@ import json
import logging import logging
import time import time
import traceback import traceback
from typing import AsyncIterator, AsyncGenerator from typing import TYPE_CHECKING, AsyncIterator, AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import litellm import litellm
from app.core.config import DEFAULT_MODEL, DEBUG from app.core.config import DEFAULT_MODEL, DEBUG
from app.core.notes import NOTE_TOOLS, execute_note_tool
from app.core.search.websearch import WEB_SEARCH_TOOL, search_web from app.core.search.websearch import WEB_SEARCH_TOOL, search_web
if TYPE_CHECKING:
from sqlalchemy.orm import Session as DBSession
litellm.drop_params = True litellm.drop_params = True
if DEBUG: if DEBUG:
litellm._turn_on_debug() litellm._turn_on_debug()
@@ -103,32 +107,38 @@ async def embed(texts: list[str], model: str) -> list[list[float]]:
return [item["embedding"] for item in response.data] return [item["embedding"] for item in response.data]
async def resolve_web_search( async def resolve_tool_calls(
messages: list[dict], messages: list[dict],
model: str | None = None, model: str | None = None,
session_id: str | None = None, session_id: str | None = None,
db: "DBSession | None" = None,
max_rounds: int = 2, max_rounds: int = 2,
) -> tuple[list[dict], list[dict]]: ) -> tuple[list[dict], list[dict], bool]:
"""必要に応じてLLMにweb_searchツールを呼ばせ、検索結果を会話に組み込んだ上でメッセージ列を返す。 """必要に応じてLLMにツールを呼ばせ、その結果を会話に組み込んだ上でメッセージ列を返す。
モデルがtool callingに対応していない場合は何もせず元のmessagesをそのまま返す。 扱うツールは web_search と共有ノート(read_note / write_note)。ノート系は db が
戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト) 渡されたときだけ提示する。モデルがtool callingに対応していない場合は何もせず
元のmessagesをそのまま返す。
戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト, ノートを変更したか)
""" """
used_model = model or DEFAULT_MODEL used_model = model or DEFAULT_MODEL
working_messages = list(messages) working_messages = list(messages)
searches: list[dict] = [] searches: list[dict] = []
notes_changed = False
tools = [WEB_SEARCH_TOOL] + (NOTE_TOOLS if db is not None else [])
for _ in range(max_rounds): for _ in range(max_rounds):
try: try:
response = await litellm.acompletion( response = await litellm.acompletion(
model=used_model, model=used_model,
messages=working_messages, messages=working_messages,
tools=[WEB_SEARCH_TOOL], tools=tools,
tool_choice="auto", tool_choice="auto",
) )
except Exception: except Exception:
logger.info("Model %s does not support tool calling; skipping web search", used_model) logger.info("Model %s does not support tool calling; skipping tools", used_model)
return messages, searches return messages, searches, notes_changed
choice_message = response.choices[0].message choice_message = response.choices[0].message
tool_calls = getattr(choice_message, "tool_calls", None) tool_calls = getattr(choice_message, "tool_calls", None)
@@ -153,13 +163,19 @@ async def resolve_web_search(
args = json.loads(tc.function.arguments or "{}") args = json.loads(tc.function.arguments or "{}")
except json.JSONDecodeError: except json.JSONDecodeError:
args = {} args = {}
query = args.get("query", "")
results = await search_web(query) name = tc.function.name
if results: if name in ("read_note", "write_note") and db is not None:
tool_text = "\n\n".join(f"[{r['title']}]({r['url']})\n{r['content']}" for r in results) tool_text, changed = execute_note_tool(db, name, args)
searches.append({"query": query, "results": results}) notes_changed = notes_changed or changed
else: else:
tool_text = "検索結果が見つかりませんでした。" 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})
else:
tool_text = "検索結果が見つかりませんでした。"
working_messages.append({ working_messages.append({
"role": "tool", "role": "tool",
@@ -167,4 +183,4 @@ async def resolve_web_search(
"content": tool_text, "content": tool_text,
}) })
return working_messages, searches return working_messages, searches, notes_changed
+6
View File
@@ -5,6 +5,7 @@ from app.core import llm
from app.core.memory_kb import knowledge from app.core.memory_kb import knowledge
from app.core.memory_kb import local_knowledge from app.core.memory_kb import local_knowledge
from app.core.memory_kb.tagging import tag_memory from app.core.memory_kb.tagging import tag_memory
from app.core import notes
async def get_context_messages(db: DBSession, session_id: str) -> list[dict]: async def get_context_messages(db: DBSession, session_id: str) -> list[dict]:
@@ -18,6 +19,11 @@ async def get_context_messages(db: DBSession, session_id: str) -> list[dict]:
if knowledge_context: if knowledge_context:
context.append({"role": "system", "content": knowledge_context}) context.append({"role": "system", "content": knowledge_context})
# 共有ノートは「タイトル一覧」だけを常時見せ、本文は read_note ツールで読ませる
notes_context = notes.get_notes_context(db)
if notes_context:
context.append({"role": "system", "content": notes_context})
if messages: if messages:
latest_user_message = next((m.content for m in reversed(messages) if m.role == "user"), "") latest_user_message = next((m.content for m in reversed(messages) if m.role == "user"), "")
local_kb_context = await local_knowledge.get_local_kb_context(db, latest_user_message) local_kb_context = await local_knowledge.get_local_kb_context(db, latest_user_message)
+171
View File
@@ -0,0 +1,171 @@
"""ユーザーとAIが共同で編集する共有ノート。
システムプロンプトにはページの「タイトル一覧」だけを注入し、本文はモデルが
read_note ツールで必要なときだけ読み出す。書き込みは write_note ツールで行う。
"""
import logging
import re
from sqlalchemy.orm import Session as DBSession
from app.core.config import NOTES_TOOL_CONTENT_LIMIT
from app.db.models import NotePage
logger = logging.getLogger(__name__)
READ_NOTE_TOOL = {
"type": "function",
"function": {
"name": "read_note",
"description": "共有ノートの特定ページの本文を読み出す。システムプロンプトに提示されたタイトル一覧の中から、"
"今の会話に関係しそうなページがあるときに使う。",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "読み出すページのタイトル(タイトル一覧に載っているもの)"},
},
"required": ["title"],
},
},
}
WRITE_NOTE_TOOL = {
"type": "function",
"function": {
"name": "write_note",
"description": "共有ノートのページに書き込む。ユーザーからノートへの記録・追記・整理を頼まれたときに使う。"
"存在しないタイトルを指定した場合は新しいページを作成する。"
"既存ページを replace で上書きすると元の内容は失われるので、追記でよい場合は必ず append を使うこと。",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "書き込む対象のページタイトル"},
"content": {"type": "string", "description": "書き込む本文(Markdown可)"},
"mode": {
"type": "string",
"enum": ["append", "replace"],
"description": "append=末尾に追記(既定) / replace=ページ全体を置き換え",
},
},
"required": ["title", "content"],
},
},
}
NOTE_TOOLS = [READ_NOTE_TOOL, WRITE_NOTE_TOOL]
def _find_page(db: DBSession, title: str) -> NotePage | None:
"""タイトルでページを探す。完全一致 → 大文字小文字・前後空白を無視した一致 の順に試す。"""
pages = db.query(NotePage).order_by(NotePage.position, NotePage.id).all()
for page in pages:
if page.title == title:
return page
normalized = title.strip().casefold()
for page in pages:
if page.title.strip().casefold() == normalized:
return page
return None
def next_position(db: DBSession) -> int:
"""新規ページを末尾に置くための position を返す。"""
last = db.query(NotePage).order_by(NotePage.position.desc()).first()
return (last.position + 1) if last else 0
def get_notes_context(db: DBSession) -> str | None:
"""共有ノートのタイトル一覧をシステムプロンプト用のテキストとして返す。"""
pages = db.query(NotePage).order_by(NotePage.position, NotePage.id).all()
lines = [
"【共有ノート】",
"ユーザーとあなたが共同で編集できるノートです。以下のページがあります。",
"",
]
if pages:
for page in pages:
preview = " ".join(page.content.split())[:40]
suffix = f" — {preview}…" if preview else " — (空)"
lines.append(f"- {page.title}{suffix}")
else:
lines.append("- (まだページがありません)")
lines += [
"",
"本文はここには含まれていません。内容が必要なときは read_note ツールで読み出してください。",
"ノートへの記録・追記を頼まれたときは write_note ツールで書き込んでください"
"(新しいタイトルを指定すればページが新規作成されます)。",
"ユーザーが明示的に頼んでいないのに勝手に書き込まないこと。",
]
return "\n".join(lines)
def read_note(db: DBSession, title: str) -> str:
"""ページ本文をツール結果用のテキストとして返す。"""
page = _find_page(db, title)
if not page:
available = ", ".join(p.title for p in db.query(NotePage).order_by(NotePage.position, NotePage.id).all())
return f"「{title}」というページはありません。現在のページ: {available or '(なし)'}"
if not page.content.strip():
return f"ページ「{page.title}」は空です。"
content = page.content
if len(content) > NOTES_TOOL_CONTENT_LIMIT:
content = content[:NOTES_TOOL_CONTENT_LIMIT] + "\n…(以下省略)"
return f"ページ「{page.title}」の本文:\n\n{content}"
_LIST_ITEM = re.compile(r"^\s*([-*+]|\d+[.)])\s")
def _append(existing: str, addition: str) -> str:
"""既存本文に追記する。箇条書き同士をつなぐ場合だけ空行を挟まない。"""
if not existing.strip():
return addition
body = existing.rstrip()
last_line = body.rsplit("\n", 1)[-1]
first_line = addition.lstrip("\n").split("\n", 1)[0]
separator = "\n" if _LIST_ITEM.match(last_line) and _LIST_ITEM.match(first_line) else "\n\n"
return f"{body}{separator}{addition}"
def write_note(db: DBSession, title: str, content: str, mode: str = "append") -> str:
"""ページに書き込む。存在しなければ新規作成する。"""
if not title.strip():
return "タイトルが空のため書き込めませんでした。"
page = _find_page(db, title)
if page is None:
page = NotePage(title=title.strip(), content=content, position=next_position(db))
db.add(page)
db.commit()
return f"新しいページ「{page.title}」を作成し、本文を書き込みました。"
if mode == "replace":
page.content = content
result = f"ページ「{page.title}」の内容を置き換えました。"
else:
page.content = _append(page.content, content)
result = f"ページ「{page.title}」に追記しました。"
db.commit()
return result
def execute_note_tool(db: DBSession, name: str, args: dict) -> tuple[str, bool]:
"""ノート系ツールを実行する。戻り値: (ツール結果テキスト, ノートを変更したか)。"""
try:
if name == "read_note":
return read_note(db, args.get("title", "")), False
if name == "write_note":
result = write_note(
db,
args.get("title", ""),
args.get("content", ""),
args.get("mode", "append"),
)
return result, True
except Exception as e:
db.rollback()
logger.exception("Note tool %s failed", name)
return f"ノート操作に失敗しました: {e}", False
return f"未知のツールです: {name}", False
+16
View File
@@ -104,6 +104,22 @@ class LocalKnowledgeDoc(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
class NotePage(Base):
"""ユーザーとAIが共同で編集する共有ノートの1ページ。UIではタブとして表示する。
ページのタイトル一覧は毎回システムプロンプトに注入され、本文はモデルが
read_note ツールで読み出す(app.core.notes を参照)。
"""
__tablename__ = "note_pages"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String)
content: Mapped[str] = mapped_column(Text, default="")
position: Mapped[int] = mapped_column(Integer, default=0) # タブの並び順
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
class AppSetting(Base): class AppSetting(Base):
"""GUIの設定画面から編集する接続情報(APIキー等)。app.core.settings_store が読み書きする。""" """GUIの設定画面から編集する接続情報(APIキー等)。app.core.settings_store が読み書きする。"""
__tablename__ = "app_settings" __tablename__ = "app_settings"
+2 -1
View File
@@ -5,7 +5,7 @@ from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api import auth as auth_api from app.api import auth as auth_api
from app.api import chat, logs, memory, system from app.api import chat, logs, memory, notes, system
from app.api import models as models_api from app.api import models as models_api
from app.api import settings as settings_api from app.api import settings as settings_api
from app.core import settings_store from app.core import settings_store
@@ -93,6 +93,7 @@ app.add_middleware(
app.include_router(auth_api.router) app.include_router(auth_api.router)
app.include_router(chat.router, dependencies=[Depends(require_site_session)]) app.include_router(chat.router, dependencies=[Depends(require_site_session)])
app.include_router(memory.router, dependencies=[Depends(require_site_session)]) app.include_router(memory.router, dependencies=[Depends(require_site_session)])
app.include_router(notes.router, dependencies=[Depends(require_site_session)])
app.include_router(logs.router, dependencies=[Depends(require_site_session)]) app.include_router(logs.router, dependencies=[Depends(require_site_session)])
app.include_router(system.router, dependencies=[Depends(require_site_session)]) app.include_router(system.router, dependencies=[Depends(require_site_session)])
app.include_router(settings_api.router, dependencies=[Depends(require_site_session)]) app.include_router(settings_api.router, dependencies=[Depends(require_site_session)])
+51
View File
@@ -24,6 +24,7 @@ SSE でストリーミング応答を返す。
|---|---|---| |---|---|---|
| `session_id` | セッション UUID | 新規セッション作成直後 | | `session_id` | セッション UUID | 新規セッション作成直後 |
| `chunk` | テキスト断片 | LLM トークン生成ごと | | `chunk` | テキスト断片 | LLM トークン生成ごと |
| `notes_updated` | — | AI が共有ノートを書き換えた直後 |
| `done` | — | 正常完了 | | `done` | — | 正常完了 |
| `error` | エラーメッセージ | 例外発生時 | | `error` | エラーメッセージ | 例外発生時 |
@@ -88,6 +89,56 @@ SSE でストリーミング応答を返す。
--- ---
## 共有ノート
ユーザーと AI が共同編集するノート。ページのタイトル一覧は毎回システムプロンプトに注入され、
本文は AI が `read_note` / `write_note` ツールで読み書きする。
### GET /api/notes
全ページを `position` 昇順で返す。
**レスポンス**
```json
[
{
"id": 1,
"title": "買い物リスト",
"content": "- 牛乳
- 卵",
"position": 0,
"updated_at": "2026-08-19T12:00:00"
}
]
```
---
### POST /api/notes
ページを新規作成し、`201 Created` で作成したページを返す。
**リクエスト**
```json
{ "title": "新しいページ", "content": "" }
```
---
### PATCH /api/notes/{page_id}
ページのタイトル・本文を更新する。指定したフィールドだけが変更される。
タイトルを空文字にしようとした場合は `400`、ページが存在しない場合は `404` を返す。
**リクエスト**
```json
{ "content": "更新後の本文" }
```
---
### DELETE /api/notes/{page_id}
ページを削除。`204 No Content` を返す。
---
## ログ ## ログ
### GET /logs ### GET /logs
+11
View File
@@ -43,6 +43,7 @@ backend/
| `sessions` | id (UUID), title, model, created_at, updated_at | チャットセッション | | `sessions` | id (UUID), title, model, created_at, updated_at | チャットセッション |
| `messages` | id, session_id, role, content, created_at | 会話メッセージ(短期メモリ) | | `messages` | id, session_id, role, content, created_at | 会話メッセージ(短期メモリ) |
| `memories` | id, session_id, summary, embedding_json, created_at | 要約による長期メモリ | | `memories` | id, session_id, summary, embedding_json, created_at | 要約による長期メモリ |
| `note_pages` | id, title, content, position, created_at, updated_at | ユーザーと AI が共同編集する共有ノートのページ |
| `api_logs` | id, session_id, model, request_json, response_text, error, latency_ms, created_at | LLM API 呼び出しログ | | `api_logs` | id, session_id, model, request_json, response_text, error, latency_ms, created_at | LLM API 呼び出しログ |
### メモリ機構(`core/memory.py`) ### メモリ機構(`core/memory.py`)
@@ -57,6 +58,15 @@ backend/
- `_log_context` で呼び出しを囲み、成功・失敗どちらも `api_logs` テーブルに記録 - `_log_context` で呼び出しを囲み、成功・失敗どちらも `api_logs` テーブルに記録
- `DEBUG=true` で `litellm._turn_on_debug()` が有効になり詳細ログが出力される - `DEBUG=true` で `litellm._turn_on_debug()` が有効になり詳細ログが出力される
### 共有ノート(`core/notes.py` / `api/notes.py`)
ユーザーと AI が共同編集するノート。`note_pages` の 1 行が UI の 1 タブに対応する。
- `get_notes_context()` が**タイトル一覧のみ**をシステムメッセージとして生成し、`get_context_messages()` が会話コンテキストに差し込む(本文は含めない)
- 本文の読み書きはツール経由: `read_note`(本文取得)/ `write_note`(追記・置換・新規作成)
- `llm.resolve_tool_calls()` が `web_search` と同じループでこれらを解決する。ノート系ツールは `db` を渡したときのみモデルに提示される
- REST(`/api/notes`)はフロントのノートパネル用。GET 一覧 / POST 作成 / PATCH 更新 / DELETE 削除
### SSE ストリーミング(`api/chat.py`) ### SSE ストリーミング(`api/chat.py`)
`POST /chat/stream` は `StreamingResponse` で以下のイベントを順番に送出する: `POST /chat/stream` は `StreamingResponse` で以下のイベントを順番に送出する:
@@ -64,6 +74,7 @@ backend/
``` ```
data: {"type":"session_id","value":"<uuid>"} # 新規セッション時のみ data: {"type":"session_id","value":"<uuid>"} # 新規セッション時のみ
data: {"type":"chunk","value":"テキスト断片"} # 0回以上 data: {"type":"chunk","value":"テキスト断片"} # 0回以上
data: {"type":"notes_updated"} # AIが共有ノートを更新した時のみ
data: {"type":"done"} # 正常完了 data: {"type":"done"} # 正常完了
data: {"type":"error","value":"エラー内容"} # 例外発生時 data: {"type":"error","value":"エラー内容"} # 例外発生時
``` ```
+8 -1
View File
@@ -6,6 +6,7 @@ import MessageInput from './components/MessageInput.vue'
import ModelSelector from './components/ModelSelector.vue' import ModelSelector from './components/ModelSelector.vue'
import LogViewer from './components/LogViewer.vue' import LogViewer from './components/LogViewer.vue'
import SettingsPanel from './components/SettingsPanel.vue' import SettingsPanel from './components/SettingsPanel.vue'
import NotePanel from './components/NotePanel.vue'
import ToastList from './components/ToastList.vue' import ToastList from './components/ToastList.vue'
import MemoryMonitor from './components/MemoryMonitor.vue' import MemoryMonitor from './components/MemoryMonitor.vue'
import Clock from './components/Clock.vue' import Clock from './components/Clock.vue'
@@ -15,7 +16,7 @@ import { useAuthStore } from './stores/auth'
const authStore = useAuthStore() const authStore = useAuthStore()
type SidePanel = 'none' | 'logs' | 'settings' type SidePanel = 'none' | 'logs' | 'settings' | 'notes'
const activePanel = ref<SidePanel>('none') const activePanel = ref<SidePanel>('none')
function togglePanel(panel: SidePanel) { function togglePanel(panel: SidePanel) {
@@ -50,6 +51,9 @@ onUnmounted(() => {
<span class="title">PlayChat</span> <span class="title">PlayChat</span>
<ModelSelector /> <ModelSelector />
<Clock /> <Clock />
<button class="log-toggle" :class="{ active: activePanel === 'notes' }" @click="togglePanel('notes')">
ノート
</button>
<button class="log-toggle" :class="{ active: activePanel === 'settings' }" @click="togglePanel('settings')"> <button class="log-toggle" :class="{ active: activePanel === 'settings' }" @click="togglePanel('settings')">
設定 設定
</button> </button>
@@ -71,6 +75,9 @@ onUnmounted(() => {
<div v-else-if="activePanel === 'settings'" class="log-pane"> <div v-else-if="activePanel === 'settings'" class="log-pane">
<SettingsPanel /> <SettingsPanel />
</div> </div>
<div v-else-if="activePanel === 'notes'" class="log-pane">
<NotePanel />
</div>
</div> </div>
</div> </div>
</div> </div>
+72 -1
View File
@@ -22,7 +22,7 @@ export interface Source {
} }
export interface SSEChunk { export interface SSEChunk {
type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources' type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources' | 'notes_updated'
value?: string | Source[] value?: string | Source[]
} }
@@ -63,6 +63,39 @@ export async function deleteSession(sessionId: string): Promise<void> {
await fetch(`${BASE}/chat/sessions/${sessionId}`, { method: 'DELETE' }) await fetch(`${BASE}/chat/sessions/${sessionId}`, { method: 'DELETE' })
} }
export function exportSession(sessionId: string): void {
const a = document.createElement('a')
a.href = `${BASE}/chat/sessions/${sessionId}/export`
a.download = `session_${sessionId}.json`
document.body.appendChild(a)
a.click()
a.remove()
}
export interface ImportPayload {
session?: {
title?: string | null
model?: string | null
created_at?: string | null
updated_at?: string | null
}
messages: {
role: string
content: string
created_at?: string | null
tags?: string[]
}[]
}
export async function importSession(payload: ImportPayload): Promise<SessionInfo> {
const res = await fetch(`${BASE}/chat/sessions/import`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
return parseJson<SessionInfo>(res, 'インポート')
}
export async function fetchLogs(params: { export async function fetchLogs(params: {
session_id?: string session_id?: string
status?: 'ok' | 'error' status?: 'ok' | 'error'
@@ -224,3 +257,41 @@ export function streamChat(
return () => controller.abort() return () => controller.abort()
} }
export interface NotePage {
id: number
title: string
content: string
position: number
updated_at: string
}
export async function fetchNotePages(): Promise<NotePage[]> {
const res = await fetch(`${BASE}/api/notes`)
return parseJson<NotePage[]>(res, 'ノート取得')
}
export async function createNotePage(title: string): Promise<NotePage> {
const res = await fetch(`${BASE}/api/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
})
return parseJson<NotePage>(res, 'ページ作成')
}
export async function updateNotePage(
pageId: number,
patch: { title?: string; content?: string },
): Promise<NotePage> {
const res = await fetch(`${BASE}/api/notes/${pageId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
return parseJson<NotePage>(res, 'ページ更新')
}
export async function deleteNotePage(pageId: number): Promise<void> {
await fetch(`${BASE}/api/notes/${pageId}`, { method: 'DELETE' })
}
+197
View File
@@ -0,0 +1,197 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, nextTick } from 'vue'
import { useNotesStore } from '../stores/notes'
const store = useNotesStore()
const renamingId = ref<number | null>(null)
const renameInput = ref<HTMLInputElement | null>(null)
async function startRename(id: number) {
renamingId.value = id
await nextTick()
renameInput.value?.select()
}
function onTitleInput(id: number, e: Event) {
store.edit(id, { title: (e.target as HTMLInputElement).value })
}
function finishRename() {
renamingId.value = null
store.flush()
}
function onContentInput(e: Event) {
if (store.activeId === null) return
store.edit(store.activeId, { content: (e.target as HTMLTextAreaElement).value })
}
async function confirmRemove(id: number, title: string) {
if (!window.confirm(`ページ「${title}」を削除しますか?`)) return
await store.removePage(id)
}
onMounted(() => {
store.load()
})
onUnmounted(() => {
store.flush()
})
</script>
<template>
<div class="note-panel">
<div class="tabs">
<div
v-for="page in store.pages"
:key="page.id"
class="tab"
:class="{ active: page.id === store.activeId }"
@click="store.selectPage(page.id)"
@dblclick="startRename(page.id)"
>
<input
v-if="renamingId === page.id"
ref="renameInput"
class="tab-rename"
:value="page.title"
@input="onTitleInput(page.id, $event)"
@blur="finishRename"
@keydown.enter="finishRename"
@keydown.esc="finishRename"
@click.stop
>
<template v-else>
<span class="tab-title" :title="page.title">{{ page.title }}</span>
<span v-if="store.dirtyIds.has(page.id)" class="dot" title="未保存">●</span>
<button class="tab-del" title="削除" @click.stop="confirmRemove(page.id, page.title)">×</button>
</template>
</div>
<button class="tab-add" title="ページを追加" @click="store.addPage()">+</button>
</div>
<div v-if="store.error" class="error">⚠ {{ store.error }}</div>
<textarea
v-if="store.activePage"
class="editor"
:value="store.activePage.content"
placeholder="ここに書いた内容はAIと共有されます。AIも read_note / write_note でこのページを読み書きできます。"
spellcheck="false"
@input="onContentInput"
@blur="store.flush()"
/>
<div v-else class="empty">
<p>ページがありません。</p>
<button class="create-btn" @click="store.addPage()">+ 最初のページを作成</button>
</div>
<div class="hint">
ページのタイトル一覧はAIのシステムプロンプトに常に含まれます。本文はAIが必要に応じて読み出します。
</div>
</div>
</template>
<style scoped>
.note-panel {
display: flex;
flex-direction: column;
height: 100%;
background: #161616;
}
.tabs {
display: flex;
align-items: stretch;
gap: 2px;
padding: 6px 6px 0;
overflow-x: auto;
flex-shrink: 0;
border-bottom: 1px solid #333;
}
.tab {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 8px;
background: #1f1f1f;
border: 1px solid #333;
border-bottom: none;
border-radius: 6px 6px 0 0;
font-size: 12px;
color: #aaa;
cursor: pointer;
white-space: nowrap;
max-width: 160px;
}
.tab.active { background: #2a2a2a; color: #fff; border-color: #444; }
.tab-title { overflow: hidden; text-overflow: ellipsis; }
.dot { color: #eab308; font-size: 8px; }
.tab-del, .tab-add {
background: none;
border: none;
color: #666;
cursor: pointer;
font-size: 13px;
padding: 0 2px;
line-height: 1;
}
.tab-del:hover, .tab-add:hover { color: #f87171; }
.tab-add { color: #888; padding: 0 8px; }
.tab-add:hover { color: #93c5fd; }
.tab-rename {
background: #111;
border: 1px solid #2563eb;
border-radius: 3px;
color: #eee;
font-size: 12px;
padding: 1px 4px;
width: 110px;
}
.error {
padding: 6px 10px;
font-size: 11px;
color: #fca5a5;
background: #2a1414;
border-bottom: 1px solid #7f1d1d;
}
.editor {
flex: 1;
resize: none;
background: #161616;
color: #ddd;
border: none;
outline: none;
padding: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
line-height: 1.6;
}
.editor::placeholder { color: #555; }
.empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: #666;
font-size: 13px;
}
.create-btn {
background: #2a2a2a;
color: #ccc;
border: 1px solid #444;
border-radius: 6px;
padding: 6px 14px;
font-size: 12px;
cursor: pointer;
}
.hint {
flex-shrink: 0;
padding: 8px 10px;
border-top: 1px solid #2a2a2a;
color: #666;
font-size: 10px;
line-height: 1.5;
}
</style>
+42 -2
View File
@@ -4,6 +4,7 @@ import { useChatStore } from '../stores/chat'
const store = useChatStore() const store = useChatStore()
const error = ref<string | null>(null) const error = ref<string | null>(null)
const importInput = ref<HTMLInputElement | null>(null)
async function load() { async function load() {
error.value = null error.value = null
@@ -14,6 +15,18 @@ async function load() {
} }
} }
function triggerImport() {
importInput.value?.click()
}
async function onImportFileChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
await store.importSessionFromFile(file)
}
onMounted(load) onMounted(load)
</script> </script>
@@ -21,7 +34,17 @@ onMounted(load)
<aside class="session-list"> <aside class="session-list">
<div class="header"> <div class="header">
<span>チャット履歴</span> <span>チャット履歴</span>
<button class="new-btn" @click="store.selectSession(null)">+ 新規</button> <div class="header-actions">
<button class="import-btn" title="JSONからインポート" @click="triggerImport">⭱</button>
<button class="new-btn" @click="store.selectSession(null)">+ 新規</button>
</div>
<input
ref="importInput"
type="file"
accept="application/json,.json"
class="hidden-file-input"
@change="onImportFileChange"
>
</div> </div>
<div v-if="error" class="api-error" :title="error">⚠ 接続エラー</div> <div v-if="error" class="api-error" :title="error">⚠ 接続エラー</div>
<ul> <ul>
@@ -32,6 +55,11 @@ onMounted(load)
@click="store.selectSession(s.id)" @click="store.selectSession(s.id)"
> >
<span class="title">{{ s.title }}</span> <span class="title">{{ s.title }}</span>
<button
class="export-btn"
@click.stop="store.downloadSessionLog(s.id)"
title="JSONでエクスポート"
>⭳</button>
<button <button
class="del-btn" class="del-btn"
@click.stop="store.removeSession(s.id)" @click.stop="store.removeSession(s.id)"
@@ -69,7 +97,13 @@ onMounted(load)
border-bottom: 1px solid #7f1d1d; border-bottom: 1px solid #7f1d1d;
cursor: default; cursor: default;
} }
.new-btn { .header-actions {
display: flex;
align-items: center;
gap: 6px;
}
.new-btn,
.import-btn {
background: #2a2a2a; background: #2a2a2a;
color: #ccc; color: #ccc;
border: 1px solid #444; border: 1px solid #444;
@@ -78,6 +112,9 @@ onMounted(load)
font-size: 12px; font-size: 12px;
cursor: pointer; cursor: pointer;
} }
.hidden-file-input {
display: none;
}
ul { ul {
list-style: none; list-style: none;
margin: 0; margin: 0;
@@ -104,6 +141,7 @@ li.active { background: #333; }
font-size: 13px; font-size: 13px;
color: #ddd; color: #ddd;
} }
.export-btn,
.del-btn { .del-btn {
background: none; background: none;
border: none; border: none;
@@ -114,5 +152,7 @@ li.active { background: #333; }
opacity: 0; opacity: 0;
transition: opacity 0.15s; transition: opacity 0.15s;
} }
.export-btn { font-size: 13px; }
li:hover .export-btn,
li:hover .del-btn { opacity: 1; } li:hover .del-btn { opacity: 1; }
</style> </style>
+33 -1
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { useNotesStore } from './notes'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { fetchSessions, fetchMessages, deleteSession, streamChat, type SessionInfo, type Source } from '../api/client' import { fetchSessions, fetchMessages, deleteSession, exportSession, importSession, streamChat, type SessionInfo, type Source, type ImportPayload } from '../api/client'
export interface Message { export interface Message {
role: 'user' | 'assistant' role: 'user' | 'assistant'
@@ -69,6 +70,32 @@ export const useChatStore = defineStore('chat', () => {
await loadSessions() await loadSessions()
} }
function downloadSessionLog(sessionId: string): void {
exportSession(sessionId)
}
async function importSessionFromFile(file: File): Promise<void> {
let payload: ImportPayload
try {
const parsed = JSON.parse(await file.text())
if (!Array.isArray(parsed?.messages)) {
throw new Error('messages 配列が見つかりません')
}
payload = parsed as ImportPayload
} catch (e) {
pushToast(`インポート失敗: JSONの読み込みに失敗しました (${e instanceof Error ? e.message : e})`)
return
}
try {
const session = await importSession(payload)
await loadSessions()
await selectSession(session.id)
pushToast(`「${session.title}」をインポートしました`)
} catch (e) {
pushToast(`インポート失敗: ${e instanceof Error ? e.message : e}`)
}
}
function sendMessage(text: string): void { function sendMessage(text: string): void {
if (isStreaming.value || !text.trim()) return if (isStreaming.value || !text.trim()) return
@@ -89,6 +116,9 @@ export const useChatStore = defineStore('chat', () => {
messages.value[msgIdx].content += chunk.value as string messages.value[msgIdx].content += chunk.value as string
} else if (chunk.type === 'sources' && chunk.value) { } else if (chunk.type === 'sources' && chunk.value) {
messages.value[msgIdx].sources = chunk.value as Source[] messages.value[msgIdx].sources = chunk.value as Source[]
} else if (chunk.type === 'notes_updated') {
// AIが共有ノートを書き換えたので、開いているノートパネルに反映する
useNotesStore().load().catch((e) => pushToast(`ノート更新失敗: ${e.message}`))
} else if (chunk.type === 'done') { } else if (chunk.type === 'done') {
messages.value[msgIdx].pending = false messages.value[msgIdx].pending = false
isStreaming.value = false isStreaming.value = false
@@ -137,6 +167,8 @@ export const useChatStore = defineStore('chat', () => {
loadSessions, loadSessions,
selectSession, selectSession,
removeSession, removeSession,
downloadSessionLog,
importSessionFromFile,
sendMessage, sendMessage,
retryMessage, retryMessage,
} }
+123
View File
@@ -0,0 +1,123 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import {
fetchNotePages,
createNotePage,
updateNotePage,
deleteNotePage,
type NotePage,
} from '../api/client'
const SAVE_DEBOUNCE_MS = 800
export const useNotesStore = defineStore('notes', () => {
const pages = ref<NotePage[]>([])
const activeId = ref<number | null>(null)
const error = ref<string | null>(null)
/** 未保存の編集があるページID。AIによる更新で上書きしないために使う。 */
const dirtyIds = ref<Set<number>>(new Set())
let saveTimer: number | null = null
const activePage = computed(() => pages.value.find((p) => p.id === activeId.value) ?? null)
async function load(): Promise<void> {
error.value = null
try {
const fetched = await fetchNotePages()
// 編集中のページはローカルの内容を優先し、サーバーの値で潰さない
pages.value = fetched.map((page) => {
if (!dirtyIds.value.has(page.id)) return page
const local = pages.value.find((p) => p.id === page.id)
return local ? { ...page, title: local.title, content: local.content } : page
})
if (activeId.value === null || !pages.value.some((p) => p.id === activeId.value)) {
activeId.value = pages.value[0]?.id ?? null
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'ノートの取得に失敗しました'
}
}
function selectPage(id: number): void {
flush()
activeId.value = id
}
async function addPage(title = '新しいページ'): Promise<void> {
error.value = null
try {
const page = await createNotePage(title)
pages.value.push(page)
activeId.value = page.id
} catch (e) {
error.value = e instanceof Error ? e.message : 'ページの作成に失敗しました'
}
}
async function removePage(id: number): Promise<void> {
error.value = null
try {
await deleteNotePage(id)
dirtyIds.value.delete(id)
pages.value = pages.value.filter((p) => p.id !== id)
if (activeId.value === id) activeId.value = pages.value[0]?.id ?? null
} catch (e) {
error.value = e instanceof Error ? e.message : 'ページの削除に失敗しました'
}
}
/** ローカルの内容を即座に更新し、少し待ってからサーバーへ保存する。 */
function edit(id: number, patch: { title?: string; content?: string }): void {
const page = pages.value.find((p) => p.id === id)
if (!page) return
Object.assign(page, patch)
dirtyIds.value.add(id)
if (saveTimer !== null) clearTimeout(saveTimer)
saveTimer = window.setTimeout(() => {
saveTimer = null
void save(id)
}, SAVE_DEBOUNCE_MS)
}
async function save(id: number): Promise<void> {
const page = pages.value.find((p) => p.id === id)
if (!page || !dirtyIds.value.has(id)) return
const { title, content } = page
try {
const saved = await updateNotePage(id, { title, content })
// 保存の往復中にさらに編集されていた場合は dirty のままにしておく
if (page.title === title && page.content === content) {
dirtyIds.value.delete(id)
page.updated_at = saved.updated_at
}
error.value = null
} catch (e) {
error.value = e instanceof Error ? e.message : 'ノートの保存に失敗しました'
}
}
/** 保留中の保存を直ちに実行する(タブ切替・パネルを閉じる時など)。 */
function flush(): void {
if (saveTimer !== null) {
clearTimeout(saveTimer)
saveTimer = null
}
for (const id of [...dirtyIds.value]) void save(id)
}
return {
pages,
activeId,
activePage,
error,
dirtyIds,
load,
selectPage,
addPage,
removePage,
edit,
flush,
}
})