diff --git a/CLAUDE.md b/CLAUDE.md index 0e9f546..94a45f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,8 +59,8 @@ Copy-Item .env.example .env frontend (Vue3 + Pinia, :5173) ↕ REST + SSE (Vite proxy) backend (FastAPI, :8000) - ├── app/api/ チャット・メモリ・ログ のルーター - ├── app/core/ LLM ラッパー・メモリロジック・設定 + ├── app/api/ チャット・メモリ・ノート・ログ のルーター + ├── app/core/ LLM ラッパー・メモリロジック・共有ノート・設定 └── app/db/ SQLAlchemy モデル・DB 接続 ↕ SQLite (playchat.db) + LiteLLM → 任意の LLM @@ -71,9 +71,19 @@ SQLite (playchat.db) + LiteLLM → 任意の LLM - **短期**: セッション内の全メッセージをコンテキストに含める - **長期**: メッセージ数が `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 ストリーミング -`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 経由で更新する。 ### Markdown レンダリング @@ -107,5 +117,6 @@ SQLite (playchat.db) + LiteLLM → 任意の LLM | `OPENAI_API_KEY` | — | OpenAI API キー | | `ANTHROPIC_API_KEY` | — | Anthropic API キー | | `OLLAMA_API_BASE` | `http://localhost:11434` | Ollama エンドポイント | +| `NOTES_TOOL_CONTENT_LIMIT` | `4000` | `read_note` が 1 回に返す本文の最大文字数 | | `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) | | `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 | diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index 0b6902c..83829ec 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -2,16 +2,17 @@ import asyncio import uuid from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import StreamingResponse +from fastapi.responses import StreamingResponse, JSONResponse from pydantic import BaseModel from sqlalchemy.orm import Session as DBSession 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 import llm from app.core.memory_kb import 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.config import DEFAULT_MODEL from app.core.memory_kb.tagging import tag_message_by_id @@ -47,6 +48,25 @@ class MessageResponse(BaseModel): 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]) def list_sessions(db: DBSession = Depends(get_db)): 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) def delete_session(session_id: str, db: DBSession = Depends(get_db)): 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 full_response = [] try: - resolved_context, searches = await llm.resolve_web_search( - context, model=used_model, session_id=session.id, + resolved_context, searches, notes_changed = await llm.resolve_tool_calls( + context, model=used_model, session_id=session.id, db=db, ) + if notes_changed: + # AIがノートを書き換えたので、フロントのノートパネルを再読み込みさせる + yield sse({"type": "notes_updated"}) if searches: sources = [ {"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"]) 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( - 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) yield sse({"type": "chunk", "value": chunk}) diff --git a/backend/app/api/notes.py b/backend/app/api/notes.py new file mode 100644 index 0000000..998cf00 --- /dev/null +++ b/backend/app/api/notes.py @@ -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() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ad50edc..eeb6a79 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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_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 # 以下はすべて LiteLLM(boto3) が os.environ から直接読み取るため、.env に書くだけで自動的に有効になる。 # IAM経由(推奨): AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY を設定する。 diff --git a/backend/app/core/llm.py b/backend/app/core/llm.py index a58440c..3852ba9 100644 --- a/backend/app/core/llm.py +++ b/backend/app/core/llm.py @@ -2,13 +2,17 @@ import json import logging import time import traceback -from typing import AsyncIterator, AsyncGenerator +from typing import TYPE_CHECKING, AsyncIterator, AsyncGenerator from contextlib import asynccontextmanager import litellm 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 +if TYPE_CHECKING: + from sqlalchemy.orm import Session as DBSession + litellm.drop_params = True if 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] -async def resolve_web_search( +async def resolve_tool_calls( messages: list[dict], model: str | None = None, session_id: str | None = None, + db: "DBSession | None" = None, max_rounds: int = 2, -) -> tuple[list[dict], list[dict]]: - """必要に応じてLLMにweb_searchツールを呼ばせ、検索結果を会話に組み込んだ上でメッセージ列を返す。 +) -> tuple[list[dict], list[dict], bool]: + """必要に応じてLLMにツールを呼ばせ、その結果を会話に組み込んだ上でメッセージ列を返す。 - モデルがtool callingに対応していない場合は何もせず元のmessagesをそのまま返す。 - 戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト) + 扱うツールは web_search と共有ノート(read_note / write_note)。ノート系は db が + 渡されたときだけ提示する。モデルがtool callingに対応していない場合は何もせず + 元のmessagesをそのまま返す。 + 戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト, ノートを変更したか) """ used_model = model or DEFAULT_MODEL working_messages = list(messages) searches: list[dict] = [] + notes_changed = False + + tools = [WEB_SEARCH_TOOL] + (NOTE_TOOLS if db is not None else []) for _ in range(max_rounds): try: response = await litellm.acompletion( model=used_model, messages=working_messages, - tools=[WEB_SEARCH_TOOL], + tools=tools, tool_choice="auto", ) except Exception: - logger.info("Model %s does not support tool calling; skipping web search", used_model) - return messages, searches + logger.info("Model %s does not support tool calling; skipping tools", used_model) + return messages, searches, notes_changed choice_message = response.choices[0].message tool_calls = getattr(choice_message, "tool_calls", None) @@ -153,13 +163,19 @@ async def resolve_web_search( args = json.loads(tc.function.arguments or "{}") except json.JSONDecodeError: args = {} - 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}) + + name = tc.function.name + if name in ("read_note", "write_note") and db is not None: + tool_text, changed = execute_note_tool(db, name, args) + notes_changed = notes_changed or changed 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({ "role": "tool", @@ -167,4 +183,4 @@ async def resolve_web_search( "content": tool_text, }) - return working_messages, searches + return working_messages, searches, notes_changed diff --git a/backend/app/core/memory_kb/memory.py b/backend/app/core/memory_kb/memory.py index 065ce68..fca300a 100644 --- a/backend/app/core/memory_kb/memory.py +++ b/backend/app/core/memory_kb/memory.py @@ -5,6 +5,7 @@ from app.core import llm from app.core.memory_kb import knowledge from app.core.memory_kb import local_knowledge 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]: @@ -18,6 +19,11 @@ async def get_context_messages(db: DBSession, session_id: str) -> list[dict]: if 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: 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 new file mode 100644 index 0000000..4db7e2c --- /dev/null +++ b/backend/app/core/notes.py @@ -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 diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 6d64534..390fcb6 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -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)) +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): """GUIの設定画面から編集する接続情報(APIキー等)。app.core.settings_store が読み書きする。""" __tablename__ = "app_settings" diff --git a/backend/app/main.py b/backend/app/main.py index 1bde96c..07b5116 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,7 +5,7 @@ from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware 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 settings as settings_api from app.core import settings_store @@ -93,6 +93,7 @@ app.add_middleware( app.include_router(auth_api.router) app.include_router(chat.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(system.router, dependencies=[Depends(require_site_session)]) app.include_router(settings_api.router, dependencies=[Depends(require_site_session)]) diff --git a/docs/api.md b/docs/api.md index 807ea10..0bd7ca2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -24,6 +24,7 @@ SSE でストリーミング応答を返す。 |---|---|---| | `session_id` | セッション UUID | 新規セッション作成直後 | | `chunk` | テキスト断片 | LLM トークン生成ごと | +| `notes_updated` | — | AI が共有ノートを書き換えた直後 | | `done` | — | 正常完了 | | `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 diff --git a/docs/architecture.md b/docs/architecture.md index 0a280c5..8749c32 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,6 +43,7 @@ backend/ | `sessions` | id (UUID), title, model, created_at, updated_at | チャットセッション | | `messages` | id, session_id, role, content, 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 呼び出しログ | ### メモリ機構(`core/memory.py`) @@ -57,6 +58,15 @@ backend/ - `_log_context` で呼び出しを囲み、成功・失敗どちらも `api_logs` テーブルに記録 - `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`) `POST /chat/stream` は `StreamingResponse` で以下のイベントを順番に送出する: @@ -64,6 +74,7 @@ backend/ ``` data: {"type":"session_id","value":""} # 新規セッション時のみ data: {"type":"chunk","value":"テキスト断片"} # 0回以上 +data: {"type":"notes_updated"} # AIが共有ノートを更新した時のみ data: {"type":"done"} # 正常完了 data: {"type":"error","value":"エラー内容"} # 例外発生時 ``` diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 7ad8974..e58b925 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -6,6 +6,7 @@ import MessageInput from './components/MessageInput.vue' import ModelSelector from './components/ModelSelector.vue' import LogViewer from './components/LogViewer.vue' import SettingsPanel from './components/SettingsPanel.vue' +import NotePanel from './components/NotePanel.vue' import ToastList from './components/ToastList.vue' import MemoryMonitor from './components/MemoryMonitor.vue' import Clock from './components/Clock.vue' @@ -15,7 +16,7 @@ import { useAuthStore } from './stores/auth' const authStore = useAuthStore() -type SidePanel = 'none' | 'logs' | 'settings' +type SidePanel = 'none' | 'logs' | 'settings' | 'notes' const activePanel = ref('none') function togglePanel(panel: SidePanel) { @@ -50,6 +51,9 @@ onUnmounted(() => { PlayChat + @@ -71,6 +75,9 @@ onUnmounted(() => {
+
+ +
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 64d8457..670d253 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -22,7 +22,7 @@ export interface Source { } export interface SSEChunk { - type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources' + type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources' | 'notes_updated' value?: string | Source[] } @@ -63,6 +63,39 @@ export async function deleteSession(sessionId: string): Promise { 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 { + const res = await fetch(`${BASE}/chat/sessions/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + return parseJson(res, 'インポート') +} + export async function fetchLogs(params: { session_id?: string status?: 'ok' | 'error' @@ -224,3 +257,41 @@ export function streamChat( return () => controller.abort() } + +export interface NotePage { + id: number + title: string + content: string + position: number + updated_at: string +} + +export async function fetchNotePages(): Promise { + const res = await fetch(`${BASE}/api/notes`) + return parseJson(res, 'ノート取得') +} + +export async function createNotePage(title: string): Promise { + const res = await fetch(`${BASE}/api/notes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title }), + }) + return parseJson(res, 'ページ作成') +} + +export async function updateNotePage( + pageId: number, + patch: { title?: string; content?: string }, +): Promise { + const res = await fetch(`${BASE}/api/notes/${pageId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }) + return parseJson(res, 'ページ更新') +} + +export async function deleteNotePage(pageId: number): Promise { + await fetch(`${BASE}/api/notes/${pageId}`, { method: 'DELETE' }) +} diff --git a/frontend/src/components/NotePanel.vue b/frontend/src/components/NotePanel.vue new file mode 100644 index 0000000..c07efc3 --- /dev/null +++ b/frontend/src/components/NotePanel.vue @@ -0,0 +1,197 @@ + + +