Author SHA1 Message Date
colcaroliandClaude Opus 5 4882d291be Render math in chat messages with KaTeX
Add a marked extension that tokenizes math delimiters and renders them
with KaTeX: `$$ ... $$` / `\[ ... \]` as block, `$ ... $` / `\( ... \)`
and mid-paragraph `$$ ... $$` as inline.

Inline `$ ... $` requires a non-space after the opening `$`, no newline
inside, and a non-digit after the closing `$`, so prose like "$100 から
$200" is left alone; `\$` still escapes a literal dollar sign. Parse
errors fall back to KaTeX's red inline message instead of throwing, and
math whose closing delimiter has not streamed in yet stays plain text.

DOMPurify strips `semantics` and `annotation` by default, which would
leak the raw TeX out of KaTeX's MathML output, so both are allowed
explicitly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:10:03 +09:00
colcaroli 8cac5d0de0 Merge pull request 'Feature/knowledge index and timezone' (#2) from feature/knowledge-index-and-timezone into main
Reviewed-on: #2
2026-08-20 06:00:21 +00:00
colcaroliandClaude Opus 5 762026aa2c Drop optional lightningcss entries from the frontend lockfile
npm install(npm 11 / Node 24)を実行した際の再解決で、lightningcss と
その各プラットフォーム向けバイナリ、detect-libc のエントリが外れた。
いずれも vite の optional な peer 依存で、実際には使われていない。

package.json は変更しておらず、追加・バージョン更新も無い(286行の削除のみ)。
この状態で npm run type-check(vue-tsc)が通ることは確認済み。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:57:30 +09:00
colcaroliandClaude Opus 5 2ee7238afb Archive summarized messages instead of deleting them
要約で長期メモリに移したメッセージをDBから物理削除していたため、
一度要約されると人間も内容を確認できなくなっていた。削除をやめ、
messages に archived_at(要約に取り込まれた時刻)と memory_id
(どの要約に取り込まれたか)を持たせる。

モデルからは従来どおり見えない:
- get_context_messages() がアーカイブ済みを除外する
- maybe_summarize() の閾値判定も現役メッセージだけを数える
  (ここを除外し忘れると件数が減らず再要約が繰り返される)
- backfill_tags() もアーカイブ済みは対象外にする

人間からは確認できる:
- GET /chat/sessions/{id}/messages がアーカイブ済みも archived_at
  付きで返す。export/import もアーカイブ状態を保持する
- チャット画面では半透明で表示し、最後のアーカイブ済みメッセージの
  直後に「ここから先がモデルに渡る」区切り線を出す

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:33:18 +09:00
colcaroliandClaude Opus 5 be3af44891 Store timestamps as UTC-aware and slim the knowledge context to headlines
タイムゾーン:
SQLite の DATETIME は tzinfo を保存できず、DateTime(timezone=True) を
指定してもバインド時に捨てられるため、API のレスポンスがオフセット無しの
ISO 文字列になりフロントがローカル時刻として誤解していた。UtcDateTime
(TypeDecorator) を追加し、書き込み時は naive な UTC に正規化、読み出し時は
UTC の aware として返すようにする。格納フォーマットは変わらないので既存
データはそのまま正しく解釈でき、マイグレーションは不要。
コンテナは TZ 未設定で UTC だったため、天気通知の cron 時刻・深夜スキップ
判定・「明日」の日付計算が9時間ずれていた。TZ=Asia/Tokyo を指定し、slim
イメージに無い tzdata を導入する。

蓄積知識:
層1が最新15件の要約全文を毎リクエスト無条件に注入しており、同じ内容が
ローカルKB(層3)にも保存されているため重複していた。層1を「クエリ+
一行要約」の目次に変え、本文は read_knowledge ツールで読み出す形にする。
一行要約は要約生成時の既存 LLM 呼び出しに相乗りするので呼び出し回数は
増えない。headline を持たない既存エントリは本文の先頭行から導出する。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:22:59 +09:00
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
28 changed files with 1290 additions and 404 deletions
+34 -6
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 ラッパー・共有ノート・設定
│ ├── memory_kb/ コンテキスト管理(要約・蓄積知識・ローカルKB・タグ付け)
│ ├── search/ Tavily Web 検索
│ └── discord/ Discord Bot・定期通知
└── app/db/ SQLAlchemy モデル・DB 接続 └── 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` の**見出し一覧のみ**(`- 「クエリ」: 一行要約`)。本文と出典は含めず、必要なら `read_knowledge` ツールで読み出す。最新 `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` に保存する。**要約した元メッセージは `archived_at` を立ててアーカイブされる**(物理削除はしない)。アーカイブ済みメッセージは `get_context_messages()` と要約の閾値判定の両方から除外されるため LLM には渡らないが、DB には残り、履歴表示・エクスポートからは参照できる。`memory_id` にどの要約に取り込まれたかを保持する。
**遅延読み込み**: 重い情報は目次だけ注入し、`llm.resolve_tool_calls()` が本番生成の前にツール(`web_search` / `read_note` / `write_note` / `read_knowledge`、最大 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 ストリーミング
@@ -89,6 +107,7 @@ SQLite (playchat.db) + LiteLLM → 任意の LLM
### Markdown レンダリング ### Markdown レンダリング
アシスタントメッセージは `marked` + `DOMPurify` でレンダリング。ストリーミング中の未閉じコードフェンスは自動補完してから描画する(`MarkdownContent.vue`)。 アシスタントメッセージは `marked` + `DOMPurify` でレンダリング。ストリーミング中の未閉じコードフェンスは自動補完してから描画する(`MarkdownContent.vue`)。
数式は `src/utils/math.ts` の marked 拡張が KaTeX で描画する(ブロック `$$...$$` / `\[...\]`、インライン `$...$` / `\(...\)`)。詳細は `docs/architecture.md` を参照。
### モデル切替 ### モデル切替
@@ -109,14 +128,23 @@ 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 キー |
| `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 回に返す本文の最大文字数 | | `NOTES_TOOL_CONTENT_LIMIT` | `4000` | `read_note` が 1 回に返す本文の最大文字数 |
| `KNOWLEDGE_TOOL_CONTENT_LIMIT` | `4000` | `read_knowledge` が 1 回に返す本文の最大文字数 |
| `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) | | `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) |
| `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 | | `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 |
+5
View File
@@ -1,5 +1,10 @@
FROM python:3.12-slim FROM python:3.12-slim
# slim イメージには /usr/share/zoneinfo が無く、TZ を設定してもUTCのままになるため入れる。
RUN apt-get update \
&& apt-get install -y --no-install-recommends tzdata \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir uv RUN pip install --no-cache-dir uv
WORKDIR /app WORKDIR /app
+8 -3
View File
@@ -42,6 +42,8 @@ class MessageResponse(BaseModel):
role: str role: str
content: str content: str
created_at: datetime created_at: datetime
# 要約に取り込まれ、LLMには渡らなくなったメッセージ。人間の履歴確認用に残してある。
archived_at: datetime | None = None
tags: list[str] = [] tags: list[str] = []
class Config: class Config:
@@ -52,6 +54,7 @@ class ImportMessage(BaseModel):
role: str role: str
content: str content: str
created_at: datetime | None = None created_at: datetime | None = None
archived_at: datetime | None = None
tags: list[str] = [] tags: list[str] = []
@@ -83,6 +86,7 @@ def get_messages(session_id: str, db: DBSession = Depends(get_db)):
role=m.role, role=m.role,
content=m.content, content=m.content,
created_at=m.created_at, created_at=m.created_at,
archived_at=m.archived_at,
tags=[t.tag for t in m.tags], tags=[t.tag for t in m.tags],
) )
for m in session.messages for m in session.messages
@@ -110,6 +114,7 @@ def export_session(session_id: str, db: DBSession = Depends(get_db)):
"role": m.role, "role": m.role,
"content": m.content, "content": m.content,
"created_at": m.created_at.isoformat(), "created_at": m.created_at.isoformat(),
"archived_at": m.archived_at.isoformat() if m.archived_at else None,
"tags": [t.tag for t in m.tags], "tags": [t.tag for t in m.tags],
} }
for m in session.messages for m in session.messages
@@ -143,7 +148,7 @@ def import_session(req: ImportRequest, db: DBSession = Depends(get_db)):
db.flush() db.flush()
for m in req.messages: for m in req.messages:
msg = Message(session_id=session.id, role=m.role, content=m.content) msg = Message(session_id=session.id, role=m.role, content=m.content, archived_at=m.archived_at)
if m.created_at: if m.created_at:
msg.created_at = m.created_at msg.created_at = m.created_at
db.add(msg) db.add(msg)
@@ -224,9 +229,9 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
] ]
yield sse({"type": "sources", "value": sources}) yield sse({"type": "sources", "value": sources})
for s in searches: for s in searches:
summary = await knowledge.summarize_results(s["query"], s["results"]) headline, summary = await knowledge.summarize_results(s["query"], s["results"])
if summary: if summary:
knowledge.save_knowledge(db, s["query"], summary, s["results"]) knowledge.save_knowledge(db, s["query"], summary, s["results"], headline=headline)
local_knowledge.save_search_result(s["query"], summary, s["results"]) local_knowledge.save_search_result(s["query"], summary, s["results"])
# 履歴にtool_callsが含まれる場合、最終呼び出しにも同じツール定義を渡す必要がある # 履歴にtool_callsが含まれる場合、最終呼び出しにも同じツール定義を渡す必要がある
+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
+3
View File
@@ -41,6 +41,9 @@ LOCAL_KB_MIN_SCORE: float = float(os.getenv("LOCAL_KB_MIN_SCORE", "0.75"))
# read_note ツールが1回に返す本文の最大文字数 # read_note ツールが1回に返す本文の最大文字数
NOTES_TOOL_CONTENT_LIMIT: int = int(os.getenv("NOTES_TOOL_CONTENT_LIMIT", "4000")) NOTES_TOOL_CONTENT_LIMIT: int = int(os.getenv("NOTES_TOOL_CONTENT_LIMIT", "4000"))
# read_knowledge ツールが1回に返す蓄積知識の本文の最大文字数
KNOWLEDGE_TOOL_CONTENT_LIMIT: int = int(os.getenv("KNOWLEDGE_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 を設定する。
+10 -4
View File
@@ -116,17 +116,21 @@ async def resolve_tool_calls(
) -> tuple[list[dict], list[dict], bool]: ) -> tuple[list[dict], list[dict], bool]:
"""必要に応じてLLMにツールを呼ばせ、その結果を会話に組み込んだ上でメッセージ列を返す。 """必要に応じてLLMにツールを呼ばせ、その結果を会話に組み込んだ上でメッセージ列を返す。
扱うツールは web_search と共有ノート(read_note / write_note)。ノート系は db が 扱うツールは web_search、共有ノート(read_note / write_note)、蓄積知識(read_knowledge)。
渡されたときだけ提示する。モデルがtool callingに対応していない場合は何もせず DBを見るものは db が渡されたときだけ提示する。モデルがtool callingに対応していない場合は
元のmessagesをそのまま返す。 何もせず元のmessagesをそのまま返す。
戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト, ノートを変更したか) 戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト, ノートを変更したか)
""" """
# app.core.memory_kb.knowledge は app.core.llm を import しているため、
# モジュールレベルで読むと循環 import になる(_save_log と同じ理由で関数内 import する)。
from app.core.memory_kb.knowledge import KNOWLEDGE_TOOLS, execute_knowledge_tool
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 notes_changed = False
tools = [WEB_SEARCH_TOOL] + (NOTE_TOOLS if db is not None else []) tools = [WEB_SEARCH_TOOL] + (NOTE_TOOLS + KNOWLEDGE_TOOLS if db is not None else [])
for _ in range(max_rounds): for _ in range(max_rounds):
try: try:
@@ -168,6 +172,8 @@ async def resolve_tool_calls(
if name in ("read_note", "write_note") and db is not None: if name in ("read_note", "write_note") and db is not None:
tool_text, changed = execute_note_tool(db, name, args) tool_text, changed = execute_note_tool(db, name, args)
notes_changed = notes_changed or changed notes_changed = notes_changed or changed
elif name == "read_knowledge" and db is not None:
tool_text = execute_knowledge_tool(db, name, args)
else: else:
query = args.get("query", "") query = args.get("query", "")
results = await search_web(query) results = await search_web(query)
+135 -17
View File
@@ -7,7 +7,11 @@ import trafilatura
from sqlalchemy.orm import Session as DBSession from sqlalchemy.orm import Session as DBSession
from app.core import llm from app.core import llm
from app.core.config import KNOWLEDGE_CONTEXT_LIMIT, SEARCH_SUMMARY_MODEL from app.core.config import (
KNOWLEDGE_CONTEXT_LIMIT,
KNOWLEDGE_TOOL_CONTENT_LIMIT,
SEARCH_SUMMARY_MODEL,
)
from app.core.memory_kb import local_knowledge from app.core.memory_kb import local_knowledge
from app.db.models import KnowledgeEntry, KnowledgeSource from app.db.models import KnowledgeEntry, KnowledgeSource
@@ -19,6 +23,9 @@ _HTML_TAG_RE = re.compile(r"<[^>]+>")
_SUMMARY_PROMPT = ( _SUMMARY_PROMPT = (
"以下はWeb検索「{query}」で得られた複数の検索結果です。" "以下はWeb検索「{query}」で得られた複数の検索結果です。"
"これらの内容を整理し、Markdown形式で構造的にまとめてください。\n" "これらの内容を整理し、Markdown形式で構造的にまとめてください。\n"
"- 1行目は「要約: 」で始まる60文字以内の一文にする"
"(何についての知識かが一目で分かる見出し。システムプロンプトにはこの行だけを載せる)\n"
"- 2行目以降を本文とし、以下の要領でまとめる\n"
"- 検索結果間で重複する情報はまとめ、矛盾する情報があれば併記する\n" "- 検索結果間で重複する情報はまとめ、矛盾する情報があれば併記する\n"
"- 見出し(##)やトピックごとの箇条書きを使い、要点が一目で分かるようにする\n" "- 見出し(##)やトピックごとの箇条書きを使い、要点が一目で分かるようにする\n"
"- 各項目の末尾に (出典: サイト名) を付ける\n" "- 各項目の末尾に (出典: サイト名) を付ける\n"
@@ -27,15 +34,46 @@ _SUMMARY_PROMPT = (
) )
async def summarize_results(query: str, results: list[dict], model: str | None = None) -> str: _HEADLINE_MARKERS = ("要約:", "要約:")
def _split_headline(text: str) -> tuple[str, str]:
"""LLMの出力を (一行要約, 本文) に分ける。1行目が想定の形式でなければ見出しは空で返す。"""
lines = text.strip().splitlines()
if not lines:
return "", ""
first = lines[0].strip().lstrip("#").lstrip("-*").strip().strip("*").strip()
for marker in _HEADLINE_MARKERS:
if not first.startswith(marker):
continue
headline = first[len(marker):].strip().strip("*").strip()
body = "\n".join(lines[1:]).strip()
if headline and body:
return headline[:120], body
break
return "", text.strip()
def derive_headline(query: str, summary: str) -> str:
"""headline を持たないエントリ(この機能より前に保存されたもの)のための代替の見出し。"""
for line in summary.splitlines():
stripped = line.strip().lstrip("#").lstrip("-*").strip().strip("*").strip()
if stripped:
return stripped[:120]
return query
async def summarize_results(query: str, results: list[dict], model: str | None = None) -> tuple[str, str]:
"""Web検索結果(title/url/content のリスト)をLLMで構造的なMarkdown要約にまとめる。 """Web検索結果(title/url/content のリスト)をLLMで構造的なMarkdown要約にまとめる。
要約に失敗した場合は空文字を返す(生のスクレイピング結果を未加工のままファイルに 戻り値は (一行要約, 本文)。一行要約はシステムプロンプトに並べる見出しとして使い、
本文はDBとローカルナレッジベースに保存する。
要約に失敗した場合は ("", "") を返す(生のスクレイピング結果を未加工のままファイルに
書き込むと、ページに残ったスクリプト断片等がセキュリティソフトに誤検知されることがあるため、 書き込むと、ページに残ったスクリプト断片等がセキュリティソフトに誤検知されることがあるため、
フォールバックとして生テキストを書き出すことはしない)。 フォールバックとして生テキストを書き出すことはしない)。
""" """
if not results: if not results:
return "" return "", ""
raw = "\n\n".join( raw = "\n\n".join(
f"### {r.get('title') or r['url']}\n{r.get('content', '')[:1500]}" f"### {r.get('title') or r['url']}\n{r.get('content', '')[:1500]}"
@@ -43,17 +81,20 @@ async def summarize_results(query: str, results: list[dict], model: str | None =
if r.get("content") if r.get("content")
) )
if not raw: if not raw:
return "" return "", ""
prompt = _SUMMARY_PROMPT.format(query=query, results=raw) prompt = _SUMMARY_PROMPT.format(query=query, results=raw)
try: try:
return await llm.complete( text = await llm.complete(
[{"role": "user", "content": prompt}], [{"role": "user", "content": prompt}],
model=model or SEARCH_SUMMARY_MODEL, model=model or SEARCH_SUMMARY_MODEL,
) )
except Exception: except Exception:
logger.exception("Failed to summarize search results for query=%r", query) logger.exception("Failed to summarize search results for query=%r", query)
return "" return "", ""
headline, body = _split_headline(text)
return headline or derive_headline(query, body), body
def _strip_tags(html: str) -> str: def _strip_tags(html: str) -> str:
@@ -111,12 +152,13 @@ async def regenerate_from_sources(db: DBSession) -> int:
if not results: if not results:
continue continue
new_summary = await summarize_results(entry.query, results) new_headline, new_summary = await summarize_results(entry.query, results)
if not new_summary: if not new_summary:
continue continue
old_summary = entry.summary old_summary = entry.summary
entry.summary = new_summary entry.summary = new_summary
entry.headline = new_headline or derive_headline(entry.query, new_summary)
db.commit() db.commit()
local_knowledge.delete_search_result_file(entry.query, old_summary) local_knowledge.delete_search_result_file(entry.query, old_summary)
@@ -129,9 +171,11 @@ async def regenerate_from_sources(db: DBSession) -> int:
return count return count
def save_knowledge(db: DBSession, query: str, summary: str, sources: list[dict]) -> KnowledgeEntry: def save_knowledge(
db: DBSession, query: str, summary: str, sources: list[dict], headline: str = "",
) -> KnowledgeEntry:
"""Web検索で得た知見をDBに保存する。""" """Web検索で得た知見をDBに保存する。"""
entry = KnowledgeEntry(query=query, summary=summary) entry = KnowledgeEntry(query=query, summary=summary, headline=headline or derive_headline(query, summary))
db.add(entry) db.add(entry)
db.flush() db.flush()
for src in sources: for src in sources:
@@ -143,8 +187,80 @@ def save_knowledge(db: DBSession, query: str, summary: str, sources: list[dict])
return entry return entry
READ_KNOWLEDGE_TOOL = {
"type": "function",
"function": {
"name": "read_knowledge",
"description": "過去のWeb検索で蓄積した知識の本文を読み出す。システムプロンプトに提示された"
"「調べた事柄の一覧」の中に今の話題に関係しそうな項目があり、その本文がまだ提示されていないときに使う。"
"ここで読めるならWeb検索をやり直す必要はない。",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "読み出す項目のクエリ(一覧の「」内に載っているもの)"},
},
"required": ["query"],
},
},
}
KNOWLEDGE_TOOLS = [READ_KNOWLEDGE_TOOL]
def _find_entry(db: DBSession, query: str) -> KnowledgeEntry | None:
"""クエリで蓄積知識を探す。完全一致 → 大文字小文字・前後空白を無視した一致 → 部分一致 の順に試す。
同じクエリで複数回検索されている場合は、最も新しいものを返す。
"""
entries = db.query(KnowledgeEntry).order_by(KnowledgeEntry.created_at.desc()).all()
for entry in entries:
if entry.query == query:
return entry
normalized = query.strip().casefold()
for entry in entries:
if entry.query.strip().casefold() == normalized:
return entry
for entry in entries:
if normalized and normalized in entry.query.strip().casefold():
return entry
return None
def read_knowledge(db: DBSession, query: str) -> str:
"""蓄積知識の本文をツール結果用のテキストとして返す。"""
entry = _find_entry(db, query)
if not entry:
available = ", ".join(
e.query
for e in db.query(KnowledgeEntry).order_by(KnowledgeEntry.created_at.desc()).limit(KNOWLEDGE_CONTEXT_LIMIT)
)
return f"「{query}」に一致する知識はありません。読み出せる項目: {available or '(なし)'}"
content = entry.summary
if len(content) > KNOWLEDGE_TOOL_CONTENT_LIMIT:
content = content[:KNOWLEDGE_TOOL_CONTENT_LIMIT] + "\n…(以下省略)"
urls = ", ".join(s.url for s in entry.sources) or "出典不明"
return f"「{entry.query}」について調べた内容:\n\n{content}\n\n(出典: {urls})"
def execute_knowledge_tool(db: DBSession, name: str, args: dict) -> str:
"""蓄積知識のツールを実行する。ノート系と違いDBを変更しないため、戻り値はテキストのみ。"""
try:
if name == "read_knowledge":
return read_knowledge(db, args.get("query", ""))
except Exception as e:
logger.exception("Knowledge tool %s failed", name)
return f"知識の読み出しに失敗しました: {e}"
return f"未知のツールです: {name}"
def get_knowledge_context(db: DBSession) -> str | None: def get_knowledge_context(db: DBSession) -> str | None:
"""蓄積された知識を、システムプロンプトに注入できる形のテキストにして返す。""" """蓄積された知識の「見出し一覧」を、システムプロンプトに注入できる形のテキストにして返す。
本文(summary)はここには含めない。同じ内容が knowledge_base/web_search/ 配下にも
Markdownとして保存されており、話題に関連していればローカルナレッジベース側が
全文を注入するため、ここで全件の本文を積むと同じ情報を二重に渡すことになる。
"""
entries = ( entries = (
db.query(KnowledgeEntry) db.query(KnowledgeEntry)
.order_by(KnowledgeEntry.created_at.desc()) .order_by(KnowledgeEntry.created_at.desc())
@@ -154,12 +270,14 @@ def get_knowledge_context(db: DBSession) -> str | None:
if not entries: if not entries:
return None return None
blocks = [] blocks = [
for entry in reversed(entries): f"- 「{entry.query}」: {entry.headline or derive_headline(entry.query, entry.summary)}"
urls = ", ".join(s.url for s in entry.sources) or "出典不明" for entry in reversed(entries)
blocks.append(f"- 「{entry.query}」について: {entry.summary}\n (出典: {urls})") ]
return ( return (
"以下はこれまでのWeb検索で得た知識です。関連する話題が出たら活用してください。" "以下はこれまでのWeb検索で調べた事柄の一覧です(見出しのみで、本文は含みません)。\n"
"ただしローカルナレッジベースの資料が別途提示されている場合は、そちらを優先してください:\n\n" + "\n".join(blocks) "関連する話題が出たときは、まず本文がローカルナレッジベースの資料として別途提示されていないか確認し、"
"あればそれを本文として使ってください。提示されていない場合は read_knowledge ツールで読み出せます。"
"いずれかで足りるならWeb検索をやり直す必要はありません:\n\n" + "\n".join(blocks)
) )
+26 -4
View File
@@ -1,3 +1,5 @@
from datetime import datetime, timezone
from sqlalchemy.orm import Session as DBSession from sqlalchemy.orm import Session as DBSession
from app.db.models import Memory, Message, Session from app.db.models import Memory, Message, Session
from app.core.config import MEMORY_SUMMARIZE_THRESHOLD from app.core.config import MEMORY_SUMMARIZE_THRESHOLD
@@ -11,7 +13,13 @@ 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]:
"""蓄積知識・長期メモリ(要約)・短期メモリ(最近のメッセージ)を結合してコンテキストを返す。""" """蓄積知識・長期メモリ(要約)・短期メモリ(最近のメッセージ)を結合してコンテキストを返す。"""
memories = db.query(Memory).filter(Memory.session_id == session_id).order_by(Memory.created_at).all() memories = db.query(Memory).filter(Memory.session_id == session_id).order_by(Memory.created_at).all()
messages = db.query(Message).filter(Message.session_id == session_id).order_by(Message.created_at).all() # アーカイブ済み(要約に取り込まれた)メッセージは、その内容が長期メモリ側に入っているため除外する。
messages = (
db.query(Message)
.filter(Message.session_id == session_id, Message.archived_at.is_(None))
.order_by(Message.created_at)
.all()
)
context: list[dict] = [] context: list[dict] = []
@@ -42,8 +50,18 @@ async def get_context_messages(db: DBSession, session_id: str) -> list[dict]:
async def maybe_summarize(db: DBSession, session: Session) -> None: async def maybe_summarize(db: DBSession, session: Session) -> None:
"""メッセージ数が閾値を超えたら古いメッセージを要約してMemoryに保存し、元メッセージを削除する。""" """メッセージ数が閾値を超えたら古いメッセージを要約してMemoryに保存し、元メッセージをアーカイブする。
messages = db.query(Message).filter(Message.session_id == session.id).order_by(Message.created_at).all()
アーカイブしたメッセージはLLMに渡すコンテキストからは外れるが、人間が後から履歴を
確認できるようDBには残す(物理削除はしない)。閾値の判定もアーカイブ済みを除いた
件数で行うため、一度要約した分が再び対象になることはない。
"""
messages = (
db.query(Message)
.filter(Message.session_id == session.id, Message.archived_at.is_(None))
.order_by(Message.created_at)
.all()
)
if len(messages) < MEMORY_SUMMARIZE_THRESHOLD: if len(messages) < MEMORY_SUMMARIZE_THRESHOLD:
return return
@@ -60,8 +78,12 @@ async def maybe_summarize(db: DBSession, session: Session) -> None:
memory = Memory(session_id=session.id, summary=summary_text) memory = Memory(session_id=session.id, summary=summary_text)
db.add(memory) db.add(memory)
db.flush() # どの要約に取り込まれたかをメッセージ側に持たせるためIDを確定させる
archived_at = datetime.now(timezone.utc)
for msg in to_summarize: for msg in to_summarize:
db.delete(msg) msg.archived_at = archived_at
msg.memory_id = memory.id
db.commit() db.commit()
db.refresh(memory) db.refresh(memory)
+7 -1
View File
@@ -185,10 +185,16 @@ async def backfill_tags(db: DBSession, model: str | None = None, limit: int | No
単体スクリプト・APIエンドポイントの両方から呼べるようにここに切り出している。 単体スクリプト・APIエンドポイントの両方から呼べるようにここに切り出している。
戻り値: タグ付けしたメッセージ数 戻り値: タグ付けしたメッセージ数
""" """
# アーカイブ済み(要約に取り込まれた)メッセージは対象外。話題選びには要約側のタグを使うため、
# 過去ログ全体にLLMを走らせる意味が薄い。
query = ( query = (
db.query(Message) db.query(Message)
.outerjoin(MessageTag, MessageTag.message_id == Message.id) .outerjoin(MessageTag, MessageTag.message_id == Message.id)
.filter(Message.role.in_(["user", "assistant"]), MessageTag.id.is_(None)) .filter(
Message.role.in_(["user", "assistant"]),
MessageTag.id.is_(None),
Message.archived_at.is_(None),
)
.order_by(Message.created_at.asc()) .order_by(Message.created_at.asc())
) )
if limit: if limit:
+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}")
+3 -2
View File
@@ -1,5 +1,5 @@
"""直近で取得した「明日の天気」データをメモリ上にキャッシュする。""" """直近で取得した「明日の天気」データをメモリ上にキャッシュする。"""
from datetime import datetime from datetime import datetime, timezone
_latest: dict | None = None _latest: dict | None = None
@@ -11,7 +11,8 @@ def set_latest(weather: str, pop: int | None, judgement: str, reason: str) -> No
"pop": pop, "pop": pop,
"judgement": judgement, "judgement": judgement,
"reason": reason, "reason": reason,
"fetched_at": datetime.now().isoformat(), # オフセット付きで返す(フロントの new Date() がローカル時刻と誤解しないようにするため)
"fetched_at": datetime.now(timezone.utc).isoformat(),
} }
+22 -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,27 @@ def get_db():
db.close() db.close()
# 既存DBに後から追加されたカラム: (テーブル名, カラム名, 型)
_ADDED_COLUMNS = [
("note_pages", "section", "VARCHAR"),
("knowledge_entries", "headline", "VARCHAR"),
("messages", "archived_at", "DATETIME"),
("messages", "memory_id", "INTEGER"),
]
def _add_missing_columns() -> None:
"""マイグレーションフレームワークを使っていないため、既存DBに後から追加されたカラムを
ここで簡易的に補う。create_all() は既存テーブルへのカラム追加は行わないので必要。"""
with engine.connect() as conn:
for table, column, column_type in _ADDED_COLUMNS:
existing = {row[1] for row in conn.execute(text(f"PRAGMA table_info({table})"))}
if existing and column not in existing:
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {column_type}"))
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()
+27 -15
View File
@@ -1,7 +1,8 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Float from sqlalchemy import String, Text, ForeignKey, Integer, Float
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.database import Base from app.db.database import Base
from app.db.types import UtcDateTime
class Session(Base): class Session(Base):
@@ -10,8 +11,8 @@ class Session(Base):
id: Mapped[str] = mapped_column(String, primary_key=True) id: Mapped[str] = mapped_column(String, primary_key=True)
title: Mapped[str] = mapped_column(String, default="New Chat") title: Mapped[str] = mapped_column(String, default="New Chat")
model: Mapped[str] = mapped_column(String, default="") model: Mapped[str] = mapped_column(String, default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) created_at: Mapped[datetime] = mapped_column(UtcDateTime, 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(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
messages: Mapped[list["Message"]] = relationship(back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at") messages: Mapped[list["Message"]] = relationship(back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at")
memories: Mapped[list["Memory"]] = relationship(back_populates="session", cascade="all, delete-orphan") memories: Mapped[list["Memory"]] = relationship(back_populates="session", cascade="all, delete-orphan")
@@ -24,7 +25,14 @@ class Message(Base):
session_id: Mapped[str] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE")) session_id: Mapped[str] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE"))
role: Mapped[str] = mapped_column(String) # "user" | "assistant" | "system" role: Mapped[str] = mapped_column(String) # "user" | "assistant" | "system"
content: Mapped[str] = mapped_column(Text) content: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
# 要約されて長期メモリに移ったメッセージ。LLMに渡すコンテキストからは外れるが、
# 人間が後から履歴を確認できるようDBには残す(app.core.memory_kb.memory.maybe_summarize を参照)。
archived_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
# どの要約に取り込まれたか。要約を削除してもメッセージ自体は残す(SET NULL)。
memory_id: Mapped[int | None] = mapped_column(
ForeignKey("memories.id", ondelete="SET NULL"), nullable=True,
)
session: Mapped["Session"] = relationship(back_populates="messages") session: Mapped["Session"] = relationship(back_populates="messages")
tags: Mapped[list["MessageTag"]] = relationship(back_populates="message", cascade="all, delete-orphan") tags: Mapped[list["MessageTag"]] = relationship(back_populates="message", cascade="all, delete-orphan")
@@ -36,7 +44,7 @@ class MessageTag(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
message_id: Mapped[int] = mapped_column(ForeignKey("messages.id", ondelete="CASCADE")) message_id: Mapped[int] = mapped_column(ForeignKey("messages.id", ondelete="CASCADE"))
tag: Mapped[str] = mapped_column(String) tag: Mapped[str] = mapped_column(String)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
message: Mapped["Message"] = relationship(back_populates="tags") message: Mapped["Message"] = relationship(back_populates="tags")
@@ -48,7 +56,7 @@ class Memory(Base):
session_id: Mapped[str] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE")) session_id: Mapped[str] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE"))
summary: Mapped[str] = mapped_column(Text) summary: Mapped[str] = mapped_column(Text)
embedding_json: Mapped[str | None] = mapped_column(Text, nullable=True) # 将来のベクトル検索用 embedding_json: Mapped[str | None] = mapped_column(Text, nullable=True) # 将来のベクトル検索用
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
session: Mapped["Session"] = relationship(back_populates="memories") session: Mapped["Session"] = relationship(back_populates="memories")
tags: Mapped[list["MemoryTag"]] = relationship(back_populates="memory", cascade="all, delete-orphan") tags: Mapped[list["MemoryTag"]] = relationship(back_populates="memory", cascade="all, delete-orphan")
@@ -60,7 +68,7 @@ class MemoryTag(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
memory_id: Mapped[int] = mapped_column(ForeignKey("memories.id", ondelete="CASCADE")) memory_id: Mapped[int] = mapped_column(ForeignKey("memories.id", ondelete="CASCADE"))
tag: Mapped[str] = mapped_column(String) tag: Mapped[str] = mapped_column(String)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
memory: Mapped["Memory"] = relationship(back_populates="tags") memory: Mapped["Memory"] = relationship(back_populates="tags")
@@ -72,7 +80,10 @@ class KnowledgeEntry(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
query: Mapped[str] = mapped_column(String) query: Mapped[str] = mapped_column(String)
summary: Mapped[str] = mapped_column(Text) summary: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) # システムプロンプトには本文ではなくこの一行要約だけを並べる(app.core.memory_kb.knowledge を参照)。
# このカラム追加より前に作られた行では None になるため、表示側で summary から導出する。
headline: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
sources: Mapped[list["KnowledgeSource"]] = relationship(back_populates="entry", cascade="all, delete-orphan") sources: Mapped[list["KnowledgeSource"]] = relationship(back_populates="entry", cascade="all, delete-orphan")
@@ -101,7 +112,7 @@ class LocalKnowledgeDoc(Base):
title: Mapped[str] = mapped_column(String) title: Mapped[str] = mapped_column(String)
content_hash: Mapped[str] = mapped_column(String) content_hash: Mapped[str] = mapped_column(String)
embedding_json: Mapped[str] = mapped_column(Text) embedding_json: Mapped[str] = mapped_column(Text)
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(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
class NotePage(Base): class NotePage(Base):
@@ -115,9 +126,10 @@ 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) # タブの並び順(セクションでまとめる際、同じセクションのページは連続させる)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) section: Mapped[str | None] = mapped_column(String, nullable=True) # タブをグループ化するセクション名。未設定なら未分類
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
class AppSetting(Base): class AppSetting(Base):
@@ -126,7 +138,7 @@ class AppSetting(Base):
key: Mapped[str] = mapped_column(String, primary_key=True) key: Mapped[str] = mapped_column(String, primary_key=True)
value: Mapped[str] = mapped_column(Text) value: Mapped[str] = mapped_column(Text)
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(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
class ModelPreset(Base): class ModelPreset(Base):
@@ -136,7 +148,7 @@ class ModelPreset(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
label: Mapped[str] = mapped_column(String) label: Mapped[str] = mapped_column(String)
value: Mapped[str] = mapped_column(String, unique=True) value: Mapped[str] = mapped_column(String, unique=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
class ApiLog(Base): class ApiLog(Base):
@@ -149,4 +161,4 @@ class ApiLog(Base):
response_text: Mapped[str | None] = mapped_column(Text, nullable=True) response_text: Mapped[str | None] = mapped_column(Text, nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True) error: Mapped[str | None] = mapped_column(Text, nullable=True)
latency_ms: Mapped[float | None] = mapped_column(Float, nullable=True) latency_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
+38
View File
@@ -0,0 +1,38 @@
"""タイムゾーンを保ったまま日時を扱うためのカラム型。"""
from datetime import datetime, timezone
from sqlalchemy import DateTime
from sqlalchemy.types import TypeDecorator
class UtcDateTime(TypeDecorator):
"""常に UTC の aware な datetime として読み書きする DateTime。
SQLite の DATETIME は tzinfo を保存できず、`DateTime(timezone=True)` を指定しても
バインド時に黙って捨てられる(=naive な値が返ってくる)。そのままだと API のレスポンスが
オフセット無しの ISO 文字列になり、フロントの `new Date()` がローカル時刻として
解釈してしまうため、この型で入口と出口を揃える。
- 書き込み: aware な値は UTC に変換し、naive な値は UTC とみなす。いずれも naive な UTC として格納する
- 読み出し: 格納値を UTC の aware な datetime として返す
格納フォーマットは素の DateTime と同じ(naive な UTC)なので、
既存DBのデータはそのまま正しく解釈でき、マイグレーションは不要。
"""
impl = DateTime
cache_ok = True
def process_bind_param(self, value: datetime | None, dialect) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value
return value.astimezone(timezone.utc).replace(tzinfo=None)
def process_result_value(self, value: datetime | None, dialect) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
+4
View File
@@ -8,6 +8,10 @@ services:
environment: environment:
# SQLite ファイルと知識ベースを永続ボリュームに置く(.env の値を上書き) # SQLite ファイルと知識ベースを永続ボリュームに置く(.env の値を上書き)
DATABASE_URL: sqlite:////app/data/playchat.db DATABASE_URL: sqlite:////app/data/playchat.db
# コンテナのタイムゾーン。未設定だとUTCになり、天気通知のcron時刻・深夜スキップ判定・
# 「明日」の日付計算がすべて9時間ずれる。プロセス起動前に効かせる必要があるため
# .env ではなくここで指定する(後から os.environ に入れても libc には反映されない)。
TZ: Asia/Tokyo
volumes: volumes:
- backend_data:/app/data - backend_data:/app/data
- backend_knowledge_base:/app/knowledge_base - backend_knowledge_base:/app/knowledge_base
+53 -7
View File
@@ -48,13 +48,15 @@ SSE でストリーミング応答を返す。
--- ---
### GET /chat/sessions/{session_id}/messages ### GET /chat/sessions/{session_id}/messages
セッションのメッセージ一覧を `created_at` 昇順で返す。 セッションのメッセージ一覧を `created_at` 昇順で返す。要約に取り込まれた(アーカイブ済みの)
メッセージも含めて返し、`archived_at` で区別する。アーカイブ済みのものは LLM に渡す
コンテキストには入らないが、履歴としては残っている。
**レスポンス** **レスポンス**
```json ```json
[ [
{ "id": 1, "role": "user", "content": "...", "created_at": "..." }, { "id": 1, "role": "user", "content": "...", "created_at": "...", "archived_at": "2026-08-20T04:00:00+00:00", "tags": [] },
{ "id": 2, "role": "assistant", "content": "...", "created_at": "..." } { "id": 2, "role": "assistant", "content": "...", "created_at": "...", "archived_at": null, "tags": [] }
] ]
``` ```
@@ -93,9 +95,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 +109,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 +119,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 }
``` ```
--- ---
+95 -14
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 # 長期メモリ参照・削除
│ │ ├── notes.py # 共有ノート CRUD
│ │ ├── models.py # モデルプリセット CRUD
│ │ ├── settings.py # GUI 設定画面(APIキー等)
│ │ ├── system.py # タグ backfill・ワードプール参照などの運用系
│ │ ├── auth.py # サイトログイン
│ │ └── logs.py # API ログ参照・削除 │ │ └── 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,86 @@ 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` の**見出し一覧のみ**(本文・出典は含めない) | `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. **要約した元メッセージは `archived_at` / `memory_id` を立ててアーカイブ**する(物理削除はしない)。LLM に渡すコンテキストからも、次回以降の要約対象からも外れるが、DB には残るため履歴表示・エクスポートからは読める
4. 保存した要約に `tagging.tag_memory()` でタグを付ける
#### ツール経由の遅延読み込み
常時注入すると重い情報は、システムプロンプトに目次だけ置いてツールで取りに行かせる。
`llm.resolve_tool_calls()` が本番のストリーミング生成の**前に**別途 LLM を呼んでツール呼び出しを解決する(最大 2 ラウンド)。
- `read_note` / `write_note` … 共有ノート本文の読み書き(`db` を渡したときだけモデルに提示)
- `read_knowledge` … 層 1 の見出し一覧に出ている知識の本文を読む(同上)。層 3 として本文が既に注入されていれば呼ぶ必要はない
- `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 件のメッセージから再シードされる)
#### 設計上の注意点
- トークン数ベースの制御は無く、すべて件数・文字数ベース。層 3(Markdown 全文 ×3)は会話が短くても
コンテキストを大きく押し上げる(層 1 は見出しのみなので件数を増やしても軽い)
- ツールを使わない会話でも `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` / `read_knowledge` を解決し、結果を組み込んだメッセージ列を返す
- `_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 +146,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":"エラー内容"} # 例外発生時
``` ```
@@ -127,6 +201,13 @@ frontend/src/
- アシスタントメッセージのみ `marked` で HTML に変換後 `DOMPurify` でサニタイズ - アシスタントメッセージのみ `marked` で HTML に変換後 `DOMPurify` でサニタイズ
- ストリーミング中(`pending=true`)は未閉じのコードフェンス(` ``` `)を自動補完してから描画 - ストリーミング中(`pending=true`)は未閉じのコードフェンス(` ``` `)を自動補完してから描画
- 数式は `src/utils/math.ts` の marked 拡張が KaTeX で描画する(`mathExtension` / `mathSanitizeConfig`)
- ブロック: 行頭の `$$ ... $$` と `\[ ... \]`。インライン: `$ ... $`、`\( ... \)`、行中の `$$ ... $$`
- `$ ... $` は誤爆防止のため「開き `$` の直後が空白でない」「中身に改行を含まない」「閉じ `$` の直後が数字でない」を満たすものだけを数式にする(`$100 から $200` のような金額表記は素通しする)。リテラルのドル記号は `\$` でエスケープできる
- インラインコード・コードフェンス内の `$` は marked のトークナイザ順により数式にならない
- 構文エラーは例外にせず KaTeX の赤字表示にフォールバックする(`throwOnError: false`)
- KaTeX の MathML 出力に含まれる `semantics` / `annotation` は DOMPurify の既定では許可されないため、`mathSanitizeConfig` で明示的に許可している
- ストリーミング途中で閉じ記号が未到達の数式は、閉じるまでプレーンテキストとして表示される
### Vite プロキシ設定 ### Vite プロキシ設定
+189
View File
@@ -0,0 +1,189 @@
# 機能一覧
このリポジトリにあるアプリ(PlayChat)が「何ができるか」を機能単位でまとめたもの。
内部設計は [architecture.md](architecture.md)、エンドポイント仕様は [api.md](api.md)、
Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は [notes-guide.md](notes-guide.md)、
デプロイ手順は [deploy-lightsail.md](deploy-lightsail.md) を参照。
## これは何か
個人用の LLM チャットアプリ。FastAPI(Python)+ Vue 3(TypeScript)で、会話・記憶・ノートを
すべて手元の SQLite に持つ。LLM は LiteLLM 経由なので OpenAI / Anthropic / Ollama / Amazon Bedrock を
同じ画面のプルダウンで切り替えられる。単なるチャット UI ではなく、
- 会話が長くなったら勝手に要約して長期記憶に落とす
- 知らないことは自分で Web 検索して、その結果を次回以降も使える知識として溜める
- ユーザーと AI が同じノートを共同編集する
- Discord 側にも住んでいて、天気を知らせたり雑談に乗ったりする
といった「動き続ける」部分を持つのが特徴。
## 機能サマリ
| # | 機能 | 概要 | 主な実装 |
|---|---|---|---|
| 1 | チャット(SSE ストリーミング) | 逐次表示・Markdown 描画・セッション管理 | `api/chat.py` / `ChatWindow.vue` |
| 2 | セッションの書き出し / 取り込み | 会話を JSON でダウンロード・復元 | `api/chat.py` / `SessionList.vue` |
| 3 | 5 層コンテキスト管理 | 蓄積知識・ノート・ローカル KB・長期/短期メモリを毎回合成 | `core/memory_kb/memory.py` |
| 4 | 自動要約による長期記憶 | 一定件数を超えたら古い会話を要約して圧縮 | `memory_kb/memory.py` |
| 5 | Web 検索と知識の蓄積 | Tavily で検索 →要約 → DB とローカル KB に保存 | `core/search/` / `memory_kb/knowledge.py` |
| 6 | ローカル Markdown ナレッジベース | 話題に近いファイルを埋め込み類似度で選んで全文注入 | `memory_kb/local_knowledge.py` |
| 7 | 共有ノート | タブ/セクション付きノートを人と AI の両方が編集 | `core/notes.py` / `NotePanel.vue` |
| 8 | 自動タグ付け | 発言・要約にタグを 3 つ付与(話題選びに使う) | `memory_kb/tagging.py` |
| 9 | ワードプール | 直近の名詞をインメモリに保持しタグクラウド表示 | `memory_kb/word_memory.py` / `WordPool.vue` |
| 10 | Discord Bot | メンションに返信。天気・雑談キーワードに反応 | `core/discord/discord_bot.py` |
| 11 | 定期通知 | 毎日の洗濯天気通知と、定期的な「どうでもいい豆知識」 | `core/discord/scheduler.py` |
| 12 | 天気予報(気象庁 API) | 明日の天気・降水確率から洗濯指数を判定 | `core/weather/` / `Clock.vue` |
| 13 | モデル切替とプリセット管理 | 選択肢を DB で管理し画面から追加・編集・削除 | `api/models.py` / `SettingsPanel.vue` |
| 14 | GUI 設定画面 | API キー等を再起動なしで変更(許可リスト制・マスク表示) | `core/settings_store.py` |
| 15 | API ログビューア | LLM 呼び出しの成功/失敗・レイテンシ・リクエスト全文 | `api/logs.py` / `LogViewer.vue` |
| 16 | システムモニタ | CPU・メモリ・GPU 使用率を 10 秒ごとに表示 | `api/system.py` / `MemoryMonitor.vue` |
| 17 | サイト認証 | 共有パスワード 1 つでサイト全体を保護 | `core/site_auth.py` / `LoginGate.vue` |
| 18 | Docker 一式 | Caddy + nginx + FastAPI で HTTPS 公開 | `docker-compose.yml` / `Caddyfile` |
---
## 1. チャット
- `POST /chat/stream` が SSE を返し、フロントは fetch + ReadableStream で受けて 1 文字ずつ描画する。
イベントは `session_id` / `chunk` / `sources` / `notes_updated` / `done` / `error` の 6 種。
- アシスタントの発言は `marked` + `DOMPurify` で描画する。ストリーミング途中で閉じていないコードフェンスは
自動で補完してから描画するので、生成中でもレイアウトが崩れない。
- セッションは左サイドバーの一覧から切り替え・削除できる。タイトルは最初の発言の先頭 40 文字。
- Web 検索が走った応答には出典(`sources`)が、各メッセージには自動生成されたタグが添えて表示される。
- 失敗時は赤いバブルとトースト通知の両方で知らせる。
## 2. セッションの書き出し / 取り込み
- `GET /chat/sessions/{id}/export` がセッションのメタ情報と全メッセージ(タグ込み)を JSON で返す。
- `POST /chat/sessions/import` は同じ形式の JSON から**新しいセッションを作成**する(上書きではない)。
サイドバーの ⭱ ボタンからファイルを選ぶだけで取り込める。
- 要約済み(アーカイブ済み)のメッセージも `archived_at` 付きでそのまま書き出され、取り込み時もアーカイブ状態を保つ。
## 3. コンテキスト管理(5 層)
毎リクエスト `get_context_messages()` が以下の順に system メッセージを積み、最後に会話履歴を連ねる。
| 層 | 中身 | 量の制御 |
|---|---|---|
| 1 蓄積知識 | 過去の Web 検索結果の**見出し一覧のみ**(本文は `read_knowledge` で読む) | 最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件 |
| 2 共有ノート | ページのタイトル一覧+本文先頭 40 文字 | 全ページ(本文は入れない) |
| 3 ローカル KB | 選ばれた Markdown の**全文** | 類似度上位 `LOCAL_KB_TOP_K`(既定 3)件かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上 |
| 4 長期メモリ | 要約を全件連結 | 上限なし |
| 5 短期メモリ | セッション内の全メッセージ | 上限なし(要約で間接的に制御) |
重い情報(ノート本文・蓄積知識の本文・Web 検索)は目次だけを渡し、本体はツール(`read_note` / `write_note` / `read_knowledge` / `web_search`)で
取りに行かせる遅延読み込み方式。`llm.resolve_tool_calls()` が本番生成の前に最大 2 ラウンドでこれを解決する。
## 4. 自動要約による長期記憶
- メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)を超えると、古い側の半分を LLM が要約して
`memories` に 1 行として保存する。
- **要約した元メッセージはアーカイブされる**(物理削除はしない)。LLM に渡るコンテキストからは外れるが、チャット画面では薄く表示され、区切り線から先だけがモデルに渡ることが分かるようになっている。
- 保存した要約にもタグが付き、Discord Bot の話題選びの材料になる。
- 溜まった要約は `GET /memory/{session_id}` で確認、`DELETE /memory/{id}` で個別削除できる(API のみ。専用の画面は無い)。
## 5. Web 検索と知識の蓄積
- LLM が必要と判断したときだけ `web_search` ツールが走る(Tavily。`TAVILY_API_KEY` 未設定なら空振り)。
- 結果は `SEARCH_SUMMARY_MODEL` で構造化要約にされ、**2 か所**に保存される。
- `knowledge_entries` / `knowledge_sources` テーブル → 次回以降、層 1 として全セッションに効く
- `knowledge_base/web_search/*.md` → 層 3 の類似度検索の対象になる
- `POST /chat/knowledge/regenerate` で、既存の知識エントリを出典 URL から取り直して要約し直せる。
## 6. ローカル Markdown ナレッジベース
- `backend/knowledge_base/**.md` を置いておくだけで、話題が近いときに自動で全文が注入される。
- 直近のユーザー発言との埋め込みコサイン類似度で選ぶ。埋め込みは `local_knowledge_docs` に
内容ハッシュ付きでキャッシュされ、ファイルが変わったときだけ取り直す。
- プロンプト上は「Web 検索結果より優先。これで答えられるなら検索するな」と明示している。
## 7. 共有ノート
- 1 ページ=1 タブ。作成・改名・削除・本文編集(入力が止まると自動保存)ができる。
- **セクション**でページをグループ化でき、同じセクションのタブは常に隣り合って表示される。
- タブは**ドラッグ&ドロップ**で並べ替えでき、他セクションのタブにドロップすればそのまま移動になる。
- AI 側はタイトル一覧だけを常に見ており、`read_note` で本文を読み、`write_note` で追記/全置換/新規作成する。
AI が書き換えると SSE の `notes_updated` が飛び、開いているノートパネルが自動で最新化される。
- 自分が編集中(未保存)のページは AI の更新で上書きされない。
## 8-9. 自動タグ付けとワードプール
- 各メッセージと各要約に、バックグラウンドでタグが最大 3 つ付く(応答ストリームはブロックしない)。
タグはコンテキストには注入せず、Discord Bot が「近い話題」を選ぶために使う。
- `POST /api/system/tags/backfill` で、過去の未タグ分をまとめて処理できる。
- ワードプールは Janome で抽出した名詞を `deque(maxlen=200)` にプロセス内保持するもの。
**DB に永続化されず再起動で消える**が、起動時に直近 200 件のメッセージから再シードされる。
サイドバーに出現回数に応じた大きさのタグクラウドとして表示される。
## 10-11. Discord Bot と定期通知
- Bot はメンションされたときだけ反応する。本文に天気系キーワード(天気・洗濯・雨など)が含まれれば
気象庁データを添えて回答し、雑談系キーワード(どうでもいい・暇など)なら発言からタグを起こして
関連する話題の豆知識を即座に投げ返す。
- APScheduler による定期ジョブが 2 つ。
- **洗濯天気通知**: 毎日 `WEATHER_NOTIFY_HOUR` 時(既定 22 時)に、翌日の天気と洗濯指数を配信
- **どうでもいい豆知識**: `USELESS_TIPS_ENABLED=true` のときだけ登録され、`USELESS_TIPS_INTERVAL_HOURS`(既定 3 時間)ごとに実行。深夜 3 時台はスキップ
- どちらも `POST /api/system/weather-notify/test` / `POST /api/system/useless-tip/test` で即時実行して確認できる。
## 12. 天気予報
- 気象庁の無料 JSON API から明日の予報を取り、天気文言と降水確率から洗濯指数を判定する
(雨雪なら室内干し、降水確率 30% 以下なら外干し OK、それ以上は室内干し推奨)。
- 取得した最新データはメモリ上にキャッシュされ、画面右上の時計の横にも表示される(`GET /api/system/weather/latest`)。
- 対象地域は `WEATHER_AREA_CODE`(既定 120000=千葉県)と `WEATHER_AREA_KEYWORD`(既定「北西部」)で指定する。
## 13-14. モデル選択と設定画面
- モデルのプルダウンは `model_presets` テーブル由来で、初回起動時に既定の一覧がシードされる。
設定画面からラベル/モデル文字列を追加・編集・削除できる(重複は 400 で弾く)。
- 設定画面では API キー類を**再起動なしで**変更できる。
- 変更できるのは許可リストにあるキーだけ(`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / AWS 系 /
`OLLAMA_API_BASE` / `TAVILY_API_KEY` / `LOCAL_KB_EMBEDDING_MODEL`)
- 値は末尾 4 文字だけを見せるマスク表示。空欄で保存すると「変更なし」扱い
- 保存すると `app_settings` テーブルに入り `os.environ` に反映される。削除すると `.env` 由来の起動時の値に戻る
## 15-16. ログビューアとシステムモニタ
- LLM 呼び出しは成功・失敗どちらも `api_logs` に記録される(モデル・リクエスト全文・応答・
レイテンシ・エラー)。ログパネルからセッション別/成功失敗別に絞り込み、個別削除ができる。
- サイドバーの監視パネルは CPU 使用率・メモリ使用量を 10 秒ごとに更新する。
`pynvml` が使える環境なら GPU メモリも表示する(無ければその欄が出ないだけ)。
## 17. 認証
パスワードが 2 種類あり、役割が違う。
| 変数 | 守る対象 | 方式 |
|---|---|---|
| `SITE_PASSWORD` | チャット含むサイト全体 | ログイン画面 → HMAC 署名付きセッション Cookie(既定 30 日) |
| `SETTINGS_PASSWORD` | 設定画面と モデルプリセットの更新系のみ | HTTP Basic(リクエストごとに送る) |
- どちらも未設定時の挙動が逆なので注意。`SITE_PASSWORD` 未設定=**誰でも使える**、
`SETTINGS_PASSWORD` 未設定=**設定画面は常に 403**(空パスワードでの突破を防ぐため)。
- `SESSION_SECRET_KEY` を設定しないと起動ごとに鍵が作り直され、再起動のたびに全員ログインし直しになる。
- ローカルの http 環境でログインが効かない場合のみ `SESSION_COOKIE_SECURE=false` にする。
## 18. デプロイ
- `docker-compose.yml` に backend(FastAPI)/ frontend(nginx で静的配信+API リバースプロキシ)/
caddy(Let's Encrypt 自動取得と HTTPS 終端)の 3 コンテナが定義済み。
- SQLite と `knowledge_base` は Docker ボリュームに永続化される。
- フロントは API をすべて相対パスで呼ぶため、同一オリジン配信なら CORS 設定は実質不要。
---
## 制約・注意点
このアプリを触るときに引っかかりやすい点。
- **1 応答あたり LLM 呼び出しは最低 2 回**。ツールを使わない会話でも `resolve_tool_calls()` が
フルコンテキストを 1 回投げてから本番生成に入る。
- **トークン数ベースの制御が一切ない**。すべて件数・文字数ベースなので、層 3(Markdown 全文 ×3)は
会話が短くてもコンテキストを大きく押し上げる(層 1 は見出しのみなので軽い)。
- **長期メモリに上限がない**。長いセッションでは要約が積み上がり続ける(要約の再要約はしない)。
- **要約自体は不可逆**。アーカイブされた元メッセージは残るが、要約をやり直したり元の状態に戻す手段は無い。
- **ワードプールは揮発性**。再起動でリセットされ、直近 200 件から作り直される。
- **DB マイグレーションは簡易実装**。Alembic は依存に入っているが使っておらず、起動時に
`PRAGMA table_info` を見て不足カラムを `ALTER TABLE` で足すだけ(SQLite 前提)。
- **自動テストは無い**。`pytest` は dev 依存に入っているが `backend/tests/` は存在しない。
+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の更新で上書きされない。
+22 -282
View File
@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"dompurify": "^3.1.0", "dompurify": "^3.1.0",
"katex": "^0.16.47",
"marked": "^12.0.0", "marked": "^12.0.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "^3.4.0", "vue": "^3.4.0",
@@ -1117,6 +1118,15 @@
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
} }
}, },
"node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1130,18 +1140,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.4.7", "version": "3.4.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
@@ -1254,278 +1252,20 @@
"he": "bin/he" "he": "bin/he"
} }
}, },
"node_modules/lightningcss": { "node_modules/katex": {
"version": "1.32.0", "version": "0.16.47",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
"dev": true, "funding": [
"license": "MPL-2.0", "https://opencollective.com/katex",
"optional": true, "https://github.com/sponsors/katex"
"peer": true, ],
"license": "MIT",
"dependencies": { "dependencies": {
"detect-libc": "^2.0.3" "commander": "^8.3.0"
}, },
"engines": { "bin": {
"node": ">= 12.0.0" "katex": "cli.js"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/magic-string": { "node_modules/magic-string": {
+1
View File
@@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"dompurify": "^3.1.0", "dompurify": "^3.1.0",
"katex": "^0.16.47",
"marked": "^12.0.0", "marked": "^12.0.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "^3.4.0", "vue": "^3.4.0",
+35 -3
View File
@@ -51,6 +51,8 @@ export interface MessageRecord {
role: 'user' | 'assistant' | 'system' role: 'user' | 'assistant' | 'system'
content: string content: string
created_at: string created_at: string
// 要約に取り込まれたメッセージ。モデルには渡らないが履歴としては残っている
archived_at: string | null
tags: string[] tags: string[]
} }
@@ -83,6 +85,7 @@ export interface ImportPayload {
role: string role: string
content: string content: string
created_at?: string | null created_at?: string | null
archived_at?: string | null
tags?: string[] tags?: string[]
}[] }[]
} }
@@ -263,6 +266,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 +275,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 +299,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, 'ノート並べ替え')
}
+33 -4
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, nextTick } from 'vue' import { computed, ref, watch, nextTick } from 'vue'
import { useChatStore } from '../stores/chat' import { useChatStore } from '../stores/chat'
import MarkdownContent from './MarkdownContent.vue' import MarkdownContent from './MarkdownContent.vue'
import type { Source } from '../api/client' import type { Source } from '../api/client'
@@ -7,6 +7,14 @@ import type { Source } from '../api/client'
const store = useChatStore() const store = useChatStore()
const scrollEl = ref<HTMLElement | null>(null) const scrollEl = ref<HTMLElement | null>(null)
// 要約済みメッセージの最後の位置。ここに「ここから先がモデルに渡る」区切りを描く。
const lastArchivedIndex = computed(() => {
for (let i = store.messages.length - 1; i >= 0; i--) {
if (store.messages[i].archived) return i
}
return -1
})
function uniqueSources(sources: Source[]): Source[] { function uniqueSources(sources: Source[]): Source[] {
const seen = new Set<string>() const seen = new Set<string>()
return sources.filter((s) => { return sources.filter((s) => {
@@ -30,10 +38,10 @@ watch(
<div v-if="store.messages.length === 0" class="empty"> <div v-if="store.messages.length === 0" class="empty">
メッセージを送信してチャットを開始してください メッセージを送信してチャットを開始してください
</div> </div>
<template v-for="(msg, i) in store.messages" :key="i">
<div <div
v-for="(msg, i) in store.messages" :class="['message-wrap', msg.role, { archived: msg.archived }]"
:key="i" :title="msg.archived ? '要約済み: 内容は長期メモリに引き継がれ、この発言自体はモデルに渡りません' : undefined"
:class="['message-wrap', msg.role]"
> >
<div :class="['message', msg.role, { error: msg.error }]"> <div :class="['message', msg.role, { error: msg.error }]">
<div class="bubble"> <div class="bubble">
@@ -65,6 +73,10 @@ watch(
>{{ src.title || src.url }}</a> >{{ src.title || src.url }}</a>
</div> </div>
</div> </div>
<div v-if="i === lastArchivedIndex" class="archive-divider">
<span>ここまでは要約済み — 履歴として残っていますが、モデルには要約だけが渡ります</span>
</div>
</template>
</div> </div>
</template> </template>
@@ -85,6 +97,23 @@ watch(
.message-wrap { display: flex; flex-direction: column; gap: 4px; } .message-wrap { display: flex; flex-direction: column; gap: 4px; }
.message-wrap.user { align-items: flex-end; } .message-wrap.user { align-items: flex-end; }
.message-wrap.assistant { align-items: flex-start; } .message-wrap.assistant { align-items: flex-start; }
/* 要約済み(モデルには渡らない)メッセージ。読めるが現役の発言と区別できる程度に落とす */
.message-wrap.archived { opacity: 0.45; }
.message-wrap.archived:hover { opacity: 0.75; }
.archive-divider {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 0;
color: #777;
font-size: 11px;
}
.archive-divider::before,
.archive-divider::after {
content: '';
flex: 1;
border-top: 1px dashed #3a3a3a;
}
.message { display: flex; width: 100%; } .message { display: flex; width: 100%; }
.message.user { justify-content: flex-end; } .message.user { justify-content: flex-end; }
.message.assistant { justify-content: flex-start; } .message.assistant { justify-content: flex-start; }
+13 -1
View File
@@ -2,6 +2,8 @@
import { computed } from 'vue' import { computed } from 'vue'
import { marked } from 'marked' import { marked } from 'marked'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import 'katex/dist/katex.min.css'
import { mathExtension, mathSanitizeConfig } from '../utils/math'
const props = defineProps<{ const props = defineProps<{
content: string content: string
@@ -9,6 +11,7 @@ const props = defineProps<{
}>() }>()
marked.setOptions({ breaks: true, gfm: true }) marked.setOptions({ breaks: true, gfm: true })
marked.use(mathExtension)
function closeOpenFences(src: string): string { function closeOpenFences(src: string): string {
const fenceCount = (src.match(/^```/gm) ?? []).length const fenceCount = (src.match(/^```/gm) ?? []).length
@@ -18,7 +21,7 @@ function closeOpenFences(src: string): string {
const html = computed(() => { const html = computed(() => {
const raw = props.pending ? closeOpenFences(props.content) : props.content const raw = props.pending ? closeOpenFences(props.content) : props.content
return DOMPurify.sanitize(marked.parse(raw) as string) return DOMPurify.sanitize(marked.parse(raw) as string, mathSanitizeConfig)
}) })
</script> </script>
@@ -104,6 +107,15 @@ const html = computed(() => {
/* 水平線 */ /* 水平線 */
.md :deep(hr) { border-color: #333; margin: 10px 0; } .md :deep(hr) { border-color: #333; margin: 10px 0; }
/* 数式 (KaTeX) */
.md :deep(.katex) { font-size: 1.05em; }
.md :deep(.katex-display) {
margin: 10px 0;
padding: 2px 0;
overflow-x: auto;
overflow-y: hidden;
}
/* リンク */ /* リンク */
.md :deep(a) { color: #60a5fa; text-decoration: none; } .md :deep(a) { color: #60a5fa; text-decoration: none; }
.md :deep(a:hover) { text-decoration: underline; } .md :deep(a:hover) { text-decoration: underline; }
+177 -8
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,12 +138,34 @@ 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="`${group.section ?? '_'}-${group.pages[0].id}`"
class="tab-group"
:class="{ named: group.section }"
>
<button
v-if="group.section"
class="section-label"
:title="`「${group.section}」セクション。クリックで名前変更・解除`"
@click="renameSectionGroup(group.section)"
>{{ group.section }}</button>
<div
v-for="page in group.pages"
:key="page.id" :key="page.id"
class="tab" class="tab"
:class="{ active: page.id === store.activeId }" :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)" @click="store.selectPage(page.id)"
@dblclick="startRename(page.id)" @dblclick="startRename(page.id)"
@dragstart="onDragStart(page.id, $event)"
@dragend="onDragEnd"
@dragover.prevent="onDragOverTab(page, $event)"
@drop.prevent="onDropTab(page, $event)"
> >
<input <input
v-if="renamingId === page.id" v-if="renamingId === page.id"
@@ -64,9 +181,18 @@ onUnmounted(() => {
<template v-else> <template v-else>
<span class="tab-title" :title="page.title">{{ page.title }}</span> <span class="tab-title" :title="page.title">{{ page.title }}</span>
<span v-if="store.dirtyIds.has(page.id)" class="dot" 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> <button class="tab-del" title="削除" @click.stop="confirmRemove(page.id, page.title)">×</button>
</template> </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;
+7 -1
View File
@@ -8,6 +8,7 @@ export interface Message {
content: string content: string
pending?: boolean pending?: boolean
error?: boolean error?: boolean
archived?: boolean
tags?: string[] tags?: string[]
sources?: Source[] sources?: Source[]
} }
@@ -55,7 +56,12 @@ export const useChatStore = defineStore('chat', () => {
const records = await fetchMessages(sessionId) const records = await fetchMessages(sessionId)
messages.value = records messages.value = records
.filter((r) => r.role === 'user' || r.role === 'assistant') .filter((r) => r.role === 'user' || r.role === 'assistant')
.map((r) => ({ role: r.role as 'user' | 'assistant', content: r.content, tags: r.tags })) .map((r) => ({
role: r.role as 'user' | 'assistant',
content: r.content,
tags: r.tags,
archived: r.archived_at !== null,
}))
} catch (e) { } catch (e) {
pushToast(`メッセージ取得失敗: ${e instanceof Error ? e.message : e}`) pushToast(`メッセージ取得失敗: ${e instanceof Error ? e.message : e}`)
} }
+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,
} }
}) })
+103
View File
@@ -0,0 +1,103 @@
import katex from 'katex'
import type { MarkedExtension, TokenizerAndRendererExtension, Tokens } from 'marked'
/**
* marked 拡張として数式 (KaTeX) を描画する。
*
* 対応する区切り記号:
* - ブロック: `$$ ... $$` / `\[ ... \]`(単独行)
* - インライン: `$ ... $` / `\( ... \)` / 行中の `$$ ... $$`
*
* `$` はドル記号としても書かれるため、インラインの `$ ... $` だけは
* 誤爆を避けるための条件を付けている(下記 INLINE_DOLLAR 参照)。
* 明示的にドル記号を書きたい場合は Markdown のエスケープ `\$` が使える。
*/
// ブロック: 行頭(インデント 3 まで)から始まる $$ ... $$ / \[ ... \]
const BLOCK_DOLLAR = /^ {0,3}\$\$([\s\S]+?)\$\$[ \t]*(?:\n+|$)/
const BLOCK_BRACKET = /^ {0,3}\\\[([\s\S]+?)\\\][ \t]*(?:\n+|$)/
// インライン: \( ... \) と、段落の途中に置かれた $$ ... $$
const INLINE_PAREN = /^\\\(((?:\\[^]|[^\\])+?)\\\)/
const INLINE_DOUBLE = /^\$\$((?:\\[^]|[^\\])+?)\$\$/
// インライン: $ ... $
// - 開き `$` の直後に空白・`$` が来るものは対象外
// - 中身に改行と裸の `$` を含まない
// - 閉じ `$` の直後が数字のものは対象外("$100から$200" のような金額表記の除外)
// - 中身が空白で終わるものは対象外(tokenizer 側で判定)
const INLINE_DOLLAR = /^\$(?![\s$])((?:\\.|[^\\$\n])+?)\$(?!\d)/
function escapeHtml(src: string): string {
return src
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function renderMath(tex: string, displayMode: boolean, raw: string): string {
try {
return katex.renderToString(tex.trim(), {
displayMode,
throwOnError: false, // 構文エラーは例外にせず赤字で表示する
errorColor: '#f87171', // ダーク背景で読める色にする
strict: false, // 日本語混じりなどの警告を出さない
output: 'htmlAndMathml',
})
} catch {
// KaTeX が想定外の失敗をした場合は元の文字列をそのまま見せる
return escapeHtml(raw)
}
}
const blockMath: TokenizerAndRendererExtension = {
name: 'blockMath',
level: 'block',
start(src: string) {
return src.match(/(?:^|\n) {0,3}(?:\$\$|\\\[)/)?.index
},
tokenizer(src: string) {
const match = BLOCK_DOLLAR.exec(src) ?? BLOCK_BRACKET.exec(src)
if (!match) return undefined
return { type: 'blockMath', raw: match[0], text: match[1] }
},
renderer(token: Tokens.Generic) {
return renderMath(token.text as string, true, token.raw as string)
},
}
const inlineMath: TokenizerAndRendererExtension = {
name: 'inlineMath',
level: 'inline',
start(src: string) {
// エスケープされた \$ は数式の開始とみなさない
const match = /(^|[^\\])(\$|\\\()/.exec(src)
return match ? match.index + match[1].length : undefined
},
tokenizer(src: string) {
const paren = INLINE_PAREN.exec(src)
if (paren) return { type: 'inlineMath', raw: paren[0], text: paren[1], display: false }
const double = INLINE_DOUBLE.exec(src)
if (double) return { type: 'inlineMath', raw: double[0], text: double[1], display: true }
const single = INLINE_DOLLAR.exec(src)
if (single && !/[\s\\]$/.test(single[1])) {
return { type: 'inlineMath', raw: single[0], text: single[1], display: false }
}
return undefined
},
renderer(token: Tokens.Generic) {
return renderMath(token.text as string, token.display === true, token.raw as string)
},
}
export const mathExtension: MarkedExtension = {
extensions: [blockMath, inlineMath],
}
/** KaTeX が出力する MathML 用に DOMPurify へ追加で許可するタグ・属性 */
export const mathSanitizeConfig = {
ADD_TAGS: ['semantics', 'annotation'],
ADD_ATTR: ['encoding'],
}