Author SHA1 Message Date
colcaroliandClaude Opus 5 8b2231ea97 Add drag-and-drop reordering for note tabs, and a user-facing guide
Tabs can now be dragged to reorder within a group and to move between
sections in one gesture: dropping on a tab's left/right half inserts
before/after it and adopts that tab's section, while a dedicated zone
past the last group ungroups and appends at the end. A blue indicator
line shows the drop position while dragging.

core/notes.reorder() takes the full new page-id order computed
client-side (NotePanel.vue has the reactive list already) rather than
recomputing positions server-side, validates it against the current
page-id set, sets the moved page's section, then renumbers position.
POST /api/notes/reorder exposes it.

Also adds docs/notes-guide.md, an end-user walkthrough of the shared
notes panel (pages, sections, drag-and-drop, AI interaction) — the
existing docs were all developer-facing specs with no operating
instructions for the feature itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:27:38 +09:00
colcaroliandClaude Opus 5 1c21fc3406 Add section grouping to shared note tabs
Notes can now be clustered into named sections in the tab bar: each tab
gets a 🏷 button to assign it to a section, and clicking a section's
label renames (or, given an empty name, ungroups) every page in it.

note_pages gains a `section` column. Since the app has no migration
framework, database.py adds the column to existing SQLite files at
startup via PRAGMA table_info + ALTER TABLE. core/notes.set_section()
keeps same-section pages contiguous by renumbering position whenever a
page's section changes, so the tab bar and the AI-facing title list
(now grouped under "## section" headings) can both treat "adjacent" as
"same group" without a separate ordering field.

Also fixes a bug in the tab rename input: ref="renameInput" sits inside
a nested v-for, which Vue 3 binds as an array rather than a single
element, so renameInput.value?.select() was throwing on every rename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:28:36 +09:00
colcaroli b1350eb8bc Merge pull request 'Feature/shared notes' (#1) from feature/shared-notes into main
Reviewed-on: #1
2026-08-18 16:29:56 +00:00
11 changed files with 630 additions and 68 deletions
+33 -7
View File
@@ -59,17 +59,34 @@ 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 接続 │ ├── memory_kb/ コンテキスト管理(要約・蓄積知識・ローカルKB・タグ付け)
│ ├── search/ Tavily Web 検索
│ └── discord/ Discord Bot・定期通知
└── app/db/ SQLAlchemy モデル・DB 接続
↕ ↕
SQLite (playchat.db) + LiteLLM → 任意の LLM SQLite (playchat.db) + LiteLLM → 任意の LLM
``` ```
### メモリ機構 ### コンテキスト管理
- **短期**: セッション内の全メッセージをコンテキストに含める LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_context_messages()` が毎リクエスト組み立てる。
- **長期**: メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(デフォルト 20)を超えると古い半分を LLM で要約 → `memories` テーブルに保存 → 次回以降のシステムプロンプトに注入 以下の順に system メッセージを積み、最後に会話履歴を連結する(詳細は `docs/architecture.md`)。
1. **蓄積知識** (`memory_kb/knowledge.py`): 過去の Web 検索で得た `knowledge_entries` の要約全文+出典。最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件。セッションを横断する
2. **共有ノート** (`core/notes.py`): タイトル一覧+本文先頭 40 文字のプレビューのみ
3. **ローカル KB** (`memory_kb/local_knowledge.py`): `backend/knowledge_base/**.md` のうち、直近ユーザー発言と埋め込みコサイン類似度が高い上位 `LOCAL_KB_TOP_K`(既定 3)件かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上を**全文**注入。プロンプト上は Web 検索結果より優先する扱い
4. **長期メモリ**: `memories` の要約を全件連結。上限なし
5. **短期メモリ**: セッション内の `messages` 全件(要約で削除された分を除く)
**要約**: `maybe_summarize()` がアシスタント応答の保存直後に走り、メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)以上になると古い半分を LLM で要約して `memories` に保存する。**要約した元メッセージは物理削除される**(復元不可)。
**遅延読み込み**: 重い情報は目次だけ注入し、`llm.resolve_tool_calls()` が本番生成の前にツール(`web_search` / `read_note` / `write_note`、最大 2 ラウンド)を解決する。Web 検索の結果は構造化要約にされ `knowledge_entries` と `knowledge_base/web_search/*.md` の両方に保存されるため、次回以降は層 1・層 3 として自動的に効いてくる。
**付随機能**: `memory_kb/tagging.py` が各メッセージ・要約にバックグラウンドでタグを 3 つ付ける(コンテキストには注入せず、Discord 雑談 Bot の話題選びに使う)。`memory_kb/word_memory.py` は Janome で抽出した名詞を `deque(maxlen=200)` にプロセス内保持する(DB 非永続。起動時に直近 200 件から再シード)。
**変更時の注意**: トークン数ベースの制御は一切なく、すべて件数・文字数ベース。またツールを使わない会話でも `resolve_tool_calls()` がフルコンテキストを 1 回投げるため、1 応答あたり LLM 呼び出しは最低 2 回になる。
### 共有ノート ### 共有ノート
@@ -80,6 +97,7 @@ SQLite (playchat.db) + LiteLLM → 任意の LLM
- **ツール実行の流れ**: `llm.resolve_tool_calls()` が本番の応答生成前にツール呼び出しを解決する。`web_search` と共通の仕組みで、ノート系ツールは `db` が渡されたときだけモデルに提示される - **ツール実行の流れ**: `llm.resolve_tool_calls()` が本番の応答生成前にツール呼び出しを解決する。`web_search` と共通の仕組みで、ノート系ツールは `db` が渡されたときだけモデルに提示される
- **UI への反映**: AI がノートを書き換えた場合、SSE で `notes_updated` を送ってフロント側のノートパネルを再読み込みさせる - **UI への反映**: AI がノートを書き換えた場合、SSE で `notes_updated` を送ってフロント側のノートパネルを再読み込みさせる
- **編集の衝突回避**: フロントは未保存の編集があるページ(`dirtyIds`)をサーバーの値で上書きしない - **編集の衝突回避**: フロントは未保存の編集があるページ(`dirtyIds`)をサーバーの値で上書きしない
- **セクションによるグルーピング**: `note_pages.section` にセクション名を持たせ、同じセクションのページは `position` 上で常に連続させる(`core/notes.set_section()` が割り当て時に並べ替える)。タブバーは `position` 順に描画し、隣接して同じ `section` が続く区間を1グループとして表示する。`get_notes_context()` はセクションごとに `## セクション名` の見出しを付けてタイトル一覧を出す。セクション名の一括変更は `POST /api/notes/sections/rename`(`core/notes.rename_section()`)
### SSE ストリーミング ### SSE ストリーミング
@@ -109,9 +127,17 @@ SQLite (playchat.db) + LiteLLM → 任意の LLM
| 変数 | デフォルト | 説明 | | 変数 | デフォルト | 説明 |
|---|---|---| |---|---|---|
| `DEFAULT_MODEL` | `gpt-5.4` | デフォルト LLM モデル | | `DEFAULT_MODEL` | `gpt-4o` | デフォルト LLM モデル(`.env.example` では `gpt-5.4`) |
| `DATABASE_URL` | `sqlite:///./playchat.db` | DB 接続先 | | `DATABASE_URL` | `sqlite:///./playchat.db` | DB 接続先 |
| `MEMORY_SUMMARIZE_THRESHOLD` | `20` | 長期メモリ化するメッセージ数の閾値 | | `MEMORY_SUMMARIZE_THRESHOLD` | `20` | 長期メモリ化するメッセージ数の閾値 |
| `KNOWLEDGE_CONTEXT_LIMIT` | `15` | 蓄積知識をシステムプロンプトに注入する最大件数 |
| `TAVILY_API_KEY` | — | Tavily Web 検索の API キー(未設定なら `web_search` は空を返す) |
| `SEARCH_SUMMARY_MODEL` | — | Web 検索結果の要約に使うモデル(未設定なら `DEFAULT_MODEL`) |
| `TAGGING_MODEL` | — | メッセージの自動タグ付けに使うモデル(未設定なら `DEFAULT_MODEL`) |
| `LOCAL_KB_DIR` | `knowledge_base` | ローカル Markdown ナレッジベースのディレクトリ |
| `LOCAL_KB_EMBEDDING_MODEL` | `text-embedding-3-small` | ローカル KB の埋め込みモデル(GUI 設定画面から実行時変更可) |
| `LOCAL_KB_TOP_K` | `3` | ローカル KB から注入する最大ファイル数 |
| `LOCAL_KB_MIN_SCORE` | `0.75` | ローカル KB を注入するコサイン類似度の下限 |
| `CORS_ORIGINS` | `http://localhost:5173` | 許可する CORS オリジン | | `CORS_ORIGINS` | `http://localhost:5173` | 許可する CORS オリジン |
| `DEBUG` | `false` | LiteLLM デバッグログの有効化 | | `DEBUG` | `false` | LiteLLM デバッグログの有効化 |
| `OPENAI_API_KEY` | — | OpenAI API キー | | `OPENAI_API_KEY` | — | OpenAI API キー |
+38 -1
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session as DBSession from sqlalchemy.orm import Session as DBSession
from app.core.notes import next_position from app.core.notes import next_position, reorder, rename_section, set_section
from app.db.database import get_db from app.db.database import get_db
from app.db.models import NotePage from app.db.models import NotePage
@@ -16,6 +16,7 @@ class NotePageResponse(BaseModel):
title: str title: str
content: str content: str
position: int position: int
section: str | None
updated_at: datetime updated_at: datetime
class Config: class Config:
@@ -25,11 +26,24 @@ class NotePageResponse(BaseModel):
class NotePageCreate(BaseModel): class NotePageCreate(BaseModel):
title: str = "新しいページ" title: str = "新しいページ"
content: str = "" content: str = ""
section: str | None = None
class NotePageUpdate(BaseModel): class NotePageUpdate(BaseModel):
title: str | None = None title: str | None = None
content: str | None = None content: str | None = None
section: str | None = None
class SectionRenameRequest(BaseModel):
old_name: str
new_name: str | None = None
class ReorderRequest(BaseModel):
page_id: int
section: str | None = None
order: list[int]
@router.get("", response_model=list[NotePageResponse]) @router.get("", response_model=list[NotePageResponse])
@@ -44,9 +58,27 @@ def create_page(req: NotePageCreate, db: DBSession = Depends(get_db)):
db.add(page) db.add(page)
db.commit() db.commit()
db.refresh(page) db.refresh(page)
if req.section is not None and req.section.strip():
page = set_section(db, page.id, req.section)
return page return page
@router.post("/sections/rename")
def rename_section_endpoint(req: SectionRenameRequest, db: DBSession = Depends(get_db)):
"""old_name セクションに属する全ページを new_name セクションへ一括で付け替える。new_name が空ならグループ解除。"""
count = rename_section(db, req.old_name, req.new_name)
return {"renamed": count}
@router.post("/reorder")
def reorder_pages(req: ReorderRequest, db: DBSession = Depends(get_db)):
"""タブのドラッグ&ドロップによる並べ替え。order は全ページIDを新しい順序で並べたもの。"""
ok = reorder(db, req.page_id, req.section, req.order)
if not ok:
raise HTTPException(status_code=400, detail="order must contain exactly the current page ids")
return {"reordered": len(req.order)}
@router.patch("/{page_id}", response_model=NotePageResponse) @router.patch("/{page_id}", response_model=NotePageResponse)
def update_page(page_id: int, req: NotePageUpdate, db: DBSession = Depends(get_db)): def update_page(page_id: int, req: NotePageUpdate, db: DBSession = Depends(get_db)):
page = db.get(NotePage, page_id) page = db.get(NotePage, page_id)
@@ -61,6 +93,11 @@ def update_page(page_id: int, req: NotePageUpdate, db: DBSession = Depends(get_d
page.content = req.content page.content = req.content
db.commit() db.commit()
db.refresh(page) db.refresh(page)
# section はページの並び順にも影響するため、変更があれば専用ロジックで反映する
if "section" in req.model_fields_set:
page = set_section(db, page_id, req.section)
return page return page
+71 -2
View File
@@ -75,17 +75,86 @@ def next_position(db: DBSession) -> int:
return (last.position + 1) if last else 0 return (last.position + 1) if last else 0
def set_section(db: DBSession, page_id: int, section: str | None) -> NotePage | None:
"""ページの所属セクションを変更し、同じセクションのページ群の直後に連続配置し直す。
タブバーはposition順に描画され、隣接して同じsectionが続く区間を1つのグループとして
まとめて表示する(app.api.notes / フロントのグルーピング表示を参照)。そのため
セクションを変更したページは、常に同じセクションの最後のページの直後に移動する。
"""
page = db.get(NotePage, page_id)
if not page:
return None
normalized = (section or "").strip() or None
ordered = [p for p in db.query(NotePage).order_by(NotePage.position, NotePage.id).all() if p.id != page_id]
if normalized is None:
insert_at = len(ordered)
else:
matching = [i for i, p in enumerate(ordered) if p.section == normalized]
insert_at = (matching[-1] + 1) if matching else len(ordered)
ordered.insert(insert_at, page)
page.section = normalized
for i, p in enumerate(ordered):
p.position = i
db.commit()
db.refresh(page)
return page
def reorder(db: DBSession, page_id: int, section: str | None, order: list[int]) -> bool:
"""タブのドラッグ&ドロップによる並べ替えを反映する。
order にはドラッグ後の全ページIDを新しい順序で渡す(フロント側で計算する)。
page_id のページには先に section を設定し、その後 order の順に position を振り直す。
こうすることで、別セクションのタブの前後にドロップした場合はそのセクションへの
移動を兼ね、同じセクション内でのドロップなら単純な並べ替えになる。
order が現在の全ページIDの集合と一致しない場合は何もせず False を返す。
"""
pages = {p.id: p for p in db.query(NotePage).all()}
if page_id not in pages or set(order) != set(pages.keys()):
return False
pages[page_id].section = (section or "").strip() or None
for i, pid in enumerate(order):
pages[pid].position = i
db.commit()
return True
def rename_section(db: DBSession, old_name: str, new_name: str | None) -> int:
"""あるセクションに属する全ページのセクション名を一括で変更する。new_name が空ならグループ解除になる。
戻り値: 変更したページ数。
"""
normalized_new = (new_name or "").strip() or None
pages = db.query(NotePage).filter(NotePage.section == old_name).order_by(NotePage.position, NotePage.id).all()
for page in pages:
page.section = normalized_new
db.commit()
return len(pages)
def get_notes_context(db: DBSession) -> str | None: def get_notes_context(db: DBSession) -> str | None:
"""共有ノートのタイトル一覧をシステムプロンプト用のテキストとして返す。""" """共有ノートのタイトル一覧をシステムプロンプト用のテキストとして返す。セクションごとに見出しを付ける。"""
pages = db.query(NotePage).order_by(NotePage.position, NotePage.id).all() pages = db.query(NotePage).order_by(NotePage.position, NotePage.id).all()
lines = [ lines = [
"【共有ノート】", "【共有ノート】",
"ユーザーとあなたが共同で編集できるノートです。以下のページがあります。", "ユーザーとあなたが共同で編集できるノートです。以下のページがあります"
"(セクションはページをグループ化する見出しで、あれば併記します)。",
"", "",
] ]
if pages: if pages:
current_section = "__unset__" # どのページのsectionとも一致しない番兵
for page in pages: for page in pages:
if page.section != current_section:
current_section = page.section
if current_section:
lines.append(f"## {current_section}")
preview = " ".join(page.content.split())[:40] preview = " ".join(page.content.split())[:40]
suffix = f" — {preview}…" if preview else " — (空)" suffix = f" — {preview}…" if preview else " — (空)"
lines.append(f"- {page.title}{suffix}") lines.append(f"- {page.title}{suffix}")
+12 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import create_engine from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker, DeclarativeBase from sqlalchemy.orm import sessionmaker, DeclarativeBase
from app.core.config import DATABASE_URL from app.core.config import DATABASE_URL
@@ -18,6 +18,17 @@ def get_db():
db.close() db.close()
def _add_missing_columns() -> None:
"""マイグレーションフレームワークを使っていないため、既存DBに後から追加されたカラムを
ここで簡易的に補う。create_all() は既存テーブルへのカラム追加は行わないので必要。"""
with engine.connect() as conn:
existing = {row[1] for row in conn.execute(text("PRAGMA table_info(note_pages)"))}
if existing and "section" not in existing:
conn.execute(text("ALTER TABLE note_pages ADD COLUMN section VARCHAR"))
conn.commit()
def init_db(): def init_db():
from app.db import models # noqa: F401 from app.db import models # noqa: F401
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_add_missing_columns()
+2 -1
View File
@@ -115,7 +115,8 @@ class NotePage(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String) title: Mapped[str] = mapped_column(String)
content: Mapped[str] = mapped_column(Text, default="") content: Mapped[str] = mapped_column(Text, default="")
position: Mapped[int] = mapped_column(Integer, default=0) # タブの並び順 position: Mapped[int] = mapped_column(Integer, default=0) # タブの並び順(セクションでまとめる際、同じセクションのページは連続させる)
section: Mapped[str | None] = mapped_column(String, nullable=True) # タブをグループ化するセクション名。未設定なら未分類
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) 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)) updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
+48 -4
View File
@@ -93,9 +93,10 @@ SSE でストリーミング応答を返す。
ユーザーと AI が共同編集するノート。ページのタイトル一覧は毎回システムプロンプトに注入され、 ユーザーと AI が共同編集するノート。ページのタイトル一覧は毎回システムプロンプトに注入され、
本文は AI が `read_note` / `write_note` ツールで読み書きする。 本文は AI が `read_note` / `write_note` ツールで読み書きする。
`section` を設定すると、同じセクション名のページはタブバー上で常に連続してグループ表示される。
### GET /api/notes ### GET /api/notes
全ページを `position` 昇順で返す。 全ページを `position` 昇順で返す。同じ `section` のページは常に連続する(隣接=同一グループとして扱われる)。
**レスポンス** **レスポンス**
```json ```json
@@ -106,6 +107,7 @@ SSE でストリーミング応答を返す。
"content": "- 牛乳 "content": "- 牛乳
- 卵", - 卵",
"position": 0, "position": 0,
"section": "生活",
"updated_at": "2026-08-19T12:00:00" "updated_at": "2026-08-19T12:00:00"
} }
] ]
@@ -115,21 +117,63 @@ SSE でストリーミング応答を返す。
### POST /api/notes ### POST /api/notes
ページを新規作成し、`201 Created` で作成したページを返す。 ページを新規作成し、`201 Created` で作成したページを返す。
`section` を指定すると、作成直後にそのセクションの末尾へ配置される(他ページの `position` も再採番される)。
**リクエスト** **リクエスト**
```json ```json
{ "title": "新しいページ", "content": "" } { "title": "新しいページ", "content": "", "section": null }
``` ```
--- ---
### PATCH /api/notes/{page_id} ### PATCH /api/notes/{page_id}
ページのタイトル・本文を更新する。指定したフィールドだけが変更される。 ページのタイトル・本文・セクションを更新する。指定したフィールドだけが変更される。
タイトルを空文字にしようとした場合は `400`、ページが存在しない場合は `404` を返す。 タイトルを空文字にしようとした場合は `400`、ページが存在しない場合は `404` を返す。
`section` はリクエストボディに**キーとして含まれているかどうか**で判定する(`null` を明示的に送るとグループ解除)。
セクションを変更すると、そのページは新しいセクションの末尾(該当ページが無ければ末尾)へ移動し、
全ページの `position` が振り直される。
**リクエスト** **リクエスト**
```json ```json
{ "content": "更新後の本文" } { "content": "更新後の本文", "section": "生活" }
```
---
### POST /api/notes/sections/rename
`old_name` セクションに属する全ページを `new_name` セクションへ一括で付け替える。
`new_name` を空文字/未指定にすると、そのセクションの全ページのグループを解除する。
ページの並び順(`position`)は変更しない。
**リクエスト**
```json
{ "old_name": "生活", "new_name": "生活雑貨" }
```
**レスポンス**
```json
{ "renamed": 2 }
```
---
### POST /api/notes/reorder
タブのドラッグ&ドロップによる並べ替えを反映する。`order` には**全ページID**をドラッグ後の
新しい順序で渡す(サーバー側では計算しない。フロントが `store.pages` から算出する)。
`page_id` のページには先に `section` を設定してから、`order` の順に `position` を振り直す。
別セクションのタブの前後にドロップした場合は、そのセクションへの移動も兼ねる。
`order` が現在の全ページIDの集合と一致しない場合は `400` を返す。
**リクエスト**
```json
{ "page_id": 3, "section": "生活", "order": [1, 3, 2, 4] }
```
**レスポンス**
```json
{ "reordered": 4 }
``` ```
--- ---
+92 -19
View File
@@ -21,18 +21,35 @@
``` ```
backend/ backend/
├── app/ ├── app/
│ ├── main.py # FastAPI インスタンス、CORS、ルーター登録、DB初期化 │ ├── main.py # FastAPI インスタンス、CORS、ルーター登録、DB初期化、起動時シード
│ ├── api/ │ ├── api/
│ │ ├── chat.py # チャット・セッション関連エンドポイント │ │ ├── chat.py # チャット・セッション関連エンドポイント(SSE / export / import)
│ │ ├── memory.py # 長期メモリ参照・削除 │ │ ├── memory.py # 長期メモリ参照・削除
│ │ └── logs.py # API ログ参照・削除 │ │ ├── notes.py # 共有ノート CRUD
│ │ ├── models.py # モデルプリセット CRUD
│ │ ├── settings.py # GUI 設定画面(APIキー等)
│ │ ├── system.py # タグ backfill・ワードプール参照などの運用系
│ │ ├── auth.py # サイトログイン
│ │ └── logs.py # API ログ参照・削除
│ ├── core/ │ ├── core/
│ │ ├── config.py # .env 読み込み・定数定義 │ │ ├── config.py # .env 読み込み・定数定義
│ │ ├── llm.py # LiteLLM ラッパー(ストリーミング・ログ記録) │ │ ├── llm.py # LiteLLM ラッパー(ストリーミング・埋め込み・ツール解決・ログ記録)
│ │ └── memory.py # メモリ読み書きロジック(要約・コンテキスト生成) │ │ ├── notes.py # 共有ノート(タイトル一覧の注入 / read_note・write_note ツール)
│ │ ├── memory_kb/ # コンテキスト管理まわり一式
│ │ │ ├── memory.py # コンテキスト合成・要約(旧 core/memory.py)
│ │ │ ├── knowledge.py # Web検索で得た知識の蓄積・要約・再生成
│ │ │ ├── local_knowledge.py # ローカル Markdown KB の埋め込み・類似検索
│ │ │ ├── tagging.py # メッセージ・要約への自動タグ付け
│ │ │ └── word_memory.py # 形態素解析による単語プール(Discord Bot 用)
│ │ ├── search/
│ │ │ └── websearch.py # Tavily による web_search ツール
│ │ ├── discord/ # Discord Bot・定期通知
│ │ ├── settings_store.py # GUI 設定を os.environ へ反映
│ │ └── auth.py / site_auth.py # 設定画面パスワード・サイトログイン
│ └── db/ │ └── db/
│ ├── database.py # SQLite 接続・セッションファクトリ・テーブル自動作成 │ ├── database.py # SQLite 接続・セッションファクトリ・テーブル自動作成
│ └── models.py # SQLAlchemy モデル定義 │ └── models.py # SQLAlchemy モデル定義
├── knowledge_base/ # ローカルナレッジベース(Markdown。web_search/ 配下は自動生成)
└── pyproject.toml └── pyproject.toml
``` ```
@@ -42,30 +59,85 @@ 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 | 会話メッセージ(短期メモリ) |
| `message_tags` | id, message_id, tag, 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 が共同編集する共有ノートのページ | | `memory_tags` | id, memory_id, tag, created_at | 要約への自動タグ |
| `knowledge_entries` | id, query, summary, created_at | Web 検索で得た知識(セッション横断) |
| `knowledge_sources` | id, entry_id, title, url | 上記知識の出典 URL |
| `local_knowledge_docs` | id, path, title, content_hash, embedding_json, updated_at | ローカル Markdown KB の埋め込みキャッシュ |
| `note_pages` | id, title, content, position, section, created_at, updated_at | ユーザーと AI が共同編集する共有ノートのページ(`section` でタブをグループ化) |
| `app_settings` | key, value, updated_at | GUI 設定画面から編集した接続情報 |
| `model_presets` | id, label, value, created_at | モデル選択プルダウンの選択肢 |
| `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_kb/`)
1. **短期メモリ**: セッション内の全 `messages` をコンテキストとして送信 LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messages()` が毎リクエスト組み立てる。
2. **長期メモリ**: メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(デフォルト 20)を超えると、古い半分を LLM で要約して `memories` に保存しメッセージを削除 以下の順に system メッセージを積み、最後に会話履歴を連結する。
3. **コンテキスト合成**: `get_context_messages()` が `memories` の要約をシステムメッセージとして先頭に付加し、残存メッセージと結合して返す
| # | 層 | 中身 | 実装 | 量の制御 |
|---|---|---|---|---|
| 1 | 蓄積知識 | 過去の Web 検索で得た `knowledge_entries` の**要約全文**+出典 URL | `knowledge.get_knowledge_context()` | 最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件 |
| 2 | 共有ノート | ページの**タイトル一覧+本文先頭 40 文字**のみ | `notes.get_notes_context()` | 全ページ(本文は入れない) |
| 3 | ローカル KB | `knowledge_base/**.md` から選ばれた**ファイル全文** | `local_knowledge.get_local_kb_context()` | 直近ユーザー発言との cos 類似度上位 `LOCAL_KB_TOP_K`(既定 3)件、かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上 |
| 4 | 長期メモリ | `memories` の要約を全件連結 | `memory.get_context_messages()` | 上限なし |
| 5 | 短期メモリ | セッション内の `messages` 全件(要約済みで削除された分を除く) | 同上 | 上限なし(要約で間接的に制御) |
層 3 のプロンプトは「Web 検索結果より優先度が高い」「これで答えられるなら検索するな」と明示しており、層 1 より優先される。
#### 要約(長期メモリ化)
`maybe_summarize()` がアシスタント応答の保存直後に走る。
1. メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)以上になったら発火
2. 古い側の半分(既定 10 件)を LLM で要約し `memories` に 1 行保存
3. **要約した元メッセージは DB から物理削除**する(復元不可。エクスポートしても生ログは残らない)
4. 保存した要約に `tagging.tag_memory()` でタグを付ける
#### ツール経由の遅延読み込み
常時注入すると重い情報は、システムプロンプトに目次だけ置いてツールで取りに行かせる。
`llm.resolve_tool_calls()` が本番のストリーミング生成の**前に**別途 LLM を呼んでツール呼び出しを解決する(最大 2 ラウンド)。
- `read_note` / `write_note` … 共有ノート本文の読み書き(`db` を渡したときだけモデルに提示)
- `web_search` … Tavily 検索。結果は `knowledge.summarize_results()` で構造化要約にされ、
`knowledge_entries` と `knowledge_base/web_search/*.md` の両方に保存される。
→ 次回以降の層 1・層 3 に自動的に効いてくる
#### 自動タグ付け・ワードプール
- `tagging.py` … 各メッセージに `asyncio.create_task()` でバックグラウンドにタグを 3 つ生成する。
コンテキスト注入には使わず、Discord 雑談 Bot の話題選び(`find_related_tag()`)に使う
- `word_memory.py` … Janome で名詞を抽出し `deque(maxlen=200)` にプロセス内保持する。
**DB に永続化されず再起動で消える**(起動時に直近 200 件のメッセージから再シードされる)
#### 設計上の注意点
- トークン数ベースの制御は無く、すべて件数・文字数ベース。層 1(知識 15 件の要約全文)と層 3(Markdown 全文 ×3)は
会話が短くてもコンテキストを大きく押し上げる
- ツールを使わない会話でも `resolve_tool_calls()` が必ず 1 回フルコンテキストを投げるため、**LLM 呼び出しは最低 2 回**になる
- 層 4(長期メモリ)に上限が無く、長いセッションでは要約が積み上がる(要約の再要約はしない)
- `get_local_kb_context()` が毎リクエストで `sync_local_kb()`(ディレクトリスキャン)と埋め込み API 呼び出しを 1 回行う
### LLM 呼び出し(`core/llm.py`) ### LLM 呼び出し(`core/llm.py`)
- `stream_chat()` / `complete()` が LiteLLM を薄くラップ - `stream_chat()` / `complete()` / `embed()` が LiteLLM を薄くラップ
- `_log_context` で呼び出しを囲み、成功・失敗どちらも `api_logs` テーブルに記録 - `resolve_tool_calls()` が `web_search` / `read_note` / `write_note` を解決し、結果を組み込んだメッセージ列を返す
- `_log_context` で呼び出しを囲み、成功・失敗どちらも `api_logs` テーブルに記録(`embed()` は対象外)
- `DEBUG=true` で `litellm._turn_on_debug()` が有効になり詳細ログが出力される - `DEBUG=true` で `litellm._turn_on_debug()` が有効になり詳細ログが出力される
### 共有ノート(`core/notes.py` / `api/notes.py`) ### 共有ノート(`core/notes.py` / `api/notes.py`)
ユーザーと AI が共同編集するノート。`note_pages` の 1 行が UI の 1 タブに対応する。 ユーザーと AI が共同編集するノート。`note_pages` の 1 行が UI の 1 タブに対応する。
利用者向けの操作方法は `docs/notes-guide.md` を参照。
- `get_notes_context()` が**タイトル一覧のみ**をシステムメッセージとして生成し、`get_context_messages()` が会話コンテキストに差し込む(本文は含めない) - `get_notes_context()` が**タイトル一覧のみ**をシステムメッセージとして生成し、`get_context_messages()` が会話コンテキストに差し込む(本文は含めない)。セクションがあれば `## セクション名` の見出しでグループ化して提示する
- 本文の読み書きはツール経由: `read_note`(本文取得)/ `write_note`(追記・置換・新規作成) - 本文の読み書きはツール経由: `read_note`(本文取得)/ `write_note`(追記・置換・新規作成)
- `llm.resolve_tool_calls()` が `web_search` と同じループでこれらを解決する。ノート系ツールは `db` を渡したときのみモデルに提示される - `llm.resolve_tool_calls()` が `web_search` と同じループでこれらを解決する。ノート系ツールは `db` を渡したときのみモデルに提示される
- REST(`/api/notes`)はフロントのノートパネル用。GET 一覧 / POST 作成 / PATCH 更新 / DELETE 削除 - REST(`/api/notes`)はフロントのノートパネル用。GET 一覧 / POST 作成 / PATCH 更新 / DELETE 削除 / `POST /api/notes/sections/rename`(セクション名の一括変更)/ `POST /api/notes/reorder`(ドラッグ&ドロップの並べ替え)
- **セクションによるグルーピング**: `note_pages.section` に名前を持たせ、同じセクションのページは常に `position` 上で連続させる。`notes.set_section()` がページのセクションを変更するたびに全ページを再採番し、同じセクションの末尾に挿入し直す(隣接=同一グループという前提をタブ描画側・システムプロンプト側の両方が使っている)。`notes.rename_section()` は既に連続しているページ群のラベルだけを一括で書き換える
- **ドラッグ&ドロップの並べ替え**: `notes.reorder()` は `set_section()` と違い、位置の計算をサーバー側ではなくフロント(`NotePanel.vue`)に任せる。フロントがドラッグ後の全ページIDの順序を計算し、`{ page_id, section, order }` として一括送信、サーバーは検証(`order` が現在の全ページID集合と一致するか)した上でそのまま `position` に反映する。ドロップ先タブの `section` を渡すことで、並べ替えと同時にセクション間移動も1リクエストで完結する
- 既存 DB への追加カラムは Alembic 等の仕組みを使っておらず、`db/database.py` の `_add_missing_columns()` が起動時に `PRAGMA table_info` を見て `ALTER TABLE ... ADD COLUMN` を素朴に実行する(SQLite 専用の簡易マイグレーション)
### SSE ストリーミング(`api/chat.py`) ### SSE ストリーミング(`api/chat.py`)
@@ -73,8 +145,9 @@ backend/
``` ```
data: {"type":"session_id","value":"<uuid>"} # 新規セッション時のみ data: {"type":"session_id","value":"<uuid>"} # 新規セッション時のみ
data: {"type":"chunk","value":"テキスト断片"} # 0回以上
data: {"type":"notes_updated"} # AIが共有ノートを更新した時のみ data: {"type":"notes_updated"} # AIが共有ノートを更新した時のみ
data: {"type":"sources","value":[{...}]} # Web検索した時のみ(出典の title/url)
data: {"type":"chunk","value":"テキスト断片"} # 0回以上
data: {"type":"done"} # 正常完了 data: {"type":"done"} # 正常完了
data: {"type":"error","value":"エラー内容"} # 例外発生時 data: {"type":"error","value":"エラー内容"} # 例外発生時
``` ```
+58
View File
@@ -0,0 +1,58 @@
# 共有ノートの使い方
ユーザーとAIが同じノートを一緒に編集できる機能。ページをタブで切り替え、関連するページ同士は
セクションでグループ化できる。実装の詳細は `docs/architecture.md` の「共有ノート」節、
API 仕様は `docs/api.md` の「共有ノート」節を参照。
## 開き方
画面右上ヘッダーの **「ノート」** ボタンをクリックすると、右側にノートパネルが開く。
もう一度クリックすると閉じる。
## ページの操作
| 操作 | 方法 |
|---|---|
| ページを開く | タブをクリック |
| ページを新規作成 | タブ列右端の **+** をクリック(末尾に「新しいページ」が追加される) |
| タイトルを変更 | タブを**ダブルクリック**すると入力欄になるので書き換えて Enter(または欄外をクリック) |
| ページを削除 | タブ内の **×** をクリック(確認ダイアログあり) |
| 本文を編集 | ページを開いた状態で下の編集エリアに直接入力 |
本文は入力を止めてから少し待つと自動保存される。保存待ちの間はタブに **●**(未保存マーク)が付く。
ページが1つも無い場合は「+ 最初のページを作成」から始められる。
## セクションでまとめる
複数のページを1つのグループとしてタブ上にまとめられる。
| 操作 | 方法 |
|---|---|
| ページをセクションに入れる | タブにマウスを乗せると出てくる **🏷** をクリックし、セクション名を入力 |
| セクション名を変更する | セクションのラベル(タブ群の左上に表示される名前)をクリックし、新しい名前を入力 |
| セクションを解除する | 🏷 またはセクションラベルをクリックした際、名前を**空欄**にして確定 |
同じセクションに入れたページは、タブ列上で必ず隣り合って表示される
(新しくセクションに追加したページは、そのセクションの末尾に自動的に移動する)。
セクション名を変更すると、そのグループに属する全ページのラベルが一括で変わる。
## タブをドラッグで動かす
タブは**ドラッグ&ドロップ**でも操作できる(🏷 やセクションラベルのクリックと同等の結果になる)。
| 操作 | 方法 |
|---|---|
| 同じグループ内で順番を変える | タブを掴んで、移動先のタブの左半分/右半分にドロップ(左=そのタブの前、右=後ろに入る) |
| 別のセクションへ移動する | 移動先のセクション内のタブにドロップ(そのタブと同じセクションに入る) |
| セクションから外す | 未分類のタブにドロップ、またはタブ列の一番右(+ボタンの手前の細い枠)にドロップ |
ドラッグ中はドロップ位置に青い線が表示される。タイトル編集中(ダブルクリックで入力欄になっている間)のタブはドラッグできない。
## AIとの連携
- 全ページの**タイトル一覧**は、会話のたびに自動でAIに渡される(セクションがあれば見出し付きで)。
ただし本文はここには含まれない。
- AIは `read_note` ツールでページ本文を読み、`write_note` ツールで追記・置き換え・新規ページ作成ができる。
「ノートに書いておいて」「〇〇について記録して」のように頼むと、AIが該当ページを判断して書き込む。
- AIがノートを書き換えると、開いているノートパネルは自動的に最新の内容へ更新される。
- 自分が編集中(未保存)のページは、AIの更新で上書きされない。
+32 -3
View File
@@ -263,6 +263,7 @@ export interface NotePage {
title: string title: string
content: string content: string
position: number position: number
section: string | null
updated_at: string updated_at: string
} }
@@ -271,18 +272,18 @@ export async function fetchNotePages(): Promise<NotePage[]> {
return parseJson<NotePage[]>(res, 'ノート取得') return parseJson<NotePage[]>(res, 'ノート取得')
} }
export async function createNotePage(title: string): Promise<NotePage> { export async function createNotePage(title: string, section?: string | null): Promise<NotePage> {
const res = await fetch(`${BASE}/api/notes`, { const res = await fetch(`${BASE}/api/notes`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }), body: JSON.stringify({ title, section: section ?? null }),
}) })
return parseJson<NotePage>(res, 'ページ作成') return parseJson<NotePage>(res, 'ページ作成')
} }
export async function updateNotePage( export async function updateNotePage(
pageId: number, pageId: number,
patch: { title?: string; content?: string }, patch: { title?: string; content?: string; section?: string | null },
): Promise<NotePage> { ): Promise<NotePage> {
const res = await fetch(`${BASE}/api/notes/${pageId}`, { const res = await fetch(`${BASE}/api/notes/${pageId}`, {
method: 'PATCH', method: 'PATCH',
@@ -295,3 +296,31 @@ export async function updateNotePage(
export async function deleteNotePage(pageId: number): Promise<void> { export async function deleteNotePage(pageId: number): Promise<void> {
await fetch(`${BASE}/api/notes/${pageId}`, { method: 'DELETE' }) await fetch(`${BASE}/api/notes/${pageId}`, { method: 'DELETE' })
} }
/** section = old のページ全体を section = new へ一括で付け替える。new が空/nullならグループ解除。 */
export async function renameNoteSection(oldName: string, newName: string | null): Promise<{ renamed: number }> {
const res = await fetch(`${BASE}/api/notes/sections/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ old_name: oldName, new_name: newName }),
})
return parseJson<{ renamed: number }>(res, 'セクション名変更')
}
/**
* タブのドラッグ&ドロップによる並べ替えをサーバーへ反映する。
* order には全ページIDをドラッグ後の新しい順序で渡す。pageId のページは section へ移動する
* (ドロップ先のタブと同じセクションを渡せば、その場でセクション間の移動を兼ねる)。
*/
export async function reorderNotePages(
pageId: number,
section: string | null,
order: number[],
): Promise<{ reordered: number }> {
const res = await fetch(`${BASE}/api/notes/reorder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page_id: pageId, section, order }),
})
return parseJson<{ reordered: number }>(res, 'ノート並べ替え')
}
+196 -27
View File
@@ -1,15 +1,43 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref, nextTick } from 'vue' import { onMounted, onUnmounted, ref, nextTick, computed } from 'vue'
import { useNotesStore } from '../stores/notes' import { useNotesStore } from '../stores/notes'
import type { NotePage } from '../api/client'
interface TabGroup {
section: string | null
pages: NotePage[]
}
const store = useNotesStore() const store = useNotesStore()
const renamingId = ref<number | null>(null) const renamingId = ref<number | null>(null)
const renameInput = ref<HTMLInputElement | null>(null) // ref="renameInput" は入れ子の v-for の中にあるため、Vue 3 では単一要素ではなく配列になる
const renameInput = ref<HTMLInputElement[]>([])
/** ドラッグ中のページID */
const draggingId = ref<number | null>(null)
/** ドラッグ中に今どのタブの前/後にドロップされるかの表示用インジケータ */
const dropTarget = ref<{ id: number; place: 'before' | 'after' } | null>(null)
/** タブ列の末尾(+ボタンの手前)にドロップしようとしているか */
const dropAtEnd = ref(false)
/** 隣接して同じsectionが続く区間を1グループとして表示する(並び順はサーバー側が保証) */
const groups = computed<TabGroup[]>(() => {
const result: TabGroup[] = []
for (const page of store.pages) {
const last = result[result.length - 1]
if (last && last.section === page.section) {
last.pages.push(page)
} else {
result.push({ section: page.section, pages: [page] })
}
}
return result
})
async function startRename(id: number) { async function startRename(id: number) {
renamingId.value = id renamingId.value = id
await nextTick() await nextTick()
renameInput.value?.select() renameInput.value[0]?.select()
} }
function onTitleInput(id: number, e: Event) { function onTitleInput(id: number, e: Event) {
@@ -31,6 +59,73 @@ async function confirmRemove(id: number, title: string) {
await store.removePage(id) await store.removePage(id)
} }
async function assignSection(page: NotePage) {
const input = window.prompt('このページが属するセクション名(空にするとグループ解除)', page.section ?? '')
if (input === null) return
await store.setSection(page.id, input.trim())
}
async function renameSectionGroup(currentName: string) {
const input = window.prompt('セクション名を変更(空にするとグループを解除)', currentName)
if (input === null) return
await store.renameSection(currentName, input.trim())
}
function onDragStart(id: number, e: DragEvent) {
draggingId.value = id
e.dataTransfer?.setData('text/plain', String(id)) // Firefoxはdataが無いとドラッグを許可しない
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'
}
function onDragEnd() {
draggingId.value = null
dropTarget.value = null
dropAtEnd.value = false
}
function onDragOverTab(page: NotePage, e: DragEvent) {
if (draggingId.value === null || draggingId.value === page.id) return
dropAtEnd.value = false
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const place: 'before' | 'after' = e.clientX - rect.left < rect.width / 2 ? 'before' : 'after'
dropTarget.value = { id: page.id, place }
}
function onDragOverEnd() {
if (draggingId.value === null) return
dropTarget.value = null
dropAtEnd.value = true
}
/** ドラッグ後の新しい並びを計算してサーバーへ送る。section にドロップ先タブの所属セクションを渡すと、並べ替えと同時にセクション間の移動も反映される。 */
async function moveTab(id: number, targetId: number | null, place: 'before' | 'after', section: string | null) {
const order = store.pages.map((p) => p.id)
const fromIdx = order.indexOf(id)
if (fromIdx === -1) return
order.splice(fromIdx, 1)
const insertAt = targetId === null
? order.length
: order.indexOf(targetId) + (place === 'after' ? 1 : 0)
order.splice(insertAt, 0, id)
await store.reorder(id, section, order)
}
async function onDropTab(page: NotePage, e: DragEvent) {
const id = draggingId.value
onDragEnd()
if (id === null || id === page.id) return
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const place: 'before' | 'after' = e.clientX - rect.left < rect.width / 2 ? 'before' : 'after'
await moveTab(id, page.id, place, page.section)
}
async function onDropEnd() {
const id = draggingId.value
onDragEnd()
if (id === null) return
await moveTab(id, null, 'after', null)
}
onMounted(() => { onMounted(() => {
store.load() store.load()
}) })
@@ -43,30 +138,61 @@ onUnmounted(() => {
<div class="note-panel"> <div class="note-panel">
<div class="tabs"> <div class="tabs">
<div <div
v-for="page in store.pages" v-for="group in groups"
:key="page.id" :key="`${group.section ?? '_'}-${group.pages[0].id}`"
class="tab" class="tab-group"
:class="{ active: page.id === store.activeId }" :class="{ named: group.section }"
@click="store.selectPage(page.id)"
@dblclick="startRename(page.id)"
> >
<input <button
v-if="renamingId === page.id" v-if="group.section"
ref="renameInput" class="section-label"
class="tab-rename" :title="`「${group.section}」セクション。クリックで名前変更・解除`"
:value="page.title" @click="renameSectionGroup(group.section)"
@input="onTitleInput(page.id, $event)" >{{ group.section }}</button>
@blur="finishRename" <div
@keydown.enter="finishRename" v-for="page in group.pages"
@keydown.esc="finishRename" :key="page.id"
@click.stop class="tab"
:class="{
active: page.id === store.activeId,
dragging: draggingId === page.id,
'drop-before': dropTarget?.id === page.id && dropTarget.place === 'before',
'drop-after': dropTarget?.id === page.id && dropTarget.place === 'after',
}"
:draggable="renamingId !== page.id"
@click="store.selectPage(page.id)"
@dblclick="startRename(page.id)"
@dragstart="onDragStart(page.id, $event)"
@dragend="onDragEnd"
@dragover.prevent="onDragOverTab(page, $event)"
@drop.prevent="onDropTab(page, $event)"
> >
<template v-else> <input
<span class="tab-title" :title="page.title">{{ page.title }}</span> v-if="renamingId === page.id"
<span v-if="store.dirtyIds.has(page.id)" class="dot" title="未保存">●</span> ref="renameInput"
<button class="tab-del" title="削除" @click.stop="confirmRemove(page.id, page.title)">×</button> class="tab-rename"
</template> :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-tag" title="セクションに割り当て" @click.stop="assignSection(page)">🏷</button>
<button class="tab-del" title="削除" @click.stop="confirmRemove(page.id, page.title)">×</button>
</template>
</div>
</div> </div>
<div
v-if="draggingId !== null"
class="drop-end"
:class="{ active: dropAtEnd }"
@dragover.prevent="onDragOverEnd"
@drop.prevent="onDropEnd"
/>
<button class="tab-add" title="ページを追加" @click="store.addPage()">+</button> <button class="tab-add" title="ページを追加" @click="store.addPage()">+</button>
</div> </div>
@@ -88,6 +214,8 @@ onUnmounted(() => {
<div class="hint"> <div class="hint">
ページのタイトル一覧はAIのシステムプロンプトに常に含まれます。本文はAIが必要に応じて読み出します。 ページのタイトル一覧はAIのシステムプロンプトに常に含まれます。本文はAIが必要に応じて読み出します。
🏷 でページをセクションにまとめられます(セクション名をクリックすると変更・解除)。
タブはドラッグで並べ替え・他のセクションへの移動ができます。
</div> </div>
</div> </div>
</template> </template>
@@ -101,13 +229,40 @@ onUnmounted(() => {
} }
.tabs { .tabs {
display: flex; display: flex;
align-items: stretch; align-items: flex-end;
gap: 2px; gap: 2px;
padding: 6px 6px 0; padding: 6px 6px 0;
overflow-x: auto; overflow-x: auto;
flex-shrink: 0; flex-shrink: 0;
border-bottom: 1px solid #333; border-bottom: 1px solid #333;
} }
.tab-group {
display: flex;
align-items: flex-end;
gap: 2px;
}
.tab-group.named {
background: #161d2e;
border: 1px solid #29314a;
border-bottom: none;
border-radius: 8px 8px 0 0;
padding: 3px 3px 0;
margin-right: 4px;
}
.section-label {
background: none;
border: none;
color: #7fa8e0;
font-size: 10px;
font-weight: 600;
cursor: pointer;
padding: 2px 5px 4px;
white-space: nowrap;
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
}
.section-label:hover { color: #a8c8f0; text-decoration: underline; }
.tab { .tab {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -124,9 +279,20 @@ onUnmounted(() => {
max-width: 160px; max-width: 160px;
} }
.tab.active { background: #2a2a2a; color: #fff; border-color: #444; } .tab.active { background: #2a2a2a; color: #fff; border-color: #444; }
.tab.dragging { opacity: 0.4; }
.tab.drop-before { box-shadow: inset 2px 0 0 #93c5fd; }
.tab.drop-after { box-shadow: inset -2px 0 0 #93c5fd; }
.drop-end {
align-self: stretch;
width: 14px;
flex-shrink: 0;
margin: 0 2px;
border-radius: 3px;
}
.drop-end.active { background: #93c5fd; opacity: 0.5; }
.tab-title { overflow: hidden; text-overflow: ellipsis; } .tab-title { overflow: hidden; text-overflow: ellipsis; }
.dot { color: #eab308; font-size: 8px; } .dot { color: #eab308; font-size: 8px; }
.tab-del, .tab-add { .tab-tag, .tab-del, .tab-add {
background: none; background: none;
border: none; border: none;
color: #666; color: #666;
@@ -135,8 +301,11 @@ onUnmounted(() => {
padding: 0 2px; padding: 0 2px;
line-height: 1; line-height: 1;
} }
.tab-tag { font-size: 11px; opacity: 0; transition: opacity 0.15s; }
.tab:hover .tab-tag, .tab.active .tab-tag { opacity: 0.8; }
.tab-tag:hover { opacity: 1 !important; }
.tab-del:hover, .tab-add:hover { color: #f87171; } .tab-del:hover, .tab-add:hover { color: #f87171; }
.tab-add { color: #888; padding: 0 8px; } .tab-add { color: #888; padding: 0 8px; align-self: center; }
.tab-add:hover { color: #93c5fd; } .tab-add:hover { color: #93c5fd; }
.tab-rename { .tab-rename {
background: #111; background: #111;
+48 -3
View File
@@ -5,6 +5,8 @@ import {
createNotePage, createNotePage,
updateNotePage, updateNotePage,
deleteNotePage, deleteNotePage,
renameNoteSection,
reorderNotePages,
type NotePage, type NotePage,
} from '../api/client' } from '../api/client'
@@ -44,17 +46,57 @@ export const useNotesStore = defineStore('notes', () => {
activeId.value = id activeId.value = id
} }
async function addPage(title = '新しいページ'): Promise<void> { async function addPage(title = '新しいページ', section: string | null = null): Promise<void> {
error.value = null error.value = null
try { try {
const page = await createNotePage(title) const page = await createNotePage(title, section)
pages.value.push(page) // サーバー側でセクションの並びに合わせて再配置されている可能性があるため一覧を取り直す
await load()
activeId.value = page.id activeId.value = page.id
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : 'ページの作成に失敗しました' error.value = e instanceof Error ? e.message : 'ページの作成に失敗しました'
} }
} }
/** ページの所属セクションを変更する。section が空/nullならグループ解除。並び順もサーバー側で調整されるため再取得する。 */
async function setSection(id: number, section: string | null): Promise<void> {
error.value = null
flush()
try {
await updateNotePage(id, { section })
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : 'セクションの変更に失敗しました'
}
}
/** oldName セクションに属する全ページを newName セクションへ一括で付け替える。newName が空ならグループ解除。 */
async function renameSection(oldName: string, newName: string | null): Promise<void> {
error.value = null
flush()
try {
await renameNoteSection(oldName, newName)
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : 'セクション名の変更に失敗しました'
}
}
/**
* タブのドラッグ&ドロップ用。order には全ページIDをドラッグ後の新しい順序で渡す。
* section にドロップ先タブの所属セクションを渡せば、並べ替えと同時にセクション間の移動も反映される。
*/
async function reorder(pageId: number, section: string | null, order: number[]): Promise<void> {
error.value = null
flush()
try {
await reorderNotePages(pageId, section, order)
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '並べ替えに失敗しました'
}
}
async function removePage(id: number): Promise<void> { async function removePage(id: number): Promise<void> {
error.value = null error.value = null
try { try {
@@ -118,6 +160,9 @@ export const useNotesStore = defineStore('notes', () => {
addPage, addPage,
removePage, removePage,
edit, edit,
setSection,
renameSection,
reorder,
flush, flush,
} }
}) })