Add system memory monitoring, Bedrock support, and Discord integrations

- Add CPU/GPU memory monitoring endpoint and frontend widget
- Add Amazon Bedrock model support (API key / inference profiles) plus model selector entries
- Add Discord weather/laundry notification job (JMA forecast + LLM-generated message)
- Add Discord bot that replies to mentions via the configured LLM, with weather-aware context
- Add scheduled "useless tips" job with separate webhook and quiet-hours skip
- Switch FastAPI startup to lifespan handler, log viewer, markdown rendering, and toast UI additions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 00:59:38 +09:00
co-authored by Claude Sonnet 4.6
parent 903d34761c
commit 4a08ba29fa
34 changed files with 5051 additions and 160 deletions
+5 -1
View File
@@ -1,7 +1,11 @@
{
"permissions": {
"allow": [
"PowerShell(Head-Object -n 50)"
"PowerShell(Head-Object -n 50)",
"Bash(ls \"C:\\\\Users\\\\Yuya Suzuki\\\\AppData\\\\Roaming\\\\Claude\\\\\")",
"WebSearch",
"Bash(uv add *)",
"Bash(git check-ignore *)"
]
}
}
+28 -12
View File
@@ -14,13 +14,11 @@ LLM は LiteLLM 経由で OpenAI / Anthropic / Ollama など任意のプロバ
```powershell
Set-Location backend
# 依存インストール (uv 推奨)
uv sync
# または
pip install -e .
# 依存インストール (SSL プロキシ環境では --native-tls が必要)
uv sync --native-tls
# 開発サーバー起動
uvicorn app.main:app --reload
uv run uvicorn app.main:app --reload
# テスト
pytest
@@ -59,10 +57,10 @@ Copy-Item .env.example .env
```
frontend (Vue3 + Pinia, :5173)
↕ REST + SSE
↕ REST + SSE (Vite proxy)
backend (FastAPI, :8000)
├── app/api/ ルーター (chat.py, memory.py)
├── app/core/ ビジネスロジック (llm.py, memory.py, config.py)
├── app/api/ チャット・メモリ・ログ のルーター
├── app/core/ LLM ラッパー・メモリロジック・設定
└── app/db/ SQLAlchemy モデル・DB 接続
↕
SQLite (playchat.db) + LiteLLM → 任意の LLM
@@ -71,14 +69,32 @@ SQLite (playchat.db) + LiteLLM → 任意のLLM
### メモリ機構
- **短期**: セッション内の全メッセージをコンテキストに含める
- **長期**: メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(デフォルト20) を超えると、古い半分をLLMで要約して `memories` テーブルに保存し、次回以降のシステムプロンプトに注入する
- **長期**: メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(デフォルト 20)を超えると古い半分を LLM で要約 → `memories` テーブルに保存 → 次回以降のシステムプロンプトに注入
### SSE ストリーミング
`POST /chat/stream` が SSE を返す。チャンク種別: `session_id`(新規セッションID)、`chunk`(テキスト断片)、`done`(完了)。
フロントは `src/api/client.ts` の `streamChat()` で fetch + ReadableStream により受信。
`POST /chat/stream` が SSE を返す。イベント種別: `session_id`(新規セッション ID)/ `chunk`(テキスト断片)/ `done`(完了)/ `error`(エラー)。
フロントは `src/api/client.ts` の `streamChat()` で fetch + ReadableStream により受信し、`messages.value[msgIdx]` を reactive proxy 経由で更新する。
### Markdown レンダリング
アシスタントメッセージは `marked` + `DOMPurify` でレンダリング。ストリーミング中の未閉じコードフェンスは自動補完してから描画する(`MarkdownContent.vue`)。
### モデル切替
`.env` の `DEFAULT_MODEL` またはリクエストの `model` フィールドで指定。
ローカル Ollama は `ollama/llama3` 形式で指定、`OLLAMA_API_BASE` でエンドポイントを設定。
ローカル Ollama は `ollama/llama3` 形式、`OLLAMA_API_BASE` でエンドポイントを設定。
`DEBUG=true` で LiteLLM の詳細ログが有効になる。
## 環境変数(backend/.env)
| 変数 | デフォルト | 説明 |
|---|---|---|
| `DEFAULT_MODEL` | `gpt-5.4` | デフォルト LLM モデル |
| `DATABASE_URL` | `sqlite:///./playchat.db` | DB 接続先 |
| `MEMORY_SUMMARIZE_THRESHOLD` | `20` | 長期メモリ化するメッセージ数の閾値 |
| `CORS_ORIGINS` | `http://localhost:5173` | 許可する CORS オリジン |
| `DEBUG` | `false` | LiteLLM デバッグログの有効化 |
| `OPENAI_API_KEY` | — | OpenAI API キー |
| `ANTHROPIC_API_KEY` | — | Anthropic API キー |
| `OLLAMA_API_BASE` | `http://localhost:11434` | Ollama エンドポイント |
+28 -1
View File
@@ -1,11 +1,38 @@
# LLM設定
DEFAULT_MODEL=gpt-4o
DEFAULT_MODEL=gpt-5.4
# OpenAI
OPENAI_API_KEY=sk-...
# Anthropic
ANTHROPIC_API_KEY=sk-ant-...
# Ollama (ローカル) — APIキー不要、モデル名は ollama/llama3 形式で指定
OLLAMA_API_BASE=http://localhost:11434
# Amazon Bedrock (API key方式) — モデル名は bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0 形式で指定
AWS_BEARER_TOKEN_BEDROCK=
AWS_REGION_NAME=us-east-1
# デバッグ (true にすると LiteLLM の詳細ログが出力される)
DEBUG=false
# 天気予報 → Discord 通知
DISCORD_WEBHOOK_URL=
WEATHER_AREA_CODE=120000
WEATHER_AREA_KEYWORD=北西部
WEATHER_NOTIFY_HOUR=22
WEATHER_NOTIFY_MINUTE=0
# 通知文をLLMで生成する場合のモデル(未設定ならDEFAULT_MODELを使用)
WEATHER_NOTIFY_MODEL=
# Discord Bot(メンションへの自動返信用)
DISCORD_BOT_TOKEN=
# 未設定ならデフォルトの「お天気アプリ」ペルソナが使われる
DISCORD_BOT_SYSTEM_PROMPT=
# どうでもいいtipsを定期的にDiscordへ投稿
USELESS_TIPS_ENABLED=false
USELESS_TIPS_INTERVAL_HOURS=3
USELESS_TIPS_MODEL=
# 未設定ならDISCORD_WEBHOOK_URL(天気通知と同じ)を使う
DISCORD_WEBHOOK_URL_TIPS=
# アプリ設定
DATABASE_URL=sqlite:///./playchat.db
+1
View File
@@ -0,0 +1 @@
from . import chat, memory, logs, system
+40 -6
View File
@@ -30,11 +30,29 @@ class SessionResponse(BaseModel):
from_attributes = True
class MessageResponse(BaseModel):
id: int
role: str
content: str
created_at: datetime
class Config:
from_attributes = True
@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()
@router.get("/sessions/{session_id}/messages", response_model=list[MessageResponse])
def get_messages(session_id: str, db: DBSession = Depends(get_db)):
session = db.get(Session, session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return session.messages
@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)
@@ -68,14 +86,19 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
context = mem.get_context_messages(db, session.id)
async def generate():
import json as _json
def sse(payload: dict) -> str:
return f"data: {_json.dumps(payload, ensure_ascii=False)}\n\n"
# セッションIDをまず返す(新規セッション時にフロントへ通知)
yield f"data: {{\"type\":\"session_id\",\"value\":\"{session.id}\"}}\n\n"
yield sse({"type": "session_id", "value": session.id})
full_response = []
async for chunk in llm.stream_chat(context, model=req.model or session.model):
try:
async for chunk in llm.stream_chat(context, model=req.model or session.model, session_id=session.id):
full_response.append(chunk)
escaped = chunk.replace('"', '\\"').replace('\n', '\\n')
yield f"data: {{\"type\":\"chunk\",\"value\":\"{escaped}\"}}\n\n"
yield sse({"type": "chunk", "value": chunk})
# アシスタントメッセージを保存
assistant_content = "".join(full_response)
@@ -86,6 +109,17 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
# メモリ要約チェック
await mem.maybe_summarize(db, session)
yield "data: {\"type\":\"done\"}\n\n"
yield sse({"type": "done"})
return StreamingResponse(generate(), media_type="text/event-stream")
except Exception as e:
yield sse({"type": "error", "value": str(e)})
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
+50
View File
@@ -0,0 +1,50 @@
from datetime import datetime
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session as DBSession
from sqlalchemy import desc
from app.db.database import get_db
from app.db.models import ApiLog
router = APIRouter(prefix="/logs", tags=["logs"])
class LogResponse(BaseModel):
id: int
session_id: str | None
model: str
request_json: str
response_text: str | None
error: str | None
latency_ms: float | None
created_at: datetime
class Config:
from_attributes = True
@router.get("", response_model=list[LogResponse])
def list_logs(
session_id: str | None = Query(None),
status: str | None = Query(None, description="'ok' または 'error'"),
limit: int = Query(100, le=500),
offset: int = Query(0),
db: DBSession = Depends(get_db),
):
q = db.query(ApiLog)
if session_id:
q = q.filter(ApiLog.session_id == session_id)
if status == "ok":
q = q.filter(ApiLog.error.is_(None))
elif status == "error":
q = q.filter(ApiLog.error.isnot(None))
return q.order_by(desc(ApiLog.created_at)).offset(offset).limit(limit).all()
@router.delete("/{log_id}", status_code=204)
def delete_log(log_id: int, db: DBSession = Depends(get_db)):
log = db.get(ApiLog, log_id)
if log:
db.delete(log)
db.commit()
+63
View File
@@ -0,0 +1,63 @@
from fastapi import APIRouter
import psutil
from pydantic import BaseModel
from app.core.scheduler import send_useless_tip, send_weather_laundry_notification
router = APIRouter(prefix="/api/system", tags=["system"])
class MemoryStats(BaseModel):
cpu_percent: float
memory_percent: float
memory_used_mb: float
memory_total_mb: float
gpu_memory_used_mb: float | None = None
gpu_memory_total_mb: float | None = None
gpu_memory_percent: float | None = None
@router.get("/memory", response_model=MemoryStats)
async def get_memory_stats():
"""CPU と GPU のメモリ使用状況を返す"""
# CPU/メモリ情報
cpu_percent = psutil.cpu_percent(interval=0.1)
memory = psutil.virtual_memory()
stats = {
"cpu_percent": cpu_percent,
"memory_percent": memory.percent,
"memory_used_mb": memory.used / (1024 * 1024),
"memory_total_mb": memory.total / (1024 * 1024),
}
# GPU 情報(オプション)
try:
import pynvml
pynvml.nvmlInit()
device_count = pynvml.nvmlDeviceGetCount()
if device_count > 0:
handle = pynvml.nvmlDeviceGetHandleByIndex(0)
mem_info = pynvml.nvmlDeviceGetMemoryInfo(handle)
stats["gpu_memory_used_mb"] = mem_info.used / (1024 * 1024)
stats["gpu_memory_total_mb"] = mem_info.total / (1024 * 1024)
stats["gpu_memory_percent"] = (mem_info.used / mem_info.total) * 100
pynvml.nvmlShutdown()
except Exception:
pass
return MemoryStats(**stats)
@router.post("/weather-notify/test")
async def test_weather_notify():
"""天気予報→Discord通知を手動で即時実行する(動作確認用)"""
await send_weather_laundry_notification()
return {"status": "sent"}
@router.post("/useless-tip/test")
async def test_useless_tip():
"""どうでもいいtips→Discord通知を手動で即時実行する(動作確認用)"""
await send_useless_tip()
return {"status": "sent"}
+32
View File
@@ -7,3 +7,35 @@ DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:///./playchat.db")
DEFAULT_MODEL: str = os.getenv("DEFAULT_MODEL", "gpt-4o")
MEMORY_SUMMARIZE_THRESHOLD: int = int(os.getenv("MEMORY_SUMMARIZE_THRESHOLD", "20"))
CORS_ORIGINS: list[str] = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")
DEBUG: bool = os.getenv("DEBUG", "false").lower() in ("1", "true", "yes")
# Amazon Bedrock
# AWS_BEARER_TOKEN_BEDROCK (API key方式) / AWS_REGION_NAME は
# LiteLLM が os.environ から直接読み取るため、.env に書くだけで自動的に有効になる
AWS_BEARER_TOKEN_BEDROCK: str | None = os.getenv("AWS_BEARER_TOKEN_BEDROCK")
AWS_REGION_NAME: str = os.getenv("AWS_REGION_NAME", "us-east-1")
# 天気予報 → Discord 通知
DISCORD_WEBHOOK_URL: str | None = os.getenv("DISCORD_WEBHOOK_URL")
WEATHER_AREA_CODE: str = os.getenv("WEATHER_AREA_CODE", "120000") # 千葉県
WEATHER_AREA_KEYWORD: str = os.getenv("WEATHER_AREA_KEYWORD", "北西部") # 市川市は千葉県北西部
WEATHER_NOTIFY_HOUR: int = int(os.getenv("WEATHER_NOTIFY_HOUR", "22"))
WEATHER_NOTIFY_MINUTE: int = int(os.getenv("WEATHER_NOTIFY_MINUTE", "0"))
# 通知メッセージをLLMで生成する場合に使うモデル(未設定ならDEFAULT_MODELを使用)
WEATHER_NOTIFY_MODEL: str | None = os.getenv("WEATHER_NOTIFY_MODEL")
# Discord Bot(メンションへの自動返信用)
DISCORD_BOT_TOKEN: str | None = os.getenv("DISCORD_BOT_TOKEN")
DISCORD_BOT_SYSTEM_PROMPT: str = os.getenv(
"DISCORD_BOT_SYSTEM_PROMPT",
"あなたは「お天気アプリ」という名前のDiscord Botです。"
"天気や洗濯指数の質問には親しみやすく簡潔に答え、"
"雑談にも気さくに応じてください。絵文字を適度に使って親しみやすい口調で話します。",
)
# どうでもいいTipsを定期的にDiscordへ投稿する機能
USELESS_TIPS_ENABLED: bool = os.getenv("USELESS_TIPS_ENABLED", "false").lower() in ("1", "true", "yes")
USELESS_TIPS_INTERVAL_HOURS: int = int(os.getenv("USELESS_TIPS_INTERVAL_HOURS", "3"))
USELESS_TIPS_MODEL: str | None = os.getenv("USELESS_TIPS_MODEL")
# 未設定なら天気通知と同じ DISCORD_WEBHOOK_URL を使う
DISCORD_WEBHOOK_URL_TIPS: str | None = os.getenv("DISCORD_WEBHOOK_URL_TIPS") or os.getenv("DISCORD_WEBHOOK_URL")
+84
View File
@@ -0,0 +1,84 @@
"""DiscordでメンションされたらplaychatのLLMが返信するBot。"""
import logging
import discord
from app.core.config import (
DISCORD_BOT_SYSTEM_PROMPT,
DISCORD_BOT_TOKEN,
WEATHER_AREA_CODE,
WEATHER_AREA_KEYWORD,
WEATHER_NOTIFY_MODEL,
)
from app.core.llm import complete
from app.core.weather import fetch_tomorrow_weather, judge_laundry
logger = logging.getLogger(__name__)
intents = discord.Intents.default()
intents.message_content = True
client = discord.Client(intents=intents)
WEATHER_KEYWORDS = ("天気", "天候", "洗濯", "雨", "気温", "降水")
async def _build_weather_context() -> str:
"""明日の天気データを取得し、LLMに渡すコンテキスト文字列を作る。失敗時は取得不可の旨を返す。"""
try:
weather_data = await fetch_tomorrow_weather(WEATHER_AREA_CODE, WEATHER_AREA_KEYWORD)
judgement, reason = judge_laundry(weather_data["weather"], weather_data["pop"])
pop_text = f"{weather_data['pop']}%" if weather_data["pop"] is not None else "不明"
return (
"【明日の天気予報データ(気象庁発表)】\n"
f"天気: {weather_data['weather']}\n"
f"降水確率: {pop_text}\n"
f"洗濯指数: {judgement}({reason})\n"
"このデータをもとにユーザーへ回答してください。"
)
except Exception:
logger.exception("Failed to fetch weather data for Discord reply")
return "(明日の天気データの取得に失敗しました。一般的な回答をしてください。)"
@client.event
async def on_ready():
logger.info("Discord bot logged in as %s", client.user)
@client.event
async def on_message(message: discord.Message):
if message.author.bot:
return
if client.user not in message.mentions:
return
content = message.content.replace(f"<@{client.user.id}>", "").strip()
if not content:
content = "こんにちは"
async with message.channel.typing():
try:
messages = [{"role": "system", "content": DISCORD_BOT_SYSTEM_PROMPT}]
if any(keyword in content for keyword in WEATHER_KEYWORDS):
weather_context = await _build_weather_context()
messages.append({"role": "system", "content": weather_context})
messages.append({"role": "user", "content": content})
reply = await complete(messages, model=WEATHER_NOTIFY_MODEL)
await message.reply(reply or "(応答が空でした)")
except Exception:
logger.exception("Failed to generate reply for Discord mention")
await message.reply("⚠️ 応答の生成に失敗しました")
async def start_discord_bot() -> None:
if not DISCORD_BOT_TOKEN:
logger.warning("DISCORD_BOT_TOKEN is not set; Discord bot will not start")
return
try:
await client.start(DISCORD_BOT_TOKEN)
except Exception:
logger.exception("Discord bot failed to start")
+71 -6
View File
@@ -1,29 +1,94 @@
from typing import AsyncIterator
import json
import logging
import time
import traceback
from typing import AsyncIterator, AsyncGenerator
from contextlib import asynccontextmanager
import litellm
from app.core.config import DEFAULT_MODEL
from app.core.config import DEFAULT_MODEL, DEBUG
litellm.drop_params = True
if DEBUG:
litellm._turn_on_debug()
logger = logging.getLogger(__name__)
@asynccontextmanager
async def _log_context(
model: str,
messages: list[dict],
session_id: str | None = None,
) -> AsyncGenerator[dict, None]:
"""LLM呼び出しの前後でログデータを収集するコンテキストマネージャー。"""
log: dict = {
"session_id": session_id,
"model": model,
"request_json": json.dumps(messages, ensure_ascii=False),
"response_text": None,
"error": None,
"latency_ms": None,
}
start = time.perf_counter()
try:
yield log
except Exception as e:
log["error"] = str(e)
log["latency_ms"] = round((time.perf_counter() - start) * 1000, 1)
logger.error("LLM call failed [model=%s]: %s\n%s", log["model"], e, traceback.format_exc())
raise
finally:
if log["latency_ms"] is None:
log["latency_ms"] = round((time.perf_counter() - start) * 1000, 1)
def _save_log(log: dict) -> None:
from app.db.database import SessionLocal
from app.db.models import ApiLog
db = SessionLocal()
try:
db.add(ApiLog(**log))
db.commit()
finally:
db.close()
async def stream_chat(
messages: list[dict],
model: str | None = None,
session_id: str | None = None,
) -> AsyncIterator[str]:
used_model = model or DEFAULT_MODEL
async with _log_context(used_model, messages, session_id) as log:
response = await litellm.acompletion(
model=model or DEFAULT_MODEL,
model=used_model,
messages=messages,
stream=True,
)
chunks: list[str] = []
async for chunk in response:
delta = chunk.choices[0].delta.content
if delta:
chunks.append(delta)
yield delta
log["response_text"] = "".join(chunks)
_save_log(log)
async def complete(messages: list[dict], model: str | None = None) -> str:
async def complete(
messages: list[dict],
model: str | None = None,
session_id: str | None = None,
) -> str:
used_model = model or DEFAULT_MODEL
async with _log_context(used_model, messages, session_id) as log:
response = await litellm.acompletion(
model=model or DEFAULT_MODEL,
model=used_model,
messages=messages,
stream=False,
)
return response.choices[0].message.content or ""
result = response.choices[0].message.content or ""
log["response_text"] = result
_save_log(log)
return result
+1 -1
View File
@@ -36,7 +36,7 @@ async def maybe_summarize(db: DBSession, session: Session) -> None:
{"role": "system", "content": "以下の会話を簡潔に要約してください。重要な事実・決定・文脈を保持してください。"},
*[{"role": m.role, "content": m.content} for m in to_summarize],
]
summary_text = await llm.complete(summary_prompt)
summary_text = await llm.complete(summary_prompt, session_id=session.id)
db.add(Memory(session_id=session.id, summary=summary_text))
for msg in to_summarize:
+13
View File
@@ -0,0 +1,13 @@
"""Discord Webhook への通知送信。"""
import logging
import httpx
logger = logging.getLogger(__name__)
async def send_discord_message(webhook_url: str, content: str) -> None:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(webhook_url, json={"content": content})
response.raise_for_status()
logger.info("Discord message sent (status=%s)", response.status_code)
+127
View File
@@ -0,0 +1,127 @@
"""毎日決まった時刻に天気予報と洗濯指数をDiscordへ送信するジョブ。"""
import logging
from datetime import datetime
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger
from app.core.config import (
DISCORD_WEBHOOK_URL,
DISCORD_WEBHOOK_URL_TIPS,
USELESS_TIPS_ENABLED,
USELESS_TIPS_INTERVAL_HOURS,
USELESS_TIPS_MODEL,
WEATHER_AREA_CODE,
WEATHER_AREA_KEYWORD,
WEATHER_NOTIFY_HOUR,
WEATHER_NOTIFY_MINUTE,
WEATHER_NOTIFY_MODEL,
)
from app.core.llm import complete
from app.core.notify import send_discord_message
from app.core.weather import fetch_tomorrow_weather, judge_laundry
logger = logging.getLogger(__name__)
scheduler = AsyncIOScheduler()
async def send_weather_laundry_notification() -> None:
if not DISCORD_WEBHOOK_URL:
logger.warning("DISCORD_WEBHOOK_URL is not set; skipping weather notification")
return
try:
weather_data = await fetch_tomorrow_weather(WEATHER_AREA_CODE, WEATHER_AREA_KEYWORD)
judgement, reason = judge_laundry(weather_data["weather"], weather_data["pop"])
pop_text = f"{weather_data['pop']}%" if weather_data["pop"] is not None else "不明"
content = await _generate_message(weather_data["weather"], pop_text, judgement, reason)
await send_discord_message(DISCORD_WEBHOOK_URL, content)
except Exception:
logger.exception("Failed to send weather/laundry notification")
async def _generate_message(weather: str, pop_text: str, judgement: str, reason: str) -> str:
"""LLMに天気データを渡し、Discord向けのメッセージ文を生成させる。失敗時は固定テンプレートにフォールバックする。"""
fallback = (
"🌤️ **明日の天気予報**\n"
f"天気: {weather}\n"
f"降水確率: {pop_text}\n\n"
f"👕 **洗濯物**: {judgement}\n"
f"({reason})"
)
prompt = (
"以下の天気データをもとに、Discordに送る通知メッセージを作成してください。\n"
"- 絵文字を使って読みやすく\n"
"- 「明日の天気」と「洗濯物を外に干すべきか」の2点を必ず含める\n"
"- 簡潔に3〜5行程度\n"
"- Markdown装飾(**太字**など)はDiscordの記法に合わせて可\n"
"- 出力はメッセージ本文のみ(前置きや説明文は不要)\n\n"
f"天気: {weather}\n"
f"降水確率: {pop_text}\n"
f"洗濯判定: {judgement}(理由: {reason})"
)
try:
messages = [{"role": "user", "content": prompt}]
result = await complete(messages, model=WEATHER_NOTIFY_MODEL)
return result.strip() or fallback
except Exception:
logger.exception("LLM message generation failed; falling back to template")
return fallback
async def send_useless_tip() -> None:
if not DISCORD_WEBHOOK_URL_TIPS:
logger.warning("DISCORD_WEBHOOK_URL_TIPS is not set; skipping useless tip")
return
if datetime.now().hour == 3:
logger.info("Skipping useless tip during 3am quiet hour")
return
prompt = (
"誰の役にも立たない、死ぬほどどうでもいい雑学・豆知識を一つ教えてください。\n"
"- 1〜3文程度で簡潔に\n"
"- 「どうでもいい」感が伝わるようなノリで\n"
"- 絵文字を1つくらい添えてもよい\n"
"- 出力は本文のみ(前置き・説明不要)\n"
"- 過去に何度も言われていそうな定番ネタは避け、できるだけ意外性のある内容にする"
)
try:
messages = [{"role": "user", "content": prompt}]
tip = await complete(messages, model=USELESS_TIPS_MODEL)
content = f"🤷 **どうでもいいtips**\n{tip.strip()}"
await send_discord_message(DISCORD_WEBHOOK_URL_TIPS, content)
except Exception:
logger.exception("Failed to send useless tip")
def start_scheduler() -> None:
scheduler.add_job(
send_weather_laundry_notification,
trigger=CronTrigger(hour=WEATHER_NOTIFY_HOUR, minute=WEATHER_NOTIFY_MINUTE),
id="weather_laundry_notification",
replace_existing=True,
)
logger.info(
"Scheduler started: weather notification at %02d:%02d",
WEATHER_NOTIFY_HOUR,
WEATHER_NOTIFY_MINUTE,
)
if USELESS_TIPS_ENABLED:
scheduler.add_job(
send_useless_tip,
trigger=IntervalTrigger(hours=USELESS_TIPS_INTERVAL_HOURS),
id="useless_tips",
replace_existing=True,
)
logger.info("Useless tips job started: every %d hour(s)", USELESS_TIPS_INTERVAL_HOURS)
scheduler.start()
+87
View File
@@ -0,0 +1,87 @@
"""気象庁の無料JSON APIから天気予報を取得し、洗濯指数を判定する。"""
import logging
from datetime import datetime, timedelta
import httpx
logger = logging.getLogger(__name__)
JMA_FORECAST_URL = "https://www.jma.go.jp/bosai/forecast/data/forecast/{area_code}.json"
async def fetch_tomorrow_weather(area_code: str, area_name_keyword: str) -> dict:
"""指定エリアの明日の天気予報を取得する。
area_code: 気象庁の府県予報区コード(例: 千葉県 = 120000)
area_name_keyword: 府県内の地域区分名の一部(例: 市川市が属する「北西部」)
"""
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(JMA_FORECAST_URL.format(area_code=area_code))
response.raise_for_status()
data = response.json()
# data[0] が短期予報(今日・明日・明後日)
short_term = data[0]
time_series = short_term["timeSeries"]
weather_series = time_series[0]
pop_series = time_series[1] if len(time_series) > 1 else None
tomorrow_index = _find_tomorrow_index(weather_series["timeDefines"])
weather_area = _find_area(weather_series["areas"], area_name_keyword)
weather_text = weather_area["weathers"][tomorrow_index] if weather_area else "不明"
pop_value = None
if pop_series:
pop_tomorrow_index = _find_tomorrow_index(pop_series["timeDefines"])
pop_area = _find_area(pop_series["areas"], area_name_keyword)
if pop_area and pop_area.get("pops"):
pops = pop_area["pops"]
# pops は3時間ごとの配列のことが多いため、明日に該当する値の最大値を取る
relevant_pops = [
int(p) for p, t in zip(pops, pop_series["timeDefines"])
if _is_same_date(t, pop_series["timeDefines"][pop_tomorrow_index]) and p
]
if relevant_pops:
pop_value = max(relevant_pops)
return {
"weather": weather_text.strip(),
"pop": pop_value,
}
def _find_tomorrow_index(time_defines: list[str]) -> int:
tomorrow = (datetime.now() + timedelta(days=1)).date()
for i, t in enumerate(time_defines):
dt = datetime.fromisoformat(t)
if dt.date() == tomorrow:
return i
return min(1, len(time_defines) - 1)
def _is_same_date(t1: str, t2: str) -> bool:
return datetime.fromisoformat(t1).date() == datetime.fromisoformat(t2).date()
def _find_area(areas: list[dict], keyword: str) -> dict | None:
for area in areas:
if keyword in area["area"]["name"]:
return area
return areas[0] if areas else None
def judge_laundry(weather: str, pop: int | None) -> tuple[str, str]:
"""天気と降水確率から洗濯指数を判定する。戻り値: (判定, 理由)"""
is_rainy = "雨" in weather or "雪" in weather
if is_rainy:
return "室内干し推奨", f"明日は「{weather}」のため"
if pop is None:
return "外干しOK(降水確率不明)", "降水確率データが取得できませんでした"
if pop <= 30:
return "外干しOK", f"降水確率{pop}%と低めです"
if pop <= 50:
return "室内干し推奨", f"降水確率{pop}%とやや高めです"
return "室内干し推奨", f"降水確率{pop}%と高いです"
+14 -1
View File
@@ -1,5 +1,5 @@
from datetime import datetime, timezone
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Float
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.database import Base
@@ -39,3 +39,16 @@ class Memory(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
session: Mapped["Session"] = relationship(back_populates="memories")
class ApiLog(Base):
__tablename__ = "api_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
session_id: Mapped[str | None] = mapped_column(ForeignKey("sessions.id", ondelete="SET NULL"), nullable=True)
model: Mapped[str] = mapped_column(String)
request_json: Mapped[str] = mapped_column(Text) # JSON: messages array
response_text: 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)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
+18 -7
View File
@@ -1,11 +1,25 @@
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import CORS_ORIGINS
from app.core.discord_bot import start_discord_bot
from app.core.scheduler import start_scheduler
from app.db.database import init_db
from app.api import chat, memory
from app.api import chat, memory, logs, system
app = FastAPI(title="PlayChat API", version="0.1.0")
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
start_scheduler()
asyncio.create_task(start_discord_bot())
yield
app = FastAPI(title="PlayChat API", version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
@@ -16,13 +30,10 @@ app.add_middleware(
)
@app.on_event("startup")
def on_startup():
init_db()
app.include_router(chat.router)
app.include_router(memory.router)
app.include_router(logs.router)
app.include_router(system.router)
@app.get("/health")
+11 -1
View File
@@ -4,12 +4,19 @@ version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.111.0",
"uvicorn[standard]>=0.30.0",
"uvicorn>=0.30.0",
"litellm>=1.40.0",
"sqlalchemy>=2.0.0",
"alembic>=1.13.0",
"python-dotenv>=1.0.0",
"sse-starlette>=2.1.0",
"huggingface-hub>=1.17.0",
"psutil>=7.2.2",
"nvidia-ml-py>=13.610.43",
"boto3>=1.43.32",
"apscheduler>=3.11.2",
"httpx>=0.28.1",
"discord-py>=2.7.1",
]
[project.optional-dependencies]
@@ -19,6 +26,9 @@ dev = [
"httpx>=0.27.0",
]
[tool.hatch.build.targets.wheel]
packages = ["app"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
+2081
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
curl.exe --ssl-no-revoke -X POST `
-H "Authorization: Bearer $env:AWS_BEARER_TOKEN_BEDROCK" `
-H "Content-Type: application/json" `
"https://bedrock-runtime.ap-northeast-1.amazonaws.com/model/jp.anthropic.claude-sonnet-4-6/converse" `
-d '{\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"hi\"}]}]}'
+103 -10
View File
@@ -3,6 +3,8 @@
ベースURL: `http://localhost:8000`
Swagger UI: `http://localhost:8000/docs`
---
## チャット
### POST /chat/stream
@@ -12,28 +14,119 @@ SSE でストリーミング応答を返す。
```json
{
"message": "こんにちは",
"session_id": "uuid (省略時は新規セッション作成)",
"model": "gpt-4o (省略時は DEFAULT_MODEL)"
"session_id": "uuid(省略時は新規セッション作成)",
"model": "gpt-5.4(省略時は DEFAULT_MODEL)"
}
```
**SSE イベント**
```
data: {"type":"session_id","value":"<uuid>"} // 新規セッション時のみ
data: {"type":"chunk","value":"テキスト断片"}
data: {"type":"done"}
```
| type | value | タイミング |
|---|---|---|
| `session_id` | セッション UUID | 新規セッション作成直後 |
| `chunk` | テキスト断片 | LLM トークン生成ごと |
| `done` | — | 正常完了 |
| `error` | エラーメッセージ | 例外発生時 |
---
### GET /chat/sessions
セッション一覧を `updated_at` 降順で返す。
**レスポンス**
```json
[
{
"id": "uuid",
"title": "セッションタイトル",
"model": "gpt-5.4",
"updated_at": "2026-06-01T12:00:00"
}
]
```
---
### GET /chat/sessions/{session_id}/messages
セッションのメッセージ一覧を `created_at` 昇順で返す。
**レスポンス**
```json
[
{ "id": 1, "role": "user", "content": "...", "created_at": "..." },
{ "id": 2, "role": "assistant", "content": "...", "created_at": "..." }
]
```
---
### DELETE /chat/sessions/{session_id}
セッションと紐づくメッセージ・メモリをすべて削除。
セッションと紐づくメッセージ・メモリをすべて削除。`204 No Content` を返す。
---
## メモリ
### GET /memory/{session_id}
セッションの長期メモリ(要約)一覧を返す。
セッションの長期メモリ(要約)一覧を `created_at` 昇順で返す。
**レスポンス**
```json
[
{
"id": 1,
"session_id": "uuid",
"summary": "要約テキスト",
"created_at": "2026-06-01T12:00:00"
}
]
```
---
### DELETE /memory/{memory_id}
特定のメモリエントリを削除。
特定のメモリエントリを削除。`204 No Content` を返す。
---
## ログ
### GET /logs
LLM API 呼び出しログを `created_at` 降順で返す。
**クエリパラメーター**
| パラメーター | 型 | 説明 |
|---|---|---|
| `session_id` | string | セッションで絞り込み |
| `status` | `ok` / `error` | 成功・失敗で絞り込み |
| `limit` | int (≤500) | 最大件数(デフォルト 100) |
| `offset` | int | スキップ件数 |
**レスポンス**
```json
[
{
"id": 1,
"session_id": "uuid",
"model": "gpt-5.4",
"request_json": "[{\"role\":\"user\",\"content\":\"...\"}]",
"response_text": "...",
"error": null,
"latency_ms": 832.5,
"created_at": "2026-06-01T12:00:00"
}
]
```
---
### DELETE /logs/{log_id}
指定ログを削除。`204 No Content` を返す。
---
## ヘルスチェック
### GET /health
```json
{ "status": "ok" }
```
+105 -31
View File
@@ -3,47 +3,121 @@
## 全体構成
```
フロントエンド (Vue 3 + TypeScript + Vite, port 5173)
↕ REST / SSE
バックエンド (FastAPI + Python, port 8000)
↕
フロントエンド (Vue 3 + TypeScript + Vite, :5173)
↕ REST + SSE (Vite dev proxy 経由)
バックエンド (FastAPI + Python, :8000)
↕ SQLAlchemy
SQLite (playchat.db)
↕
LiteLLM → OpenAI / Anthropic / Ollama (任意のLLM)
↕ LiteLLM
OpenAI / Anthropic / Ollama など任意の LLM
```
---
## バックエンド
### エントリーポイント
`app/main.py` — FastAPI インスタンス作成、CORS 設定、DB 初期化、ルーター登録。
### ディレクトリ構成
### ルーター
| ファイル | エンドポイント | 役割 |
```
backend/
├── app/
│ ├── main.py # FastAPI インスタンス、CORS、ルーター登録、DB初期化
│ ├── api/
│ │ ├── chat.py # チャット・セッション関連エンドポイント
│ │ ├── memory.py # 長期メモリ参照・削除
│ │ └── logs.py # API ログ参照・削除
│ ├── core/
│ │ ├── config.py # .env 読み込み・定数定義
│ │ ├── llm.py # LiteLLM ラッパー(ストリーミング・ログ記録)
│ │ └── memory.py # メモリ読み書きロジック(要約・コンテキスト生成)
│ └── db/
│ ├── database.py # SQLite 接続・セッションファクトリ・テーブル自動作成
│ └── models.py # SQLAlchemy モデル定義
└── pyproject.toml
```
### DB テーブル
| テーブル | 主なカラム | 用途 |
|---|---|---|
| `app/api/chat.py` | `POST /chat/stream` | SSE でストリーミング応答 |
| `app/api/chat.py` | `GET /chat/sessions` | セッション一覧 |
| `app/api/chat.py` | `DELETE /chat/sessions/{id}` | セッション削除 |
| `app/api/memory.py` | `GET /memory/{session_id}` | メモリ一覧 |
| `app/api/memory.py` | `DELETE /memory/{id}` | メモリ削除 |
| `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 | 要約による長期メモリ |
| `api_logs` | id, session_id, model, request_json, response_text, error, latency_ms, created_at | LLM API 呼び出しログ |
### メモリ機構
- **短期メモリ**: セッション内のすべてのメッセージをコンテキストとして送信
- **長期メモリ**: メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(デフォルト20) を超えると古い半分をLLMで要約し `memories` テーブルに保存
- 要約はシステムメッセージとして次回以降のコンテキスト先頭に注入される
### メモリ機構(`core/memory.py`)
### LLM 接続
`app/core/llm.py` が LiteLLM を薄くラップ。モデル名は `DEFAULT_MODEL` 環境変数で制御。
ローカルの Ollama は `ollama/llama3` のように `ollama/` プレフィックスで指定。
1. **短期メモリ**: セッション内の全 `messages` をコンテキストとして送信
2. **長期メモリ**: メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(デフォルト 20)を超えると、古い半分を LLM で要約して `memories` に保存しメッセージを削除
3. **コンテキスト合成**: `get_context_messages()` が `memories` の要約をシステムメッセージとして先頭に付加し、残存メッセージと結合して返す
### LLM 呼び出し(`core/llm.py`)
- `stream_chat()` / `complete()` が LiteLLM を薄くラップ
- `_log_context` で呼び出しを囲み、成功・失敗どちらも `api_logs` テーブルに記録
- `DEBUG=true` で `litellm._turn_on_debug()` が有効になり詳細ログが出力される
### SSE ストリーミング(`api/chat.py`)
`POST /chat/stream` は `StreamingResponse` で以下のイベントを順番に送出する:
```
data: {"type":"session_id","value":"<uuid>"} # 新規セッション時のみ
data: {"type":"chunk","value":"テキスト断片"} # 0回以上
data: {"type":"done"} # 正常完了
data: {"type":"error","value":"エラー内容"} # 例外発生時
```
バッファリング防止のため `Cache-Control: no-cache` と `X-Accel-Buffering: no` を付与する。
---
## フロントエンド
### 状態管理
`src/stores/chat.ts` (Pinia) が全状態を保持:
- `sessions` — バックエンドから取得したセッション一覧
- `messages` — 現在セッションの表示用メッセージ配列
- `isStreaming` — SSE 受信中フラグ
- `selectedModel` — UI で選択中のモデル
### ディレクトリ構成
### SSE ストリーミング
`src/api/client.ts` の `streamChat()` が fetch + ReadableStream で SSE を受信。
チャンクは3種類: `session_id`(新規セッションID通知)、`chunk`(テキスト断片)、`done`(完了)。
```
frontend/src/
├── api/
│ └── client.ts # fetch ラッパー・SSE クライアント・型定義
├── stores/
│ └── chat.ts # Pinia ストア(全アプリ状態)
├── components/
│ ├── SessionList.vue # セッション一覧サイドバー
│ ├── ChatWindow.vue # メッセージ表示エリア
│ ├── MessageInput.vue # 入力欄・送信
│ ├── ModelSelector.vue # LLM モデル切替セレクト
│ ├── MarkdownContent.vue # marked + DOMPurify によるレンダリング
│ ├── LogViewer.vue # API ログ閲覧パネル
│ └── ToastList.vue # エラートースト通知
└── App.vue # ルートレイアウト
```
### 状態管理(`stores/chat.ts`)
| 状態 | 型 | 説明 |
|---|---|---|
| `sessions` | `SessionInfo[]` | セッション一覧 |
| `currentSessionId` | `string \| null` | 選択中セッション |
| `messages` | `Message[]` | 表示中メッセージ |
| `isStreaming` | `boolean` | SSE 受信中フラグ |
| `selectedModel` | `string` | 選択中 LLM モデル |
| `toasts` | `Toast[]` | エラー通知キュー |
### SSE 受信フロー
1. `sendMessage()` がユーザーメッセージと空のアシスタントメッセージを `messages` に追加
2. `streamChat()` が fetch + ReadableStream で SSE を受信
3. `chunk` イベント受信のたびに `messages.value[msgIdx].content` を更新(Vue reactive proxy 経由)
4. `done` で `pending` フラグを解除
5. `error` / ネットワークエラーは `error` フラグを立て赤バブル表示 + トースト通知
### Markdown レンダリング(`MarkdownContent.vue`)
- アシスタントメッセージのみ `marked` で HTML に変換後 `DOMPurify` でサニタイズ
- ストリーミング中(`pending=true`)は未閉じのコードフェンス(` ``` `)を自動補完してから描画
### Vite プロキシ設定
`/chat`、`/memory`、`/logs`、`/health` を `http://localhost:8000` に転送。
`/chat` は SSE バッファリング無効化のため `configure` で `x-accel-buffering: no` を付与。
+1381
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -10,14 +10,17 @@
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"dompurify": "^3.1.0",
"marked": "^12.0.0",
"pinia": "^2.1.7",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@types/dompurify": "^3.0.5",
"@vitejs/plugin-vue": "^5.0.4",
"typescript": "^5.4.0",
"vite": "^5.2.0",
"vite": "^8.0.16",
"vue-tsc": "^2.0.6"
}
}
+34 -21
View File
@@ -1,22 +1,42 @@
<script setup lang="ts">
import { 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 ToastList from './components/ToastList.vue'
import MemoryMonitor from './components/MemoryMonitor.vue'
const showLogs = ref(false)
</script>
<template>
<div class="app">
<div class="sidebar">
<SessionList />
<MemoryMonitor />
</div>
<div class="main">
<header>
<span class="title">PlayChat</span>
<ModelSelector />
<button class="log-toggle" :class="{ active: showLogs }" @click="showLogs = !showLogs">
ログ
</button>
</header>
<div class="body">
<div class="chat-pane">
<ChatWindow />
<MessageInput />
</div>
<div v-if="showLogs" class="log-pane">
<LogViewer />
</div>
</div>
</div>
</div>
<ToastList />
</template>
<style>
@@ -26,28 +46,21 @@ body { background: #111; color: #eee; font-family: system-ui, sans-serif; }
</style>
<style scoped>
.app {
display: flex;
height: 100vh;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.app { display: flex; height: 100vh; }
.sidebar { display: flex; flex-direction: column; border-right: 1px solid #333; overflow: hidden; }
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 48px;
border-bottom: 1px solid #333;
background: #1a1a1a;
display: flex; align-items: center; justify-content: space-between;
padding: 0 16px; height: 48px;
border-bottom: 1px solid #333; background: #1a1a1a; flex-shrink: 0;
}
.title {
font-size: 16px;
font-weight: 600;
color: #fff;
.title { font-size: 16px; font-weight: 600; color: #fff; }
.log-toggle {
background: #2a2a2a; color: #aaa; border: 1px solid #444;
border-radius: 6px; padding: 4px 14px; font-size: 13px; cursor: pointer;
}
.log-toggle.active { background: #1d3557; color: #93c5fd; border-color: #2563eb; }
.body { flex: 1; display: flex; overflow: hidden; }
.chat-pane { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.log-pane { width: 400px; min-width: 300px; border-left: 1px solid #2a2a2a; overflow: hidden; }
</style>
+58 -5
View File
@@ -1,3 +1,14 @@
export interface ApiLog {
id: number
session_id: string | null
model: string
request_json: string
response_text: string | null
error: string | null
latency_ms: number | null
created_at: string
}
export interface SessionInfo {
id: string
title: string
@@ -6,22 +17,64 @@ export interface SessionInfo {
}
export interface SSEChunk {
type: 'session_id' | 'chunk' | 'done'
type: 'session_id' | 'chunk' | 'done' | 'error'
value?: string
}
const BASE = ''
async function parseJson<T>(res: Response, label: string): Promise<T> {
const ct = res.headers.get('content-type') ?? ''
if (!ct.includes('application/json')) {
throw new Error(`${label}: バックエンドに接続できません (${res.status} ${res.statusText})`)
}
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(`${label}: ${body.detail ?? res.statusText}`)
}
return res.json()
}
export async function fetchSessions(): Promise<SessionInfo[]> {
const res = await fetch(`${BASE}/chat/sessions`)
if (!res.ok) throw new Error('Failed to fetch sessions')
return res.json()
return parseJson<SessionInfo[]>(res, 'セッション取得')
}
export interface MessageRecord {
id: number
role: 'user' | 'assistant' | 'system'
content: string
created_at: string
}
export async function fetchMessages(sessionId: string): Promise<MessageRecord[]> {
const res = await fetch(`${BASE}/chat/sessions/${sessionId}/messages`)
return parseJson<MessageRecord[]>(res, 'メッセージ取得')
}
export async function deleteSession(sessionId: string): Promise<void> {
await fetch(`${BASE}/chat/sessions/${sessionId}`, { method: 'DELETE' })
}
export async function fetchLogs(params: {
session_id?: string
status?: 'ok' | 'error'
limit?: number
offset?: number
} = {}): Promise<ApiLog[]> {
const q = new URLSearchParams()
if (params.session_id) q.set('session_id', params.session_id)
if (params.status) q.set('status', params.status)
if (params.limit != null) q.set('limit', String(params.limit))
if (params.offset != null) q.set('offset', String(params.offset))
const res = await fetch(`${BASE}/logs?${q}`)
return parseJson<ApiLog[]>(res, 'ログ取得')
}
export async function deleteLog(logId: number): Promise<void> {
await fetch(`${BASE}/logs/${logId}`, { method: 'DELETE' })
}
export function streamChat(
message: string,
sessionId: string | null,
@@ -54,8 +107,8 @@ export function streamChat(
try {
const parsed: SSEChunk = JSON.parse(line.slice(6))
onChunk(parsed)
} catch {
// ignore malformed
} catch (e) {
console.error('[SSE] JSON parse error:', e, '| raw:', line)
}
}
}
+18 -7
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { useChatStore } from '../stores/chat'
import MarkdownContent from './MarkdownContent.vue'
const store = useChatStore()
const scrollEl = ref<HTMLElement | null>(null)
@@ -22,11 +23,17 @@ watch(
<div
v-for="(msg, i) in store.messages"
:key="i"
:class="['message', msg.role, { pending: msg.pending }]"
:class="['message', msg.role, { error: msg.error }]"
>
<div class="bubble">
<span class="content">{{ msg.content }}</span>
<span v-if="msg.pending" class="cursor">▌</span>
<!-- ユーザーメッセージはプレーンテキスト -->
<template v-if="msg.role === 'user'">{{ msg.content }}</template>
<!-- アシスタントはMarkdownレンダリング -->
<template v-else>
<MarkdownContent :content="msg.content" :pending="msg.pending" />
<span v-if="msg.pending && !msg.content" class="cursor">▌</span>
</template>
</div>
</div>
</div>
@@ -46,9 +53,7 @@ watch(
color: #555;
font-size: 14px;
}
.message {
display: flex;
}
.message { display: flex; }
.message.user { justify-content: flex-end; }
.message.assistant { justify-content: flex-start; }
.bubble {
@@ -57,20 +62,26 @@ watch(
border-radius: 12px;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.user .bubble {
background: #2563eb;
color: #fff;
border-bottom-right-radius: 4px;
white-space: pre-wrap;
}
.assistant .bubble {
background: #2a2a2a;
color: #eee;
border-bottom-left-radius: 4px;
}
.message.error .bubble {
background: #3b1a1a;
color: #f87171;
border: 1px solid #7f1d1d;
}
.cursor {
display: inline-block;
animation: blink 0.8s step-end infinite;
}
@keyframes blink { 50% { opacity: 0; } }
+176
View File
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { fetchLogs, deleteLog, type ApiLog } from '../api/client'
import { useChatStore } from '../stores/chat'
const store = useChatStore()
const logs = ref<ApiLog[]>([])
const filterStatus = ref<'all' | 'ok' | 'error'>('all')
const filterSession = ref(false)
const expanded = ref<Set<number>>(new Set())
const loading = ref(false)
const error = ref<string | null>(null)
async function load() {
loading.value = true
error.value = null
try {
logs.value = await fetchLogs({
status: filterStatus.value === 'all' ? undefined : filterStatus.value,
session_id: filterSession.value && store.currentSessionId ? store.currentSessionId : undefined,
limit: 200,
})
} catch (e) {
error.value = e instanceof Error ? e.message : 'エラーが発生しました'
} finally {
loading.value = false
}
}
async function remove(id: number) {
await deleteLog(id)
logs.value = logs.value.filter((l) => l.id !== id)
}
function toggle(id: number) {
if (expanded.value.has(id)) expanded.value.delete(id)
else expanded.value.add(id)
}
function formatTime(iso: string) {
return new Date(iso).toLocaleString('ja-JP', { hour12: false })
}
function requestMessages(log: ApiLog): string {
try {
const msgs: { role: string; content: string }[] = JSON.parse(log.request_json)
return msgs.map((m) => `[${m.role}] ${m.content}`).join('\n\n')
} catch {
return log.request_json
}
}
watch([filterStatus, filterSession], load)
onMounted(load)
</script>
<template>
<div class="log-viewer">
<div class="toolbar">
<span class="title">API ログ</span>
<div class="filters">
<label>
<input type="checkbox" v-model="filterSession" :disabled="!store.currentSessionId" />
現在のセッションのみ
</label>
<select v-model="filterStatus">
<option value="all">すべて</option>
<option value="ok">成功のみ</option>
<option value="error">エラーのみ</option>
</select>
</div>
<button class="reload-btn" @click="load" :disabled="loading">↻</button>
</div>
<div v-if="loading" class="empty">読み込み中...</div>
<div v-else-if="error" class="empty api-error">{{ error }}</div>
<div v-else-if="logs.length === 0" class="empty">ログがありません</div>
<ul v-else>
<li v-for="log in logs" :key="log.id" :class="{ 'has-error': !!log.error }">
<div class="log-header" @click="toggle(log.id)">
<span class="status-dot" :class="log.error ? 'err' : 'ok'" />
<span class="model">{{ log.model }}</span>
<span class="latency">{{ log.latency_ms != null ? log.latency_ms + ' ms' : '-' }}</span>
<span class="time">{{ formatTime(log.created_at) }}</span>
<button class="del-btn" @click.stop="remove(log.id)">✕</button>
<span class="arrow">{{ expanded.has(log.id) ? '▲' : '▼' }}</span>
</div>
<div v-if="expanded.has(log.id)" class="log-detail">
<section>
<h4>リクエスト</h4>
<pre>{{ requestMessages(log) }}</pre>
</section>
<section v-if="log.response_text">
<h4>レスポンス</h4>
<pre>{{ log.response_text }}</pre>
</section>
<section v-if="log.error" class="error-section">
<h4>エラー</h4>
<pre>{{ log.error }}</pre>
</section>
</div>
</li>
</ul>
</div>
</template>
<style scoped>
.log-viewer {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: #111;
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid #2a2a2a;
flex-shrink: 0;
}
.title { font-size: 13px; font-weight: 600; color: #aaa; flex: 1; }
.filters { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #888; }
.filters label { display: flex; align-items: center; gap: 4px; cursor: pointer; }
.filters input { accent-color: #2563eb; cursor: pointer; }
select {
background: #2a2a2a; color: #ccc; border: 1px solid #444;
border-radius: 5px; padding: 2px 6px; font-size: 12px;
}
.reload-btn {
background: none; border: 1px solid #444; color: #aaa;
border-radius: 5px; padding: 2px 8px; cursor: pointer; font-size: 14px;
}
.reload-btn:hover:not(:disabled) { color: #fff; }
.empty { padding: 20px; text-align: center; color: #555; font-size: 13px; }
.api-error { color: #f87171 !important; }
ul { list-style: none; margin: 0; padding: 4px 0; overflow-y: auto; flex: 1; }
li { border-bottom: 1px solid #1e1e1e; }
li.has-error { border-left: 3px solid #7f1d1d; }
.log-header {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; cursor: pointer;
font-size: 12px;
}
.log-header:hover { background: #1a1a1a; }
.status-dot {
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
}
.status-dot.ok { background: #22c55e; }
.status-dot.err { background: #ef4444; }
.model { color: #ccc; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.latency { color: #666; width: 60px; text-align: right; }
.time { color: #555; white-space: nowrap; }
.del-btn {
background: none; border: none; color: #555;
cursor: pointer; font-size: 11px; opacity: 0;
}
.log-header:hover .del-btn { opacity: 1; }
.arrow { color: #555; font-size: 10px; }
.log-detail {
padding: 10px 14px 14px;
display: flex; flex-direction: column; gap: 10px;
}
section h4 { font-size: 11px; color: #666; margin-bottom: 4px; text-transform: uppercase; }
pre {
background: #1a1a1a; color: #ccc; border-radius: 6px;
padding: 10px; font-size: 12px; line-height: 1.5;
white-space: pre-wrap; word-break: break-word; margin: 0;
max-height: 300px; overflow-y: auto;
}
.error-section pre { background: #1f0a0a; color: #f87171; }
</style>
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { computed } from 'vue'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
const props = defineProps<{
content: string
pending?: boolean
}>()
marked.setOptions({ breaks: true, gfm: true })
function closeOpenFences(src: string): string {
const fenceCount = (src.match(/^```/gm) ?? []).length
if (fenceCount % 2 !== 0) return src + '\n```'
return src
}
const html = computed(() => {
const raw = props.pending ? closeOpenFences(props.content) : props.content
return DOMPurify.sanitize(marked.parse(raw) as string)
})
</script>
<template>
<div class="md" v-html="html" />
</template>
<style scoped>
.md { line-height: 1.7; word-break: break-word; }
/* インライン要素 */
.md :deep(code) {
background: #1a1a1a;
color: #e2e8f0;
padding: 1px 5px;
border-radius: 4px;
font-size: 0.875em;
font-family: 'Consolas', 'Menlo', monospace;
}
/* コードブロック */
.md :deep(pre) {
background: #0f0f0f;
border: 1px solid #2a2a2a;
border-radius: 8px;
padding: 12px 14px;
overflow-x: auto;
margin: 8px 0;
}
.md :deep(pre code) {
background: none;
padding: 0;
font-size: 0.85em;
color: #e2e8f0;
}
/* 見出し */
.md :deep(h1),
.md :deep(h2),
.md :deep(h3) {
margin: 12px 0 4px;
color: #f1f5f9;
font-weight: 600;
}
.md :deep(h1) { font-size: 1.2em; }
.md :deep(h2) { font-size: 1.1em; }
.md :deep(h3) { font-size: 1em; }
/* リスト */
.md :deep(ul),
.md :deep(ol) {
padding-left: 20px;
margin: 4px 0;
}
.md :deep(li) { margin: 2px 0; }
/* 段落 */
.md :deep(p) { margin: 4px 0; }
/* 引用 */
.md :deep(blockquote) {
border-left: 3px solid #444;
margin: 6px 0;
padding: 4px 12px;
color: #94a3b8;
}
/* テーブル */
.md :deep(table) {
border-collapse: collapse;
margin: 8px 0;
font-size: 0.9em;
width: 100%;
}
.md :deep(th),
.md :deep(td) {
border: 1px solid #333;
padding: 6px 10px;
text-align: left;
}
.md :deep(th) { background: #1a1a1a; color: #cbd5e1; }
/* 水平線 */
.md :deep(hr) { border-color: #333; margin: 10px 0; }
/* リンク */
.md :deep(a) { color: #60a5fa; text-decoration: none; }
.md :deep(a:hover) { text-decoration: underline; }
</style>
+123
View File
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
interface MemoryStats {
cpu_percent: number
memory_percent: number
memory_used_mb: number
memory_total_mb: number
gpu_memory_used_mb?: number
gpu_memory_total_mb?: number
gpu_memory_percent?: number
}
const stats = ref<MemoryStats | null>(null)
let intervalId: number | null = null
const fetchMemoryStats = async () => {
try {
const response = await fetch('/api/system/memory')
stats.value = await response.json()
} catch (error) {
console.error('Failed to fetch memory stats:', error)
}
}
const formatMemory = (mb: number) => {
if (mb > 1024) {
return `${(mb / 1024).toFixed(1)}GB`
}
return `${Math.round(mb)}MB`
}
onMounted(() => {
fetchMemoryStats()
intervalId = window.setInterval(fetchMemoryStats, 10000)
})
onUnmounted(() => {
if (intervalId) {
clearInterval(intervalId)
}
})
</script>
<template>
<div class="memory-monitor" v-if="stats">
<div class="stat">
<span class="label">CPU</span>
<div class="bar">
<div class="fill" :style="{ width: stats.cpu_percent + '%' }"></div>
</div>
<span class="value">{{ stats.cpu_percent.toFixed(1) }}%</span>
</div>
<div class="stat">
<span class="label">RAM</span>
<div class="bar">
<div class="fill" :style="{ width: stats.memory_percent + '%' }"></div>
</div>
<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">
<span class="label">GPU</span>
<div class="bar">
<div class="fill gpu" :style="{ width: stats.gpu_memory_percent + '%' }"></div>
</div>
<span class="value">{{ stats.gpu_memory_percent.toFixed(1) }}% ({{ formatMemory(stats.gpu_memory_used_mb!) }} / {{ formatMemory(stats.gpu_memory_total_mb!) }})</span>
</div>
</div>
</template>
<style scoped>
.memory-monitor {
background: #1a1a1a;
border-top: 1px solid #333;
border-bottom: 1px solid #333;
padding: 12px;
font-size: 11px;
font-family: monospace;
gap: 8px;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.stat {
display: flex;
align-items: center;
gap: 8px;
}
.label {
width: 45px;
color: #888;
font-weight: bold;
}
.bar {
flex: 1;
height: 16px;
background: #2a2a2a;
border-radius: 3px;
border: 1px solid #444;
overflow: hidden;
}
.fill {
height: 100%;
background: linear-gradient(90deg, #4CAF50, #8BC34A);
transition: width 0.3s ease;
}
.fill.gpu {
background: linear-gradient(90deg, #2196F3, #64B5F6);
}
.value {
width: 120px;
text-align: right;
color: #aaa;
}
</style>
+8 -2
View File
@@ -4,11 +4,17 @@ import { useChatStore } from '../stores/chat'
const store = useChatStore()
const presets = [
{ label: 'GPT-4o', value: 'gpt-4o' },
{ label: 'GPT-4o mini', value: 'gpt-4o-mini' },
{ label: 'GPT-5.5', value: 'gpt-5.5' },
{ label: 'GPT-5.4', value: 'gpt-5.4' },
{ label: 'GPT-5.4 mini', value: 'gpt-5.4-mini' },
{ label: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6' },
{ label: 'Claude Haiku 4.5', value: 'claude-haiku-4-5-20251001' },
{ label: 'Ollama llama3', value: 'ollama/llama3' },
{ label: 'Ollama Gemma4', value: 'ollama/gemma4' },
{ label: 'Gemma 4 12B (llama-server)', value: 'gpt-3.5-turbo' },
{ label: 'Claude Sonnet 4.6 (Bedrock)', value: 'bedrock/jp.anthropic.claude-sonnet-4-6' },
{ label: 'Llama 3 70B (Bedrock)', value: 'bedrock/meta.llama3-70b-instruct-v1:0' },
{ label: 'Nova 2 Lite (Bedrock)', value: 'bedrock/jp.amazon.nova-2-lite-v1:0' },
]
</script>
+21 -2
View File
@@ -1,10 +1,20 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { useChatStore } from '../stores/chat'
const store = useChatStore()
const error = ref<string | null>(null)
onMounted(() => store.loadSessions())
async function load() {
error.value = null
try {
await store.loadSessions()
} catch (e) {
error.value = e instanceof Error ? e.message : 'エラーが発生しました'
}
}
onMounted(load)
</script>
<template>
@@ -13,6 +23,7 @@ onMounted(() => store.loadSessions())
<span>チャット履歴</span>
<button class="new-btn" @click="store.selectSession(null)">+ 新規</button>
</div>
<div v-if="error" class="api-error" :title="error">⚠ 接続エラー</div>
<ul>
<li
v-for="s in store.sessions"
@@ -50,6 +61,14 @@ onMounted(() => store.loadSessions())
color: #888;
border-bottom: 1px solid #2a2a2a;
}
.api-error {
padding: 6px 12px;
font-size: 11px;
color: #f87171;
background: #1f0a0a;
border-bottom: 1px solid #7f1d1d;
cursor: default;
}
.new-btn {
background: #2a2a2a;
color: #ccc;
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { useChatStore } from '../stores/chat'
const store = useChatStore()
</script>
<template>
<div class="toast-list">
<div
v-for="toast in store.toasts"
:key="toast.id"
class="toast"
@click="store.dismissToast(toast.id)"
>
<span class="icon">⚠</span>
<span class="msg">{{ toast.message }}</span>
<button class="close">✕</button>
</div>
</div>
</template>
<style scoped>
.toast-list {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 1000;
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: #1f0a0a;
border: 1px solid #7f1d1d;
border-radius: 8px;
color: #f87171;
font-size: 13px;
max-width: 360px;
cursor: pointer;
pointer-events: all;
animation: slide-in 0.2s ease;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
.icon { flex-shrink: 0; }
.msg { flex: 1; word-break: break-word; }
.close { background: none; border: none; color: #f87171; cursor: pointer; font-size: 11px; padding: 0; }
@keyframes slide-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
+51 -12
View File
@@ -1,31 +1,61 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { fetchSessions, deleteSession, streamChat, type SessionInfo } from '../api/client'
import { fetchSessions, fetchMessages, deleteSession, streamChat, type SessionInfo } from '../api/client'
export interface Message {
role: 'user' | 'assistant'
content: string
pending?: boolean
error?: boolean
}
export interface Toast {
id: number
message: string
}
let toastSeq = 0
export const useChatStore = defineStore('chat', () => {
const sessions = ref<SessionInfo[]>([])
const currentSessionId = ref<string | null>(null)
const messages = ref<Message[]>([])
const isStreaming = ref(false)
const selectedModel = ref('gpt-4o')
const selectedModel = ref('gpt-5.4')
const toasts = ref<Toast[]>([])
const currentSession = computed(() =>
sessions.value.find((s) => s.id === currentSessionId.value) ?? null,
)
function pushToast(message: string, durationMs = 5000) {
const id = ++toastSeq
toasts.value.push({ id, message })
setTimeout(() => {
toasts.value = toasts.value.filter((t) => t.id !== id)
}, durationMs)
}
function dismissToast(id: number) {
toasts.value = toasts.value.filter((t) => t.id !== id)
}
async function loadSessions() {
sessions.value = await fetchSessions()
}
function selectSession(sessionId: string | null) {
async function selectSession(sessionId: string | null) {
currentSessionId.value = sessionId
messages.value = []
if (!sessionId) return
try {
const records = await fetchMessages(sessionId)
messages.value = records
.filter((r) => r.role === 'user' || r.role === 'assistant')
.map((r) => ({ role: r.role as 'user' | 'assistant', content: r.content }))
} catch (e) {
pushToast(`メッセージ取得失敗: ${e instanceof Error ? e.message : e}`)
}
}
async function removeSession(sessionId: string) {
@@ -41,8 +71,8 @@ export const useChatStore = defineStore('chat', () => {
if (isStreaming.value || !text.trim()) return
messages.value.push({ role: 'user', content: text })
const assistantMsg: Message = { role: 'assistant', content: '', pending: true }
messages.value.push(assistantMsg)
messages.value.push({ role: 'assistant', content: '', pending: true })
const msgIdx = messages.value.length - 1 // reactive proxy 経由で更新するためインデックスを保持
isStreaming.value = true
streamChat(
@@ -52,18 +82,24 @@ export const useChatStore = defineStore('chat', () => {
(chunk) => {
if (chunk.type === 'session_id' && chunk.value) {
currentSessionId.value = chunk.value
loadSessions()
loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`))
} else if (chunk.type === 'chunk' && chunk.value) {
assistantMsg.content += chunk.value
messages.value[msgIdx].content += chunk.value
} else if (chunk.type === 'done') {
assistantMsg.pending = false
messages.value[msgIdx].pending = false
isStreaming.value = false
loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`))
} else if (chunk.type === 'error') {
messages.value[msgIdx].content = chunk.value ?? 'バックエンドでエラーが発生しました。'
messages.value[msgIdx].pending = false
messages.value[msgIdx].error = true
isStreaming.value = false
loadSessions()
}
},
(_err) => {
assistantMsg.content = 'エラーが発生しました。'
assistantMsg.pending = false
(err) => {
messages.value[msgIdx].content = err.message
messages.value[msgIdx].pending = false
messages.value[msgIdx].error = true
isStreaming.value = false
},
)
@@ -75,7 +111,10 @@ export const useChatStore = defineStore('chat', () => {
messages,
isStreaming,
selectedModel,
toasts,
currentSession,
pushToast,
dismissToast,
loadSessions,
selectSession,
removeSession,
+12 -1
View File
@@ -6,8 +6,19 @@ export default defineConfig({
server: {
port: 5173,
proxy: {
'/chat': 'http://localhost:8000',
'/chat': {
target: 'http://localhost:8000',
changeOrigin: true,
// SSE ストリーミングのバッファリングを無効化
configure: (proxy) => {
proxy.on('proxyRes', (proxyRes) => {
proxyRes.headers['x-accel-buffering'] = 'no'
})
},
},
'/api': 'http://localhost:8000',
'/memory': 'http://localhost:8000',
'/logs': 'http://localhost:8000',
'/health': 'http://localhost:8000',
},
},