Feature/shared notes #1
@@ -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 の署名鍵 |
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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
|
||||||
@@ -210,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"]}
|
||||||
@@ -225,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})
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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 を設定する。
|
||||||
|
|||||||
+26
-10
@@ -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,6 +163,12 @@ 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 = {}
|
||||||
|
|
||||||
|
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:
|
||||||
query = args.get("query", "")
|
query = args.get("query", "")
|
||||||
results = await search_web(query)
|
results = await search_web(query)
|
||||||
if results:
|
if results:
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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":"エラー内容"} # 例外発生時
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,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' })
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
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, exportSession, importSession, streamChat, type SessionInfo, type Source, type ImportPayload } from '../api/client'
|
import { fetchSessions, fetchMessages, deleteSession, exportSession, importSession, streamChat, type SessionInfo, type Source, type ImportPayload } from '../api/client'
|
||||||
|
|
||||||
@@ -115,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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user