Author SHA1 Message Date
colcaroliandClaude Opus 5 a08b1785a4 Add shared notes with tabbed pages editable by user and AI
Adds a note_pages table backing a tabbed notes panel where the user and
the model can both read and write. Only page titles (plus a short
preview) are injected into the system prompt on every turn; full page
content is kept out of context and fetched on demand via a read_note
tool, with write_note letting the model append to, replace, or create a
page.

Generalizes llm.resolve_web_search into resolve_tool_calls so the
tool-calling loop handles web_search and the new note tools through one
path. The chat stream emits a notes_updated SSE event when the model
edits a note so the frontend panel can refresh; local edits are tracked
as dirty and never clobbered by that refresh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 01:27:31 +09:00
colcaroliandClaude Opus 5 ef588d99d8 Add session export/import as JSON
Sessions can now be downloaded as a JSON file containing the session
metadata and every message with its tags, and re-imported as a new
session from that same format.

GET /chat/sessions/{id}/export streams the session as a file download.
POST /chat/sessions/import validates the roles, then recreates the
session under a fresh UUID so importing never collides with an existing
session. The sidebar gets a per-row download button and an import button
that feeds a hidden file input.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 01:22:08 +09:00
colcaroliandClaude Sonnet 5 1ad8b130c7 Add site-wide login gate protecting the whole app
Chat/memory/logs/models APIs were reachable without any auth even
though the app is deployed publicly behind Caddy HTTPS; only the
settings screen had a password. Add an optional SITE_PASSWORD-backed
session cookie gate in front of everything (settings included, on
top of its existing SETTINGS_PASSWORD), with a matching login screen
on the frontend. Stays fully backward compatible: with SITE_PASSWORD
unset the app behaves exactly as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 23:04:01 +09:00
colcaroli 002282a8b7 Fix MemoryMonitor blank on GPU-less deployments
gpu_memory_percent is serialized as JSON null (not omitted) when no
GPU is present, but the template checked `!== undefined`, which is
true for null too. Calling .toFixed() on that null then threw during
render, blanking the whole widget instead of just hiding the GPU row.
Only showed up on GPU-less hosts like a Lightsail instance, not on
this dev machine which has a GPU.
2026-08-18 21:48:38 +09:00
colcaroli 20cb449f76 Add Caddy for automatic HTTPS in front of the frontend/backend stack
Caddy now owns ports 80/443 and reverse-proxies to the frontend
container (which was publishing 80 itself, conflicting with Caddy),
handling Let's Encrypt certificate issuance/renewal automatically.
Fix the Caddyfile's upstream name (it referenced a nonexistent "app"
service instead of "frontend") and update the Lightsail deploy guide's
HTTPS section to match this setup instead of the old manual-certbot
approach.
2026-08-18 21:22:53 +09:00
28 changed files with 1338 additions and 85 deletions
+6
View File
@@ -6,6 +6,12 @@
"runtimeExecutable": "npm",
"runtimeArgs": ["--prefix", "frontend", "run", "dev"],
"port": 5173
},
{
"name": "backend-dev",
"runtimeExecutable": "backend\\run_dev.cmd",
"runtimeArgs": [],
"port": 8000
}
]
}
+16 -3
View File
@@ -59,8 +59,8 @@ Copy-Item .env.example .env
frontend (Vue3 + Pinia, :5173)
↕ REST + SSE (Vite proxy)
backend (FastAPI, :8000)
├── app/api/ チャット・メモリ・ログ のルーター
├── app/core/ LLM ラッパー・メモリロジック・設定
├── app/api/ チャット・メモリ・ノート・ログ のルーター
├── app/core/ LLM ラッパー・メモリロジック・共有ノート・設定
└── app/db/ SQLAlchemy モデル・DB 接続
↕
SQLite (playchat.db) + LiteLLM → 任意の LLM
@@ -71,9 +71,19 @@ SQLite (playchat.db) + LiteLLM → 任意の LLM
- **短期**: セッション内の全メッセージをコンテキストに含める
- **長期**: メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(デフォルト 20)を超えると古い半分を LLM で要約 → `memories` テーブルに保存 → 次回以降のシステムプロンプトに注入
### 共有ノート
ユーザーと AI が共同編集するノート。`note_pages` テーブルに 1 ページ 1 行で保存し、UI ではタブとして切り替える。
- **モデルから見える範囲**: システムプロンプトに入るのは**ページのタイトル一覧**(と本文の先頭 40 文字程度のプレビュー)だけ。本文全体は含めない
- **モデルによる読み書き**: `read_note` / `write_note` ツール(`app/core/notes.py`)で行う。`write_note` は存在しないタイトルを指定するとページを新規作成し、`mode` が `append`(既定)なら追記、`replace` なら全置換
- **ツール実行の流れ**: `llm.resolve_tool_calls()` が本番の応答生成前にツール呼び出しを解決する。`web_search` と共通の仕組みで、ノート系ツールは `db` が渡されたときだけモデルに提示される
- **UI への反映**: AI がノートを書き換えた場合、SSE で `notes_updated` を送ってフロント側のノートパネルを再読み込みさせる
- **編集の衝突回避**: フロントは未保存の編集があるページ(`dirtyIds`)をサーバーの値で上書きしない
### SSE ストリーミング
`POST /chat/stream` が SSE を返す。イベント種別: `session_id`(新規セッション ID)/ `chunk`(テキスト断片)/ `done`(完了)/ `error`(エラー)。
`POST /chat/stream` が SSE を返す。イベント種別: `session_id`(新規セッション ID)/ `chunk`(テキスト断片)/ `sources`(Web 検索の出典)/ `notes_updated`(AI が共有ノートを更新)/ `done`(完了)/ `error`(エラー)。
フロントは `src/api/client.ts` の `streamChat()` で fetch + ReadableStream により受信し、`messages.value[msgIdx]` を reactive proxy 経由で更新する。
### Markdown レンダリング
@@ -107,3 +117,6 @@ SQLite (playchat.db) + LiteLLM → 任意の LLM
| `OPENAI_API_KEY` | — | OpenAI API キー |
| `ANTHROPIC_API_KEY` | — | Anthropic API キー |
| `OLLAMA_API_BASE` | `http://localhost:11434` | Ollama エンドポイント |
| `NOTES_TOOL_CONTENT_LIMIT` | `4000` | `read_note` が 1 回に返す本文の最大文字数 |
| `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) |
| `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 |
+3
View File
@@ -0,0 +1,3 @@
colcaroli-playchat.duckdns.org {
reverse_proxy frontend:80
}
+11
View File
@@ -21,6 +21,17 @@ DEBUG=false
# HTTPS化していない環境ではBasic認証の資格情報が平文で流れるため、公開環境ではHTTPS化とセットで使うこと。
SETTINGS_PASSWORD=
# サイト全体(チャット含む全機能)を保護する簡易パスワード。SETTINGS_PASSWORDとは別物。
# 未設定だとこれまで通りサイト全体が無認証で使える。外部公開する場合は必ず設定すること。
SITE_PASSWORD=
# セッションCookieの署名鍵。未設定でも動くが、その場合バックエンド再起動のたびに全員ログインし直しになる。
# 公開環境では `openssl rand -hex 32` などで生成した値を設定しておくこと。
SESSION_SECRET_KEY=
# ローカルのhttp開発環境(HTTPS化していない)でログインが効かない場合のみ false にする。公開環境では変更しないこと。
SESSION_COOKIE_SECURE=true
# ログインセッションの有効日数
SESSION_MAX_AGE_DAYS=30
# Tavily Web検索(未設定だとweb_searchツールは何も検索せず空振りする)
TAVILY_API_KEY=
# 蓄積知識をシステムプロンプトに注入する際の最大件数
+50
View File
@@ -0,0 +1,50 @@
import secrets
from fastapi import APIRouter, HTTPException, Request, Response
from pydantic import BaseModel
from app.core.config import SESSION_COOKIE_SECURE, SESSION_MAX_AGE_DAYS, SITE_PASSWORD
from app.core.site_auth import COOKIE_NAME, create_session_token, verify_session_token
router = APIRouter(prefix="/api/auth", tags=["auth"])
class LoginRequest(BaseModel):
password: str
class AuthStatus(BaseModel):
required: bool
authenticated: bool
@router.get("/status", response_model=AuthStatus)
def status(request: Request):
if not SITE_PASSWORD:
return AuthStatus(required=False, authenticated=True)
token = request.cookies.get(COOKIE_NAME)
return AuthStatus(required=True, authenticated=bool(token and verify_session_token(token)))
@router.post("/login", response_model=AuthStatus)
def login(req: LoginRequest, response: Response):
if not SITE_PASSWORD:
raise HTTPException(status_code=403, detail="SITE_PASSWORD is not configured on the server")
if not secrets.compare_digest(req.password, SITE_PASSWORD):
raise HTTPException(status_code=401, detail="Invalid password")
response.set_cookie(
COOKIE_NAME,
create_session_token(),
max_age=SESSION_MAX_AGE_DAYS * 86400,
httponly=True,
secure=SESSION_COOKIE_SECURE,
samesite="lax",
path="/",
)
return AuthStatus(required=True, authenticated=True)
@router.post("/logout", response_model=AuthStatus)
def logout(response: Response):
response.delete_cookie(COOKIE_NAME, path="/")
return AuthStatus(required=bool(SITE_PASSWORD), authenticated=False)
+97 -6
View File
@@ -2,16 +2,17 @@ import asyncio
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from fastapi.responses import StreamingResponse, JSONResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session as DBSession
from app.db.database import get_db
from app.db.models import Session, Message
from app.db.models import Session, Message, MessageTag
from app.core.memory_kb import memory as mem
from app.core import llm
from app.core.memory_kb import knowledge
from app.core.memory_kb import local_knowledge
from app.core.notes import NOTE_TOOLS
from app.core.search.websearch import WEB_SEARCH_TOOL
from app.core.config import DEFAULT_MODEL
from app.core.memory_kb.tagging import tag_message_by_id
@@ -47,6 +48,25 @@ class MessageResponse(BaseModel):
from_attributes = True
class ImportMessage(BaseModel):
role: str
content: str
created_at: datetime | None = None
tags: list[str] = []
class ImportSessionMeta(BaseModel):
title: str | None = None
model: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class ImportRequest(BaseModel):
session: ImportSessionMeta = ImportSessionMeta()
messages: list[ImportMessage]
@router.get("/sessions", response_model=list[SessionResponse])
def list_sessions(db: DBSession = Depends(get_db)):
return db.query(Session).order_by(Session.updated_at.desc()).all()
@@ -69,6 +89,73 @@ def get_messages(session_id: str, db: DBSession = Depends(get_db)):
]
@router.get("/sessions/{session_id}/export")
def export_session(session_id: str, db: DBSession = Depends(get_db)):
"""セッションのメタデータと全メッセージをJSONファイルとしてダウンロードする。"""
session = db.get(Session, session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
data = {
"session": {
"id": session.id,
"title": session.title,
"model": session.model,
"created_at": session.created_at.isoformat(),
"updated_at": session.updated_at.isoformat(),
},
"messages": [
{
"id": m.id,
"role": m.role,
"content": m.content,
"created_at": m.created_at.isoformat(),
"tags": [t.tag for t in m.tags],
}
for m in session.messages
],
}
return JSONResponse(
content=data,
headers={"Content-Disposition": f'attachment; filename="session_{session.id}.json"'},
)
@router.post("/sessions/import", response_model=SessionResponse, status_code=201)
def import_session(req: ImportRequest, db: DBSession = Depends(get_db)):
"""export で出力したJSON(またはその形式に沿ったJSON)から新規セッションを作成する。"""
if not req.messages:
raise HTTPException(status_code=400, detail="messages is empty")
for m in req.messages:
if m.role not in ("user", "assistant", "system"):
raise HTTPException(status_code=400, detail=f"invalid role: {m.role}")
title = req.session.title or req.messages[0].content[:40]
session = Session(
id=str(uuid.uuid4()),
title=title,
model=req.session.model or DEFAULT_MODEL,
)
if req.session.created_at:
session.created_at = req.session.created_at
session.updated_at = req.session.updated_at or datetime.now(timezone.utc)
db.add(session)
db.flush()
for m in req.messages:
msg = Message(session_id=session.id, role=m.role, content=m.content)
if m.created_at:
msg.created_at = m.created_at
db.add(msg)
db.flush()
for tag in m.tags:
db.add(MessageTag(message_id=msg.id, tag=tag))
db.commit()
db.refresh(session)
return session
@router.delete("/sessions/{session_id}", status_code=204)
def delete_session(session_id: str, db: DBSession = Depends(get_db)):
session = db.get(Session, session_id)
@@ -124,9 +211,12 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
used_model = req.model or session.model
full_response = []
try:
resolved_context, searches = await llm.resolve_web_search(
context, model=used_model, session_id=session.id,
resolved_context, searches, notes_changed = await llm.resolve_tool_calls(
context, model=used_model, session_id=session.id, db=db,
)
if notes_changed:
# AIがノートを書き換えたので、フロントのノートパネルを再読み込みさせる
yield sse({"type": "notes_updated"})
if searches:
sources = [
{"title": r["title"], "url": r["url"]}
@@ -139,9 +229,10 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
knowledge.save_knowledge(db, s["query"], summary, s["results"])
local_knowledge.save_search_result(s["query"], summary, s["results"])
search_tools = [WEB_SEARCH_TOOL] if len(resolved_context) > len(context) else None
# 履歴にtool_callsが含まれる場合、最終呼び出しにも同じツール定義を渡す必要がある
used_tools = [WEB_SEARCH_TOOL, *NOTE_TOOLS] if len(resolved_context) > len(context) else None
async for chunk in llm.stream_chat(
resolved_context, model=used_model, session_id=session.id, tools=search_tools,
resolved_context, model=used_model, session_id=session.id, tools=used_tools,
):
full_response.append(chunk)
yield sse({"type": "chunk", "value": chunk})
+73
View File
@@ -0,0 +1,73 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session as DBSession
from app.core.notes import next_position
from app.db.database import get_db
from app.db.models import NotePage
router = APIRouter(prefix="/api/notes", tags=["notes"])
class NotePageResponse(BaseModel):
id: int
title: str
content: str
position: int
updated_at: datetime
class Config:
from_attributes = True
class NotePageCreate(BaseModel):
title: str = "新しいページ"
content: str = ""
class NotePageUpdate(BaseModel):
title: str | None = None
content: str | None = None
@router.get("", response_model=list[NotePageResponse])
def list_pages(db: DBSession = Depends(get_db)):
return db.query(NotePage).order_by(NotePage.position, NotePage.id).all()
@router.post("", response_model=NotePageResponse, status_code=201)
def create_page(req: NotePageCreate, db: DBSession = Depends(get_db)):
title = req.title.strip() or "新しいページ"
page = NotePage(title=title, content=req.content, position=next_position(db))
db.add(page)
db.commit()
db.refresh(page)
return page
@router.patch("/{page_id}", response_model=NotePageResponse)
def update_page(page_id: int, req: NotePageUpdate, db: DBSession = Depends(get_db)):
page = db.get(NotePage, page_id)
if not page:
raise HTTPException(status_code=404, detail="Note page not found")
if req.title is not None:
title = req.title.strip()
if not title:
raise HTTPException(status_code=400, detail="title is empty")
page.title = title
if req.content is not None:
page.content = req.content
db.commit()
db.refresh(page)
return page
@router.delete("/{page_id}", status_code=204)
def delete_page(page_id: int, db: DBSession = Depends(get_db)):
page = db.get(NotePage, page_id)
if not page:
raise HTTPException(status_code=404, detail="Note page not found")
db.delete(page)
db.commit()
+13
View File
@@ -1,5 +1,6 @@
from dotenv import load_dotenv
import os
import secrets
load_dotenv()
@@ -12,6 +13,14 @@ DEBUG: bool = os.getenv("DEBUG", "false").lower() in ("1", "true", "yes")
# GUI設定画面(/api/settings)を保護する簡易パスワード。未設定の場合、設定画面は常に403で拒否する。
SETTINGS_PASSWORD: str | None = os.getenv("SETTINGS_PASSWORD")
# サイト全体(設定画面以外の全API)を保護する簡易パスワード。未設定の場合はこれまで通り無認証で動く。
SITE_PASSWORD: str | None = os.getenv("SITE_PASSWORD")
# セッションCookieの署名に使う秘密鍵。未設定なら起動毎に使い捨てキーを生成する(バックエンド再起動でログインし直しになる)。
SESSION_SECRET_KEY: str = os.getenv("SESSION_SECRET_KEY") or secrets.token_hex(32)
# ローカルのhttp開発環境ではSecure Cookieが送信されないため、その場合のみfalseにする。公開環境では必ずtrueのままにすること。
SESSION_COOKIE_SECURE: bool = os.getenv("SESSION_COOKIE_SECURE", "true").lower() in ("1", "true", "yes")
SESSION_MAX_AGE_DAYS: int = int(os.getenv("SESSION_MAX_AGE_DAYS", "30"))
# メッセージへの自動タグ付けに使うモデル(未設定ならDEFAULT_MODELを使用)
TAGGING_MODEL: str | None = os.getenv("TAGGING_MODEL")
@@ -28,6 +37,10 @@ LOCAL_KB_EMBEDDING_MODEL: str = os.getenv("LOCAL_KB_EMBEDDING_MODEL", "text-embe
LOCAL_KB_TOP_K: int = int(os.getenv("LOCAL_KB_TOP_K", "3"))
LOCAL_KB_MIN_SCORE: float = float(os.getenv("LOCAL_KB_MIN_SCORE", "0.75"))
# 共有ノート(ユーザーとAIが共同編集するノート)
# read_note ツールが1回に返す本文の最大文字数
NOTES_TOOL_CONTENT_LIMIT: int = int(os.getenv("NOTES_TOOL_CONTENT_LIMIT", "4000"))
# Amazon Bedrock
# 以下はすべて LiteLLM(boto3) が os.environ から直接読み取るため、.env に書くだけで自動的に有効になる。
# IAM経由(推奨): AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY を設定する。
+26 -10
View File
@@ -2,13 +2,17 @@ import json
import logging
import time
import traceback
from typing import AsyncIterator, AsyncGenerator
from typing import TYPE_CHECKING, AsyncIterator, AsyncGenerator
from contextlib import asynccontextmanager
import litellm
from app.core.config import DEFAULT_MODEL, DEBUG
from app.core.notes import NOTE_TOOLS, execute_note_tool
from app.core.search.websearch import WEB_SEARCH_TOOL, search_web
if TYPE_CHECKING:
from sqlalchemy.orm import Session as DBSession
litellm.drop_params = True
if DEBUG:
litellm._turn_on_debug()
@@ -103,32 +107,38 @@ async def embed(texts: list[str], model: str) -> list[list[float]]:
return [item["embedding"] for item in response.data]
async def resolve_web_search(
async def resolve_tool_calls(
messages: list[dict],
model: str | None = None,
session_id: str | None = None,
db: "DBSession | None" = None,
max_rounds: int = 2,
) -> tuple[list[dict], list[dict]]:
"""必要に応じてLLMにweb_searchツールを呼ばせ、検索結果を会話に組み込んだ上でメッセージ列を返す。
) -> tuple[list[dict], list[dict], bool]:
"""必要に応じてLLMにツールを呼ばせ、その結果を会話に組み込んだ上でメッセージ列を返す。
モデルがtool callingに対応していない場合は何もせず元のmessagesをそのまま返す。
戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト)
扱うツールは web_search と共有ノート(read_note / write_note)。ノート系は db が
渡されたときだけ提示する。モデルがtool callingに対応していない場合は何もせず
元のmessagesをそのまま返す。
戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト, ノートを変更したか)
"""
used_model = model or DEFAULT_MODEL
working_messages = list(messages)
searches: list[dict] = []
notes_changed = False
tools = [WEB_SEARCH_TOOL] + (NOTE_TOOLS if db is not None else [])
for _ in range(max_rounds):
try:
response = await litellm.acompletion(
model=used_model,
messages=working_messages,
tools=[WEB_SEARCH_TOOL],
tools=tools,
tool_choice="auto",
)
except Exception:
logger.info("Model %s does not support tool calling; skipping web search", used_model)
return messages, searches
logger.info("Model %s does not support tool calling; skipping tools", used_model)
return messages, searches, notes_changed
choice_message = response.choices[0].message
tool_calls = getattr(choice_message, "tool_calls", None)
@@ -153,6 +163,12 @@ async def resolve_web_search(
args = json.loads(tc.function.arguments or "{}")
except json.JSONDecodeError:
args = {}
name = tc.function.name
if name in ("read_note", "write_note") and db is not None:
tool_text, changed = execute_note_tool(db, name, args)
notes_changed = notes_changed or changed
else:
query = args.get("query", "")
results = await search_web(query)
if results:
@@ -167,4 +183,4 @@ async def resolve_web_search(
"content": tool_text,
})
return working_messages, searches
return working_messages, searches, notes_changed
+6
View File
@@ -5,6 +5,7 @@ from app.core import llm
from app.core.memory_kb import knowledge
from app.core.memory_kb import local_knowledge
from app.core.memory_kb.tagging import tag_memory
from app.core import notes
async def get_context_messages(db: DBSession, session_id: str) -> list[dict]:
@@ -18,6 +19,11 @@ async def get_context_messages(db: DBSession, session_id: str) -> list[dict]:
if knowledge_context:
context.append({"role": "system", "content": knowledge_context})
# 共有ノートは「タイトル一覧」だけを常時見せ、本文は read_note ツールで読ませる
notes_context = notes.get_notes_context(db)
if notes_context:
context.append({"role": "system", "content": notes_context})
if messages:
latest_user_message = next((m.content for m in reversed(messages) if m.role == "user"), "")
local_kb_context = await local_knowledge.get_local_kb_context(db, latest_user_message)
+171
View File
@@ -0,0 +1,171 @@
"""ユーザーとAIが共同で編集する共有ノート。
システムプロンプトにはページの「タイトル一覧」だけを注入し、本文はモデルが
read_note ツールで必要なときだけ読み出す。書き込みは write_note ツールで行う。
"""
import logging
import re
from sqlalchemy.orm import Session as DBSession
from app.core.config import NOTES_TOOL_CONTENT_LIMIT
from app.db.models import NotePage
logger = logging.getLogger(__name__)
READ_NOTE_TOOL = {
"type": "function",
"function": {
"name": "read_note",
"description": "共有ノートの特定ページの本文を読み出す。システムプロンプトに提示されたタイトル一覧の中から、"
"今の会話に関係しそうなページがあるときに使う。",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "読み出すページのタイトル(タイトル一覧に載っているもの)"},
},
"required": ["title"],
},
},
}
WRITE_NOTE_TOOL = {
"type": "function",
"function": {
"name": "write_note",
"description": "共有ノートのページに書き込む。ユーザーからノートへの記録・追記・整理を頼まれたときに使う。"
"存在しないタイトルを指定した場合は新しいページを作成する。"
"既存ページを replace で上書きすると元の内容は失われるので、追記でよい場合は必ず append を使うこと。",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "書き込む対象のページタイトル"},
"content": {"type": "string", "description": "書き込む本文(Markdown可)"},
"mode": {
"type": "string",
"enum": ["append", "replace"],
"description": "append=末尾に追記(既定) / replace=ページ全体を置き換え",
},
},
"required": ["title", "content"],
},
},
}
NOTE_TOOLS = [READ_NOTE_TOOL, WRITE_NOTE_TOOL]
def _find_page(db: DBSession, title: str) -> NotePage | None:
"""タイトルでページを探す。完全一致 → 大文字小文字・前後空白を無視した一致 の順に試す。"""
pages = db.query(NotePage).order_by(NotePage.position, NotePage.id).all()
for page in pages:
if page.title == title:
return page
normalized = title.strip().casefold()
for page in pages:
if page.title.strip().casefold() == normalized:
return page
return None
def next_position(db: DBSession) -> int:
"""新規ページを末尾に置くための position を返す。"""
last = db.query(NotePage).order_by(NotePage.position.desc()).first()
return (last.position + 1) if last else 0
def get_notes_context(db: DBSession) -> str | None:
"""共有ノートのタイトル一覧をシステムプロンプト用のテキストとして返す。"""
pages = db.query(NotePage).order_by(NotePage.position, NotePage.id).all()
lines = [
"【共有ノート】",
"ユーザーとあなたが共同で編集できるノートです。以下のページがあります。",
"",
]
if pages:
for page in pages:
preview = " ".join(page.content.split())[:40]
suffix = f" — {preview}…" if preview else " — (空)"
lines.append(f"- {page.title}{suffix}")
else:
lines.append("- (まだページがありません)")
lines += [
"",
"本文はここには含まれていません。内容が必要なときは read_note ツールで読み出してください。",
"ノートへの記録・追記を頼まれたときは write_note ツールで書き込んでください"
"(新しいタイトルを指定すればページが新規作成されます)。",
"ユーザーが明示的に頼んでいないのに勝手に書き込まないこと。",
]
return "\n".join(lines)
def read_note(db: DBSession, title: str) -> str:
"""ページ本文をツール結果用のテキストとして返す。"""
page = _find_page(db, title)
if not page:
available = ", ".join(p.title for p in db.query(NotePage).order_by(NotePage.position, NotePage.id).all())
return f"「{title}」というページはありません。現在のページ: {available or '(なし)'}"
if not page.content.strip():
return f"ページ「{page.title}」は空です。"
content = page.content
if len(content) > NOTES_TOOL_CONTENT_LIMIT:
content = content[:NOTES_TOOL_CONTENT_LIMIT] + "\n…(以下省略)"
return f"ページ「{page.title}」の本文:\n\n{content}"
_LIST_ITEM = re.compile(r"^\s*([-*+]|\d+[.)])\s")
def _append(existing: str, addition: str) -> str:
"""既存本文に追記する。箇条書き同士をつなぐ場合だけ空行を挟まない。"""
if not existing.strip():
return addition
body = existing.rstrip()
last_line = body.rsplit("\n", 1)[-1]
first_line = addition.lstrip("\n").split("\n", 1)[0]
separator = "\n" if _LIST_ITEM.match(last_line) and _LIST_ITEM.match(first_line) else "\n\n"
return f"{body}{separator}{addition}"
def write_note(db: DBSession, title: str, content: str, mode: str = "append") -> str:
"""ページに書き込む。存在しなければ新規作成する。"""
if not title.strip():
return "タイトルが空のため書き込めませんでした。"
page = _find_page(db, title)
if page is None:
page = NotePage(title=title.strip(), content=content, position=next_position(db))
db.add(page)
db.commit()
return f"新しいページ「{page.title}」を作成し、本文を書き込みました。"
if mode == "replace":
page.content = content
result = f"ページ「{page.title}」の内容を置き換えました。"
else:
page.content = _append(page.content, content)
result = f"ページ「{page.title}」に追記しました。"
db.commit()
return result
def execute_note_tool(db: DBSession, name: str, args: dict) -> tuple[str, bool]:
"""ノート系ツールを実行する。戻り値: (ツール結果テキスト, ノートを変更したか)。"""
try:
if name == "read_note":
return read_note(db, args.get("title", "")), False
if name == "write_note":
result = write_note(
db,
args.get("title", ""),
args.get("content", ""),
args.get("mode", "append"),
)
return result, True
except Exception as e:
db.rollback()
logger.exception("Note tool %s failed", name)
return f"ノート操作に失敗しました: {e}", False
return f"未知のツールです: {name}", False
+40
View File
@@ -0,0 +1,40 @@
import hashlib
import hmac
import time
from fastapi import HTTPException, Request
from app.core.config import SESSION_MAX_AGE_DAYS, SESSION_SECRET_KEY, SITE_PASSWORD
COOKIE_NAME = "playchat_session"
def _sign(payload: str) -> str:
return hmac.new(SESSION_SECRET_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()
def create_session_token() -> str:
issued_at = str(int(time.time()))
return f"{issued_at}.{_sign(issued_at)}"
def verify_session_token(token: str) -> bool:
issued_at, _, signature = token.partition(".")
if not issued_at or not signature:
return False
if not hmac.compare_digest(_sign(issued_at), signature):
return False
age_seconds = time.time() - int(issued_at)
return 0 <= age_seconds <= SESSION_MAX_AGE_DAYS * 86400
def require_site_session(request: Request) -> None:
"""サイト全体(設定画面以外の全API)を保護するセッションCookie認証。
SITE_PASSWORD が未設定の場合は既存のローカル開発体験を壊さないよう常に通す。
"""
if not SITE_PASSWORD:
return
token = request.cookies.get(COOKIE_NAME)
if not token or not verify_session_token(token):
raise HTTPException(status_code=401, detail="Login required")
+16
View File
@@ -104,6 +104,22 @@ class LocalKnowledgeDoc(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
class NotePage(Base):
"""ユーザーとAIが共同で編集する共有ノートの1ページ。UIではタブとして表示する。
ページのタイトル一覧は毎回システムプロンプトに注入され、本文はモデルが
read_note ツールで読み出す(app.core.notes を参照)。
"""
__tablename__ = "note_pages"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String)
content: Mapped[str] = mapped_column(Text, default="")
position: Mapped[int] = mapped_column(Integer, default=0) # タブの並び順
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
class AppSetting(Base):
"""GUIの設定画面から編集する接続情報(APIキー等)。app.core.settings_store が読み書きする。"""
__tablename__ = "app_settings"
+12 -8
View File
@@ -1,14 +1,16 @@
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import chat, logs, memory, system
from app.api import auth as auth_api
from app.api import chat, logs, memory, notes, system
from app.api import models as models_api
from app.api import settings as settings_api
from app.core import settings_store
from app.core.config import CORS_ORIGINS
from app.core.site_auth import require_site_session
from app.core.discord.discord_bot import start_discord_bot
from app.core.discord.scheduler import start_scheduler
from app.core.memory_kb.word_memory import seed_from_history
@@ -88,12 +90,14 @@ app.add_middleware(
)
app.include_router(chat.router)
app.include_router(memory.router)
app.include_router(logs.router)
app.include_router(system.router)
app.include_router(settings_api.router)
app.include_router(models_api.router)
app.include_router(auth_api.router)
app.include_router(chat.router, dependencies=[Depends(require_site_session)])
app.include_router(memory.router, dependencies=[Depends(require_site_session)])
app.include_router(notes.router, dependencies=[Depends(require_site_session)])
app.include_router(logs.router, dependencies=[Depends(require_site_session)])
app.include_router(system.router, dependencies=[Depends(require_site_session)])
app.include_router(settings_api.router, dependencies=[Depends(require_site_session)])
app.include_router(models_api.router, dependencies=[Depends(require_site_session)])
@app.get("/health")
+6
View File
@@ -0,0 +1,6 @@
@echo off
rem SSLKEYLOGFILE がこのマシンのTLS監視プロキシの名前付きパイプを指していると
rem litellm(OpenSSLを直接ロード)が OPENSSL_Uplink エラーで起動時にクラッシュするため、
rem このコマンドの実行中だけ unset する(CLAUDE.md の「ローカル開発環境メモ」参照)。
set SSLKEYLOGFILE=
uv run --directory backend uvicorn app.main:app --reload --port 8000
+15
View File
@@ -20,9 +20,24 @@ services:
restart: unless-stopped
depends_on:
- backend
expose:
- "80"
caddy:
image: caddy:latest
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- frontend
volumes:
backend_data:
backend_knowledge_base:
caddy_data:
caddy_config:
+51
View File
@@ -24,6 +24,7 @@ SSE でストリーミング応答を返す。
|---|---|---|
| `session_id` | セッション UUID | 新規セッション作成直後 |
| `chunk` | テキスト断片 | LLM トークン生成ごと |
| `notes_updated` | — | AI が共有ノートを書き換えた直後 |
| `done` | — | 正常完了 |
| `error` | エラーメッセージ | 例外発生時 |
@@ -88,6 +89,56 @@ SSE でストリーミング応答を返す。
---
## 共有ノート
ユーザーと AI が共同編集するノート。ページのタイトル一覧は毎回システムプロンプトに注入され、
本文は AI が `read_note` / `write_note` ツールで読み書きする。
### GET /api/notes
全ページを `position` 昇順で返す。
**レスポンス**
```json
[
{
"id": 1,
"title": "買い物リスト",
"content": "- 牛乳
- 卵",
"position": 0,
"updated_at": "2026-08-19T12:00:00"
}
]
```
---
### POST /api/notes
ページを新規作成し、`201 Created` で作成したページを返す。
**リクエスト**
```json
{ "title": "新しいページ", "content": "" }
```
---
### PATCH /api/notes/{page_id}
ページのタイトル・本文を更新する。指定したフィールドだけが変更される。
タイトルを空文字にしようとした場合は `400`、ページが存在しない場合は `404` を返す。
**リクエスト**
```json
{ "content": "更新後の本文" }
```
---
### DELETE /api/notes/{page_id}
ページを削除。`204 No Content` を返す。
---
## ログ
### GET /logs
+11
View File
@@ -43,6 +43,7 @@ backend/
| `sessions` | id (UUID), title, model, created_at, updated_at | チャットセッション |
| `messages` | id, session_id, role, content, created_at | 会話メッセージ(短期メモリ) |
| `memories` | id, session_id, summary, embedding_json, created_at | 要約による長期メモリ |
| `note_pages` | id, title, content, position, created_at, updated_at | ユーザーと AI が共同編集する共有ノートのページ |
| `api_logs` | id, session_id, model, request_json, response_text, error, latency_ms, created_at | LLM API 呼び出しログ |
### メモリ機構(`core/memory.py`)
@@ -57,6 +58,15 @@ backend/
- `_log_context` で呼び出しを囲み、成功・失敗どちらも `api_logs` テーブルに記録
- `DEBUG=true` で `litellm._turn_on_debug()` が有効になり詳細ログが出力される
### 共有ノート(`core/notes.py` / `api/notes.py`)
ユーザーと AI が共同編集するノート。`note_pages` の 1 行が UI の 1 タブに対応する。
- `get_notes_context()` が**タイトル一覧のみ**をシステムメッセージとして生成し、`get_context_messages()` が会話コンテキストに差し込む(本文は含めない)
- 本文の読み書きはツール経由: `read_note`(本文取得)/ `write_note`(追記・置換・新規作成)
- `llm.resolve_tool_calls()` が `web_search` と同じループでこれらを解決する。ノート系ツールは `db` を渡したときのみモデルに提示される
- REST(`/api/notes`)はフロントのノートパネル用。GET 一覧 / POST 作成 / PATCH 更新 / DELETE 削除
### SSE ストリーミング(`api/chat.py`)
`POST /chat/stream` は `StreamingResponse` で以下のイベントを順番に送出する:
@@ -64,6 +74,7 @@ backend/
```
data: {"type":"session_id","value":"<uuid>"} # 新規セッション時のみ
data: {"type":"chunk","value":"テキスト断片"} # 0回以上
data: {"type":"notes_updated"} # AIが共有ノートを更新した時のみ
data: {"type":"done"} # 正常完了
data: {"type":"error","value":"エラー内容"} # 例外発生時
```
+48 -15
View File
@@ -10,6 +10,9 @@ Lightsail Containers(マネージドコンテナサービス)よりも、状
Internet
│ :80 / :443
▼
[caddy コンテナ] Let's Encrypt証明書の自動取得・更新、HTTP→HTTPSリダイレクト
│ :80(コンテナ間通信のみ、ホストには公開しない)
▼
[frontend コンテナ] nginx
├─ 静的ファイル (Vue ビルド成果物) を配信
└─ /chat, /api, /memory, /logs, /health を backend にリバースプロキシ
@@ -26,7 +29,7 @@ Internet
- [backend/Dockerfile](../backend/Dockerfile)
- [frontend/Dockerfile](../frontend/Dockerfile) / [frontend/nginx.conf](../frontend/nginx.conf)
- [docker-compose.yml](../docker-compose.yml)
- [docker-compose.yml](../docker-compose.yml) / [Caddyfile](../Caddyfile)
## 1. Lightsail インスタンスの作成
@@ -39,7 +42,7 @@ Internet
作成後、インスタンス詳細ページの「ネットワーキング」タブで:
- **静的 IP をアタッチ**(再起動してもIPが変わらないようにする。ドメインを使う場合は必須)
- ファイアウォールで **80 (HTTP)** と、HTTPS を使うなら **443** を許可(22/SSH はデフォルトで開いている)
- ファイアウォールで **80 (HTTP)** と **443 (HTTPS)** を許可(Caddyの証明書取得とHTTPS配信に両方必要。22/SSH はデフォルトで開いている)
## 2. SSH 接続 & Docker のインストール
@@ -117,7 +120,7 @@ nano backend/.env # APIキーやモデル名など本番用の値を設定
SQLite(`AppSetting`テーブル、`backend_data`ボリュームで永続化)に保存され、`.env`の値より優先される。
**注意:** このパスワード保護は簡易的なものであり、HTTP平文通信の場合はBasic認証の資格情報がネットワーク上に
そのまま流れてしまう。この機能を使うなら[6. HTTPS化](#6-https-化ドメインがある場合)と合わせて運用すること。
そのまま流れてしまう。この機能を使うなら[6. HTTPS化](#6-https-化caddy)と合わせて運用すること。
## 4. ビルド & 起動
@@ -127,7 +130,9 @@ docker compose up -d
docker compose logs -f backend # 起動ログ確認(Ctrl+C で抜ける。プロセスは動き続ける)
```
ブラウザで `http://<静的IP>/` にアクセスして動作確認する。
`docker-compose.yml`にはCaddyが含まれており、80/443番ポートはCaddyが受け持つ(`frontend`は直接公開しない)。
Caddyfileにはドメイン名を設定する前提のため、動作確認は静的IPへの直接アクセスではなく
[6. HTTPS化](#6-https-化caddy)の手順でドメイン経由で行うこと。
停止・再起動・更新の運用コマンド:
@@ -153,21 +158,49 @@ docker cp $(docker compose ps -q backend):/app/data/backup.db ./playchat-backup-
インスタンスごとのバックアップとしては、Lightsail の「スナップショット」機能(自動スナップショット設定可)も併用すると安心。
## 6. HTTPS 化(ドメインがある場合)
## 6. HTTPS 化(Caddy)
ドメインの A レコードを Lightsail の静的 IP に向けた上で、ホスト側で certbot を使い証明書を取得し、
`frontend` コンテナへ読み取り専用でマウントする。
[Caddy](https://caddyserver.com/) をリバースプロキシとして`frontend`の手前に置き、証明書の取得・更新を
自動化する(Let's Encrypt、ACME HTTP-01 チャレンジ)。手動でのcertbot操作は不要。
```bash
sudo apt install -y certbot
docker compose stop frontend # 80番ポートを一時的に空ける
sudo certbot certonly --standalone -d your-domain.example.com
docker compose start frontend
構成:
```
Internet
│ :80 / :443
▼
[caddy コンテナ] ← Let's Encrypt証明書の自動取得・更新、HTTP→HTTPSリダイレクト
│ :80 (コンテナ間通信、ホストには公開しない)
▼
[frontend コンテナ] nginx(静的配信 + backendへのAPIプロキシ)
▼
[backend コンテナ]
```
`frontend/nginx.conf` に 443 用のサーバーブロックを追記し、`docker-compose.yml` の `frontend` サービスに
`443:443` のポートマッピングと `/etc/letsencrypt` のボリュームマウントを追加する(証明書更新時は `docker compose restart frontend` が必要。
`certbot renew` の `--deploy-hook` に登録しておくと自動化できる)。この構成に変更したい場合は改めて対応する。
`frontend`はもう80番ポートをホストに公開せず(`expose`のみ)、代わりに`caddy`が80/443を受けて
`frontend`へ内部転送する。ルートの [Caddyfile](../Caddyfile) にドメインを設定する:
```
your-domain.example.com {
reverse_proxy frontend:80
}
```
前提条件:
- ドメインの A レコード(IPv4)を Lightsail の静的 IP に向けておく(DuckDNS等の無料動的DNSでも可)
- Lightsail ファイアウォールで **443 (HTTPS)** を許可([1. Lightsail インスタンスの作成](#1-lightsail-インスタンスの作成)で追加済みのはず)
起動すると、Caddyがドメイン名を見て自動的にACMEチャレンジ(ポート80経由)で証明書を取得し、
以後は自動更新される。証明書は`caddy_data`ボリュームに永続化されるので、`docker compose down`
(`-v`無し)では消えない。
```bash
docker compose up -d
docker compose logs -f caddy # 証明書取得ログを確認("certificate obtained successfully" 等)
```
`https://your-domain.example.com/` にアクセスして確認する。
## 7. 常駐タスクについて
+32 -2
View File
@@ -1,25 +1,45 @@
<script setup lang="ts">
import { ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import SessionList from './components/SessionList.vue'
import ChatWindow from './components/ChatWindow.vue'
import MessageInput from './components/MessageInput.vue'
import ModelSelector from './components/ModelSelector.vue'
import LogViewer from './components/LogViewer.vue'
import SettingsPanel from './components/SettingsPanel.vue'
import NotePanel from './components/NotePanel.vue'
import ToastList from './components/ToastList.vue'
import MemoryMonitor from './components/MemoryMonitor.vue'
import Clock from './components/Clock.vue'
import WordPool from './components/WordPool.vue'
import LoginGate from './components/LoginGate.vue'
import { useAuthStore } from './stores/auth'
type SidePanel = 'none' | 'logs' | 'settings'
const authStore = useAuthStore()
type SidePanel = 'none' | 'logs' | 'settings' | 'notes'
const activePanel = ref<SidePanel>('none')
function togglePanel(panel: SidePanel) {
activePanel.value = activePanel.value === panel ? 'none' : panel
}
function handleUnauthorized() {
authStore.markUnauthenticated()
}
onMounted(() => {
authStore.checkStatus()
window.addEventListener('playchat:unauthorized', handleUnauthorized)
})
onUnmounted(() => {
window.removeEventListener('playchat:unauthorized', handleUnauthorized)
})
</script>
<template>
<template v-if="authStore.loading" />
<LoginGate v-else-if="authStore.required && !authStore.authenticated" />
<template v-else>
<div class="app">
<div class="sidebar">
<SessionList />
@@ -31,12 +51,18 @@ function togglePanel(panel: SidePanel) {
<span class="title">PlayChat</span>
<ModelSelector />
<Clock />
<button class="log-toggle" :class="{ active: activePanel === 'notes' }" @click="togglePanel('notes')">
ノート
</button>
<button class="log-toggle" :class="{ active: activePanel === 'settings' }" @click="togglePanel('settings')">
設定
</button>
<button class="log-toggle" :class="{ active: activePanel === 'logs' }" @click="togglePanel('logs')">
ログ
</button>
<button v-if="authStore.required" class="log-toggle" @click="authStore.logout()">
ログアウト
</button>
</header>
<div class="body">
<div class="chat-pane">
@@ -49,11 +75,15 @@ function togglePanel(panel: SidePanel) {
<div v-else-if="activePanel === 'settings'" class="log-pane">
<SettingsPanel />
</div>
<div v-else-if="activePanel === 'notes'" class="log-pane">
<NotePanel />
</div>
</div>
</div>
</div>
<ToastList />
</template>
</template>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+96 -1
View File
@@ -22,7 +22,7 @@ export interface Source {
}
export interface SSEChunk {
type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources'
type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources' | 'notes_updated'
value?: string | Source[]
}
@@ -34,6 +34,7 @@ async function parseJson<T>(res: Response, label: string): Promise<T> {
throw new Error(`${label}: バックエンドに接続できません (${res.status} ${res.statusText})`)
}
if (!res.ok) {
if (res.status === 401) window.dispatchEvent(new Event('playchat:unauthorized'))
const body = await res.json().catch(() => ({}))
throw new Error(`${label}: ${body.detail ?? res.statusText}`)
}
@@ -62,6 +63,39 @@ export async function deleteSession(sessionId: string): Promise<void> {
await fetch(`${BASE}/chat/sessions/${sessionId}`, { method: 'DELETE' })
}
export function exportSession(sessionId: string): void {
const a = document.createElement('a')
a.href = `${BASE}/chat/sessions/${sessionId}/export`
a.download = `session_${sessionId}.json`
document.body.appendChild(a)
a.click()
a.remove()
}
export interface ImportPayload {
session?: {
title?: string | null
model?: string | null
created_at?: string | null
updated_at?: string | null
}
messages: {
role: string
content: string
created_at?: string | null
tags?: string[]
}[]
}
export async function importSession(payload: ImportPayload): Promise<SessionInfo> {
const res = await fetch(`${BASE}/chat/sessions/import`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
return parseJson<SessionInfo>(res, 'インポート')
}
export async function fetchLogs(params: {
session_id?: string
status?: 'ok' | 'error'
@@ -81,6 +115,29 @@ export async function deleteLog(logId: number): Promise<void> {
await fetch(`${BASE}/logs/${logId}`, { method: 'DELETE' })
}
export interface AuthStatus {
required: boolean
authenticated: boolean
}
export async function fetchAuthStatus(): Promise<AuthStatus> {
const res = await fetch(`${BASE}/api/auth/status`)
return parseJson<AuthStatus>(res, '認証状態取得')
}
export async function login(password: string): Promise<AuthStatus> {
const res = await fetch(`${BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
return parseJson<AuthStatus>(res, 'ログイン')
}
export async function logout(): Promise<void> {
await fetch(`${BASE}/api/auth/logout`, { method: 'POST' })
}
export interface SettingEntry {
key: string
is_set: boolean
@@ -200,3 +257,41 @@ export function streamChat(
return () => controller.abort()
}
export interface NotePage {
id: number
title: string
content: string
position: number
updated_at: string
}
export async function fetchNotePages(): Promise<NotePage[]> {
const res = await fetch(`${BASE}/api/notes`)
return parseJson<NotePage[]>(res, 'ノート取得')
}
export async function createNotePage(title: string): Promise<NotePage> {
const res = await fetch(`${BASE}/api/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
})
return parseJson<NotePage>(res, 'ページ作成')
}
export async function updateNotePage(
pageId: number,
patch: { title?: string; content?: string },
): Promise<NotePage> {
const res = await fetch(`${BASE}/api/notes/${pageId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
return parseJson<NotePage>(res, 'ページ更新')
}
export async function deleteNotePage(pageId: number): Promise<void> {
await fetch(`${BASE}/api/notes/${pageId}`, { method: 'DELETE' })
}
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useAuthStore } from '../stores/auth'
const store = useAuthStore()
const passwordInput = ref('')
async function submit() {
const ok = await store.login(passwordInput.value)
if (ok) passwordInput.value = ''
}
</script>
<template>
<div class="login-gate">
<form class="card" @submit.prevent="submit">
<span class="title">PlayChat</span>
<p class="hint">サイトを利用するにはパスワードが必要です。</p>
<input
v-model="passwordInput"
type="password"
placeholder="パスワード"
autocomplete="current-password"
autofocus
/>
<button type="submit" :disabled="store.loading || !passwordInput">
{{ store.loading ? '確認中...' : 'ログイン' }}
</button>
<p v-if="store.error" class="error">{{ store.error }}</p>
</form>
</div>
</template>
<style scoped>
.login-gate {
height: 100vh; display: flex; align-items: center; justify-content: center;
background: #111;
}
.card {
display: flex; flex-direction: column; gap: 10px;
width: 280px; padding: 24px; background: #1a1a1a;
border: 1px solid #2a2a2a; border-radius: 10px;
}
.title { font-size: 18px; font-weight: 600; color: #fff; }
.hint { font-size: 12px; color: #888; }
input {
background: #2a2a2a; color: #eee; border: 1px solid #444;
border-radius: 6px; padding: 8px 10px; font-size: 13px; width: 100%;
}
button {
background: #1d3557; color: #93c5fd; border: 1px solid #2563eb;
border-radius: 6px; padding: 8px 10px; font-size: 13px; cursor: pointer;
}
button:disabled { opacity: 0.5; cursor: default; }
.error { font-size: 12px; color: #f87171; }
</style>
+1 -1
View File
@@ -60,7 +60,7 @@ onUnmounted(() => {
<span class="value">{{ stats.memory_percent.toFixed(1) }}% ({{ formatMemory(stats.memory_used_mb) }} / {{ formatMemory(stats.memory_total_mb) }})</span>
</div>
<div class="stat" v-if="stats.gpu_memory_percent !== undefined">
<div class="stat" v-if="stats.gpu_memory_percent != null">
<span class="label">GPU</span>
<div class="bar">
<div class="fill gpu" :style="{ width: stats.gpu_memory_percent + '%' }"></div>
+197
View File
@@ -0,0 +1,197 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, nextTick } from 'vue'
import { useNotesStore } from '../stores/notes'
const store = useNotesStore()
const renamingId = ref<number | null>(null)
const renameInput = ref<HTMLInputElement | null>(null)
async function startRename(id: number) {
renamingId.value = id
await nextTick()
renameInput.value?.select()
}
function onTitleInput(id: number, e: Event) {
store.edit(id, { title: (e.target as HTMLInputElement).value })
}
function finishRename() {
renamingId.value = null
store.flush()
}
function onContentInput(e: Event) {
if (store.activeId === null) return
store.edit(store.activeId, { content: (e.target as HTMLTextAreaElement).value })
}
async function confirmRemove(id: number, title: string) {
if (!window.confirm(`ページ「${title}」を削除しますか?`)) return
await store.removePage(id)
}
onMounted(() => {
store.load()
})
onUnmounted(() => {
store.flush()
})
</script>
<template>
<div class="note-panel">
<div class="tabs">
<div
v-for="page in store.pages"
:key="page.id"
class="tab"
:class="{ active: page.id === store.activeId }"
@click="store.selectPage(page.id)"
@dblclick="startRename(page.id)"
>
<input
v-if="renamingId === page.id"
ref="renameInput"
class="tab-rename"
:value="page.title"
@input="onTitleInput(page.id, $event)"
@blur="finishRename"
@keydown.enter="finishRename"
@keydown.esc="finishRename"
@click.stop
>
<template v-else>
<span class="tab-title" :title="page.title">{{ page.title }}</span>
<span v-if="store.dirtyIds.has(page.id)" class="dot" title="未保存">●</span>
<button class="tab-del" title="削除" @click.stop="confirmRemove(page.id, page.title)">×</button>
</template>
</div>
<button class="tab-add" title="ページを追加" @click="store.addPage()">+</button>
</div>
<div v-if="store.error" class="error">⚠ {{ store.error }}</div>
<textarea
v-if="store.activePage"
class="editor"
:value="store.activePage.content"
placeholder="ここに書いた内容はAIと共有されます。AIも read_note / write_note でこのページを読み書きできます。"
spellcheck="false"
@input="onContentInput"
@blur="store.flush()"
/>
<div v-else class="empty">
<p>ページがありません。</p>
<button class="create-btn" @click="store.addPage()">+ 最初のページを作成</button>
</div>
<div class="hint">
ページのタイトル一覧はAIのシステムプロンプトに常に含まれます。本文はAIが必要に応じて読み出します。
</div>
</div>
</template>
<style scoped>
.note-panel {
display: flex;
flex-direction: column;
height: 100%;
background: #161616;
}
.tabs {
display: flex;
align-items: stretch;
gap: 2px;
padding: 6px 6px 0;
overflow-x: auto;
flex-shrink: 0;
border-bottom: 1px solid #333;
}
.tab {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 8px;
background: #1f1f1f;
border: 1px solid #333;
border-bottom: none;
border-radius: 6px 6px 0 0;
font-size: 12px;
color: #aaa;
cursor: pointer;
white-space: nowrap;
max-width: 160px;
}
.tab.active { background: #2a2a2a; color: #fff; border-color: #444; }
.tab-title { overflow: hidden; text-overflow: ellipsis; }
.dot { color: #eab308; font-size: 8px; }
.tab-del, .tab-add {
background: none;
border: none;
color: #666;
cursor: pointer;
font-size: 13px;
padding: 0 2px;
line-height: 1;
}
.tab-del:hover, .tab-add:hover { color: #f87171; }
.tab-add { color: #888; padding: 0 8px; }
.tab-add:hover { color: #93c5fd; }
.tab-rename {
background: #111;
border: 1px solid #2563eb;
border-radius: 3px;
color: #eee;
font-size: 12px;
padding: 1px 4px;
width: 110px;
}
.error {
padding: 6px 10px;
font-size: 11px;
color: #fca5a5;
background: #2a1414;
border-bottom: 1px solid #7f1d1d;
}
.editor {
flex: 1;
resize: none;
background: #161616;
color: #ddd;
border: none;
outline: none;
padding: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
line-height: 1.6;
}
.editor::placeholder { color: #555; }
.empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: #666;
font-size: 13px;
}
.create-btn {
background: #2a2a2a;
color: #ccc;
border: 1px solid #444;
border-radius: 6px;
padding: 6px 14px;
font-size: 12px;
cursor: pointer;
}
.hint {
flex-shrink: 0;
padding: 8px 10px;
border-top: 1px solid #2a2a2a;
color: #666;
font-size: 10px;
line-height: 1.5;
}
</style>
+41 -1
View File
@@ -4,6 +4,7 @@ import { useChatStore } from '../stores/chat'
const store = useChatStore()
const error = ref<string | null>(null)
const importInput = ref<HTMLInputElement | null>(null)
async function load() {
error.value = null
@@ -14,6 +15,18 @@ async function load() {
}
}
function triggerImport() {
importInput.value?.click()
}
async function onImportFileChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
await store.importSessionFromFile(file)
}
onMounted(load)
</script>
@@ -21,8 +34,18 @@ onMounted(load)
<aside class="session-list">
<div class="header">
<span>チャット履歴</span>
<div class="header-actions">
<button class="import-btn" title="JSONからインポート" @click="triggerImport">⭱</button>
<button class="new-btn" @click="store.selectSession(null)">+ 新規</button>
</div>
<input
ref="importInput"
type="file"
accept="application/json,.json"
class="hidden-file-input"
@change="onImportFileChange"
>
</div>
<div v-if="error" class="api-error" :title="error">⚠ 接続エラー</div>
<ul>
<li
@@ -32,6 +55,11 @@ onMounted(load)
@click="store.selectSession(s.id)"
>
<span class="title">{{ s.title }}</span>
<button
class="export-btn"
@click.stop="store.downloadSessionLog(s.id)"
title="JSONでエクスポート"
>⭳</button>
<button
class="del-btn"
@click.stop="store.removeSession(s.id)"
@@ -69,7 +97,13 @@ onMounted(load)
border-bottom: 1px solid #7f1d1d;
cursor: default;
}
.new-btn {
.header-actions {
display: flex;
align-items: center;
gap: 6px;
}
.new-btn,
.import-btn {
background: #2a2a2a;
color: #ccc;
border: 1px solid #444;
@@ -78,6 +112,9 @@ onMounted(load)
font-size: 12px;
cursor: pointer;
}
.hidden-file-input {
display: none;
}
ul {
list-style: none;
margin: 0;
@@ -104,6 +141,7 @@ li.active { background: #333; }
font-size: 13px;
color: #ddd;
}
.export-btn,
.del-btn {
background: none;
border: none;
@@ -114,5 +152,7 @@ li.active { background: #333; }
opacity: 0;
transition: opacity 0.15s;
}
.export-btn { font-size: 13px; }
li:hover .export-btn,
li:hover .del-btn { opacity: 1; }
</style>
+51
View File
@@ -0,0 +1,51 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { fetchAuthStatus, login as loginApi, logout as logoutApi } from '../api/client'
export const useAuthStore = defineStore('auth', () => {
const required = ref(false)
const authenticated = ref(false)
const loading = ref(true)
const error = ref<string | null>(null)
async function checkStatus(): Promise<void> {
loading.value = true
try {
const status = await fetchAuthStatus()
required.value = status.required
authenticated.value = status.authenticated
} catch (e) {
error.value = e instanceof Error ? e.message : 'エラーが発生しました'
} finally {
loading.value = false
}
}
async function login(password: string): Promise<boolean> {
loading.value = true
error.value = null
try {
const status = await loginApi(password)
required.value = status.required
authenticated.value = status.authenticated
return true
} catch (e) {
authenticated.value = false
error.value = e instanceof Error ? e.message : 'エラーが発生しました'
return false
} finally {
loading.value = false
}
}
async function logout(): Promise<void> {
await logoutApi().catch(() => {})
authenticated.value = false
}
function markUnauthenticated(): void {
authenticated.value = false
}
return { required, authenticated, loading, error, checkStatus, login, logout, markUnauthenticated }
})
+33 -1
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { useNotesStore } from './notes'
import { ref, computed } from 'vue'
import { fetchSessions, fetchMessages, deleteSession, streamChat, type SessionInfo, type Source } from '../api/client'
import { fetchSessions, fetchMessages, deleteSession, exportSession, importSession, streamChat, type SessionInfo, type Source, type ImportPayload } from '../api/client'
export interface Message {
role: 'user' | 'assistant'
@@ -69,6 +70,32 @@ export const useChatStore = defineStore('chat', () => {
await loadSessions()
}
function downloadSessionLog(sessionId: string): void {
exportSession(sessionId)
}
async function importSessionFromFile(file: File): Promise<void> {
let payload: ImportPayload
try {
const parsed = JSON.parse(await file.text())
if (!Array.isArray(parsed?.messages)) {
throw new Error('messages 配列が見つかりません')
}
payload = parsed as ImportPayload
} catch (e) {
pushToast(`インポート失敗: JSONの読み込みに失敗しました (${e instanceof Error ? e.message : e})`)
return
}
try {
const session = await importSession(payload)
await loadSessions()
await selectSession(session.id)
pushToast(`「${session.title}」をインポートしました`)
} catch (e) {
pushToast(`インポート失敗: ${e instanceof Error ? e.message : e}`)
}
}
function sendMessage(text: string): void {
if (isStreaming.value || !text.trim()) return
@@ -89,6 +116,9 @@ export const useChatStore = defineStore('chat', () => {
messages.value[msgIdx].content += chunk.value as string
} else if (chunk.type === 'sources' && chunk.value) {
messages.value[msgIdx].sources = chunk.value as Source[]
} else if (chunk.type === 'notes_updated') {
// AIが共有ノートを書き換えたので、開いているノートパネルに反映する
useNotesStore().load().catch((e) => pushToast(`ノート更新失敗: ${e.message}`))
} else if (chunk.type === 'done') {
messages.value[msgIdx].pending = false
isStreaming.value = false
@@ -137,6 +167,8 @@ export const useChatStore = defineStore('chat', () => {
loadSessions,
selectSession,
removeSession,
downloadSessionLog,
importSessionFromFile,
sendMessage,
retryMessage,
}
+123
View File
@@ -0,0 +1,123 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import {
fetchNotePages,
createNotePage,
updateNotePage,
deleteNotePage,
type NotePage,
} from '../api/client'
const SAVE_DEBOUNCE_MS = 800
export const useNotesStore = defineStore('notes', () => {
const pages = ref<NotePage[]>([])
const activeId = ref<number | null>(null)
const error = ref<string | null>(null)
/** 未保存の編集があるページID。AIによる更新で上書きしないために使う。 */
const dirtyIds = ref<Set<number>>(new Set())
let saveTimer: number | null = null
const activePage = computed(() => pages.value.find((p) => p.id === activeId.value) ?? null)
async function load(): Promise<void> {
error.value = null
try {
const fetched = await fetchNotePages()
// 編集中のページはローカルの内容を優先し、サーバーの値で潰さない
pages.value = fetched.map((page) => {
if (!dirtyIds.value.has(page.id)) return page
const local = pages.value.find((p) => p.id === page.id)
return local ? { ...page, title: local.title, content: local.content } : page
})
if (activeId.value === null || !pages.value.some((p) => p.id === activeId.value)) {
activeId.value = pages.value[0]?.id ?? null
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'ノートの取得に失敗しました'
}
}
function selectPage(id: number): void {
flush()
activeId.value = id
}
async function addPage(title = '新しいページ'): Promise<void> {
error.value = null
try {
const page = await createNotePage(title)
pages.value.push(page)
activeId.value = page.id
} catch (e) {
error.value = e instanceof Error ? e.message : 'ページの作成に失敗しました'
}
}
async function removePage(id: number): Promise<void> {
error.value = null
try {
await deleteNotePage(id)
dirtyIds.value.delete(id)
pages.value = pages.value.filter((p) => p.id !== id)
if (activeId.value === id) activeId.value = pages.value[0]?.id ?? null
} catch (e) {
error.value = e instanceof Error ? e.message : 'ページの削除に失敗しました'
}
}
/** ローカルの内容を即座に更新し、少し待ってからサーバーへ保存する。 */
function edit(id: number, patch: { title?: string; content?: string }): void {
const page = pages.value.find((p) => p.id === id)
if (!page) return
Object.assign(page, patch)
dirtyIds.value.add(id)
if (saveTimer !== null) clearTimeout(saveTimer)
saveTimer = window.setTimeout(() => {
saveTimer = null
void save(id)
}, SAVE_DEBOUNCE_MS)
}
async function save(id: number): Promise<void> {
const page = pages.value.find((p) => p.id === id)
if (!page || !dirtyIds.value.has(id)) return
const { title, content } = page
try {
const saved = await updateNotePage(id, { title, content })
// 保存の往復中にさらに編集されていた場合は dirty のままにしておく
if (page.title === title && page.content === content) {
dirtyIds.value.delete(id)
page.updated_at = saved.updated_at
}
error.value = null
} catch (e) {
error.value = e instanceof Error ? e.message : 'ノートの保存に失敗しました'
}
}
/** 保留中の保存を直ちに実行する(タブ切替・パネルを閉じる時など)。 */
function flush(): void {
if (saveTimer !== null) {
clearTimeout(saveTimer)
saveTimer = null
}
for (const id of [...dirtyIds.value]) void save(id)
}
return {
pages,
activeId,
activePage,
error,
dirtyIds,
load,
selectPage,
addPage,
removePage,
edit,
flush,
}
})