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": { "permissions": {
"allow": [ "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 *)"
] ]
} }
} }
+34 -18
View File
@@ -5,7 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## プロジェクト概要 ## プロジェクト概要
Python (FastAPI) バックエンド + Vue 3 (TypeScript) フロントエンドのチャットアプリ。 Python (FastAPI) バックエンド + Vue 3 (TypeScript) フロントエンドのチャットアプリ。
LLM は LiteLLM 経由で OpenAI / Anthropic / Ollama など任意のプロバイダーを統一APIで扱い、会話履歴とメモリは SQLite に永続化する。 LLM は LiteLLM 経由で OpenAI / Anthropic / Ollama など任意のプロバイダーを統一 API で扱い、会話履歴とメモリは SQLite に永続化する。
## コマンド ## コマンド
@@ -14,13 +14,11 @@ LLM は LiteLLM 経由で OpenAI / Anthropic / Ollama など任意のプロバ
```powershell ```powershell
Set-Location backend Set-Location backend
# 依存インストール (uv 推奨) # 依存インストール (SSL プロキシ環境では --native-tls が必要)
uv sync uv sync --native-tls
# または
pip install -e .
# 開発サーバー起動 # 開発サーバー起動
uvicorn app.main:app --reload uv run uvicorn app.main:app --reload
# テスト # テスト
pytest pytest
@@ -50,7 +48,7 @@ npm run type-check
```powershell ```powershell
Set-Location backend Set-Location backend
Copy-Item .env.example .env Copy-Item .env.example .env
# .env を編集してAPIキーとモデル名を設定 # .env を編集して API キーとモデル名を設定
``` ```
## アーキテクチャ ## アーキテクチャ
@@ -58,27 +56,45 @@ Copy-Item .env.example .env
詳細は `docs/architecture.md` を参照。 詳細は `docs/architecture.md` を参照。
``` ```
frontend (Vue3+Pinia, :5173) frontend (Vue3 + Pinia, :5173)
↕ REST + SSE ↕ REST + SSE (Vite proxy)
backend (FastAPI, :8000) backend (FastAPI, :8000)
├── app/api/ ルーター (chat.py, memory.py) ├── app/api/ チャット・メモリ・ログ のルーター
├── app/core/ ビジネスロジック (llm.py, memory.py, config.py) ├── app/core/ LLM ラッパー・メモリロジック・設定
└── app/db/ SQLAlchemy モデル・DB接続 └── app/db/ SQLAlchemy モデル・DB 接続
↕ ↕
SQLite (playchat.db) + LiteLLM → 任意のLLM SQLite (playchat.db) + LiteLLM → 任意の LLM
``` ```
### メモリ機構 ### メモリ機構
- **短期**: セッション内の全メッセージをコンテキストに含める - **短期**: セッション内の全メッセージをコンテキストに含める
- **長期**: メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(デフォルト20) を超えると、古い半分をLLMで要約して `memories` テーブルに保存し、次回以降のシステムプロンプトに注入する - **長期**: メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(デフォルト 20)を超えると古い半分を LLM で要約 → `memories` テーブルに保存 → 次回以降のシステムプロンプトに注入
### SSEストリーミング ### SSE ストリーミング
`POST /chat/stream` が SSE を返す。チャンク種別: `session_id`(新規セッションID)、`chunk`(テキスト断片)、`done`(完了)。 `POST /chat/stream` が SSE を返す。イベント種別: `session_id`(新規セッション ID)/ `chunk`(テキスト断片)/ `done`(完了)/ `error`(エラー)。
フロントは `src/api/client.ts` の `streamChat()` で fetch + ReadableStream により受信。 フロントは `src/api/client.ts` の `streamChat()` で fetch + ReadableStream により受信し、`messages.value[msgIdx]` を reactive proxy 経由で更新する。
### Markdown レンダリング
アシスタントメッセージは `marked` + `DOMPurify` でレンダリング。ストリーミング中の未閉じコードフェンスは自動補完してから描画する(`MarkdownContent.vue`)。
### モデル切替 ### モデル切替
`.env` の `DEFAULT_MODEL` またはリクエストの `model` フィールドで指定。 `.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設定 # LLM設定
DEFAULT_MODEL=gpt-4o DEFAULT_MODEL=gpt-5.4
# OpenAI # OpenAI
OPENAI_API_KEY=sk-... OPENAI_API_KEY=sk-...
# Anthropic # Anthropic
ANTHROPIC_API_KEY=sk-ant-... ANTHROPIC_API_KEY=sk-ant-...
# Ollama (ローカル) — APIキー不要、モデル名は ollama/llama3 形式で指定 # Ollama (ローカル) — APIキー不要、モデル名は ollama/llama3 形式で指定
OLLAMA_API_BASE=http://localhost:11434 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 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 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]) @router.get("/sessions", response_model=list[SessionResponse])
def list_sessions(db: DBSession = Depends(get_db)): def list_sessions(db: DBSession = Depends(get_db)):
return db.query(Session).order_by(Session.updated_at.desc()).all() 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) @router.delete("/sessions/{session_id}", status_code=204)
def delete_session(session_id: str, db: DBSession = Depends(get_db)): def delete_session(session_id: str, db: DBSession = Depends(get_db)):
session = db.get(Session, session_id) 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) context = mem.get_context_messages(db, session.id)
async def generate(): async def generate():
import json as _json
def sse(payload: dict) -> str:
return f"data: {_json.dumps(payload, ensure_ascii=False)}\n\n"
# セッションIDをまず返す(新規セッション時にフロントへ通知) # セッションIDをまず返す(新規セッション時にフロントへ通知)
yield f"data: {{\"type\":\"session_id\",\"value\":\"{session.id}\"}}\n\n" yield sse({"type": "session_id", "value": session.id})
full_response = [] 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) full_response.append(chunk)
escaped = chunk.replace('"', '\\"').replace('\n', '\\n') yield sse({"type": "chunk", "value": chunk})
yield f"data: {{\"type\":\"chunk\",\"value\":\"{escaped}\"}}\n\n"
# アシスタントメッセージを保存 # アシスタントメッセージを保存
assistant_content = "".join(full_response) 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) 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") DEFAULT_MODEL: str = os.getenv("DEFAULT_MODEL", "gpt-4o")
MEMORY_SUMMARIZE_THRESHOLD: int = int(os.getenv("MEMORY_SUMMARIZE_THRESHOLD", "20")) MEMORY_SUMMARIZE_THRESHOLD: int = int(os.getenv("MEMORY_SUMMARIZE_THRESHOLD", "20"))
CORS_ORIGINS: list[str] = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",") 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 import litellm
from app.core.config import DEFAULT_MODEL from app.core.config import DEFAULT_MODEL, DEBUG
litellm.drop_params = True 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( async def stream_chat(
messages: list[dict], messages: list[dict],
model: str | None = None, model: str | None = None,
session_id: str | None = None,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
used_model = model or DEFAULT_MODEL
async with _log_context(used_model, messages, session_id) as log:
response = await litellm.acompletion( response = await litellm.acompletion(
model=model or DEFAULT_MODEL, model=used_model,
messages=messages, messages=messages,
stream=True, stream=True,
) )
chunks: list[str] = []
async for chunk in response: async for chunk in response:
delta = chunk.choices[0].delta.content delta = chunk.choices[0].delta.content
if delta: if delta:
chunks.append(delta)
yield 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( response = await litellm.acompletion(
model=model or DEFAULT_MODEL, model=used_model,
messages=messages, messages=messages,
stream=False, 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": "system", "content": "以下の会話を簡潔に要約してください。重要な事実・決定・文脈を保持してください。"},
*[{"role": m.role, "content": m.content} for m in to_summarize], *[{"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)) db.add(Memory(session_id=session.id, summary=summary_text))
for msg in to_summarize: 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 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.database import Base 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)) created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
session: Mapped["Session"] = relationship(back_populates="memories") 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 import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.core.config import CORS_ORIGINS 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.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( app.add_middleware(
CORSMiddleware, 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(chat.router)
app.include_router(memory.router) app.include_router(memory.router)
app.include_router(logs.router)
app.include_router(system.router)
@app.get("/health") @app.get("/health")
+11 -1
View File
@@ -4,12 +4,19 @@ version = "0.1.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"fastapi>=0.111.0", "fastapi>=0.111.0",
"uvicorn[standard]>=0.30.0", "uvicorn>=0.30.0",
"litellm>=1.40.0", "litellm>=1.40.0",
"sqlalchemy>=2.0.0", "sqlalchemy>=2.0.0",
"alembic>=1.13.0", "alembic>=1.13.0",
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
"sse-starlette>=2.1.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] [project.optional-dependencies]
@@ -19,6 +26,9 @@ dev = [
"httpx>=0.27.0", "httpx>=0.27.0",
] ]
[tool.hatch.build.targets.wheel]
packages = ["app"]
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
build-backend = "hatchling.build" 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\"}]}]}'
+104 -11
View File
@@ -3,6 +3,8 @@
ベースURL: `http://localhost:8000` ベースURL: `http://localhost:8000`
Swagger UI: `http://localhost:8000/docs` Swagger UI: `http://localhost:8000/docs`
---
## チャット ## チャット
### POST /chat/stream ### POST /chat/stream
@@ -12,28 +14,119 @@ SSE でストリーミング応答を返す。
```json ```json
{ {
"message": "こんにちは", "message": "こんにちは",
"session_id": "uuid (省略時は新規セッション作成)", "session_id": "uuid(省略時は新規セッション作成)",
"model": "gpt-4o (省略時は DEFAULT_MODEL)" "model": "gpt-5.4(省略時は DEFAULT_MODEL)"
} }
``` ```
**SSEイベント** **SSE イベント**
``` | type | value | タイミング |
data: {"type":"session_id","value":"<uuid>"} // 新規セッション時のみ |---|---|---|
data: {"type":"chunk","value":"テキスト断片"} | `session_id` | セッション UUID | 新規セッション作成直後 |
data: {"type":"done"} | `chunk` | テキスト断片 | LLM トークン生成ごと |
``` | `done` | — | 正常完了 |
| `error` | エラーメッセージ | 例外発生時 |
---
### GET /chat/sessions ### GET /chat/sessions
セッション一覧を `updated_at` 降順で返す。 セッション一覧を `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} ### DELETE /chat/sessions/{session_id}
セッションと紐づくメッセージ・メモリをすべて削除。 セッションと紐づくメッセージ・メモリをすべて削除。`204 No Content` を返す。
---
## メモリ ## メモリ
### GET /memory/{session_id} ### GET /memory/{session_id}
セッションの長期メモリ(要約)一覧を返す。 セッションの長期メモリ(要約)一覧を `created_at` 昇順で返す。
**レスポンス**
```json
[
{
"id": 1,
"session_id": "uuid",
"summary": "要約テキスト",
"created_at": "2026-06-01T12:00:00"
}
]
```
---
### DELETE /memory/{memory_id} ### 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) フロントエンド (Vue 3 + TypeScript + Vite, :5173)
↕ REST / SSE ↕ REST + SSE (Vite dev proxy 経由)
バックエンド (FastAPI + Python, port 8000) バックエンド (FastAPI + Python, :8000)
↕ ↕ SQLAlchemy
SQLite (playchat.db) SQLite (playchat.db)
↕ ↕ LiteLLM
LiteLLM → OpenAI / Anthropic / Ollama (任意のLLM) 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 でストリーミング応答 | | `sessions` | id (UUID), title, model, created_at, updated_at | チャットセッション |
| `app/api/chat.py` | `GET /chat/sessions` | セッション一覧 | | `messages` | id, session_id, role, content, created_at | 会話メッセージ(短期メモリ) |
| `app/api/chat.py` | `DELETE /chat/sessions/{id}` | セッション削除 | | `memories` | id, session_id, summary, embedding_json, created_at | 要約による長期メモリ |
| `app/api/memory.py` | `GET /memory/{session_id}` | メモリ一覧 | | `api_logs` | id, session_id, model, request_json, response_text, error, latency_ms, created_at | LLM API 呼び出しログ |
| `app/api/memory.py` | `DELETE /memory/{id}` | メモリ削除 |
### メモリ機構 ### メモリ機構(`core/memory.py`)
- **短期メモリ**: セッション内のすべてのメッセージをコンテキストとして送信
- **長期メモリ**: メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(デフォルト20) を超えると古い半分をLLMで要約し `memories` テーブルに保存
- 要約はシステムメッセージとして次回以降のコンテキスト先頭に注入される
### LLM 接続 1. **短期メモリ**: セッション内の全 `messages` をコンテキストとして送信
`app/core/llm.py` が LiteLLM を薄くラップ。モデル名は `DEFAULT_MODEL` 環境変数で制御。 2. **長期メモリ**: メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(デフォルト 20)を超えると、古い半分を LLM で要約して `memories` に保存しメッセージを削除
ローカルの Ollama は `ollama/llama3` のように `ollama/` プレフィックスで指定。 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 を受信。 frontend/src/
チャンクは3種類: `session_id`(新規セッションID通知)、`chunk`(テキスト断片)、`done`(完了)。 ├── 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" "type-check": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"dompurify": "^3.1.0",
"marked": "^12.0.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-router": "^4.3.0" "vue-router": "^4.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/dompurify": "^3.0.5",
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
"typescript": "^5.4.0", "typescript": "^5.4.0",
"vite": "^5.2.0", "vite": "^8.0.16",
"vue-tsc": "^2.0.6" "vue-tsc": "^2.0.6"
} }
} }
+34 -21
View File
@@ -1,22 +1,42 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
import SessionList from './components/SessionList.vue' import SessionList from './components/SessionList.vue'
import ChatWindow from './components/ChatWindow.vue' import ChatWindow from './components/ChatWindow.vue'
import MessageInput from './components/MessageInput.vue' import MessageInput from './components/MessageInput.vue'
import ModelSelector from './components/ModelSelector.vue' import ModelSelector from './components/ModelSelector.vue'
import LogViewer from './components/LogViewer.vue'
import ToastList from './components/ToastList.vue'
import MemoryMonitor from './components/MemoryMonitor.vue'
const showLogs = ref(false)
</script> </script>
<template> <template>
<div class="app"> <div class="app">
<div class="sidebar">
<SessionList /> <SessionList />
<MemoryMonitor />
</div>
<div class="main"> <div class="main">
<header> <header>
<span class="title">PlayChat</span> <span class="title">PlayChat</span>
<ModelSelector /> <ModelSelector />
<button class="log-toggle" :class="{ active: showLogs }" @click="showLogs = !showLogs">
ログ
</button>
</header> </header>
<div class="body">
<div class="chat-pane">
<ChatWindow /> <ChatWindow />
<MessageInput /> <MessageInput />
</div> </div>
<div v-if="showLogs" class="log-pane">
<LogViewer />
</div> </div>
</div>
</div>
</div>
<ToastList />
</template> </template>
<style> <style>
@@ -26,28 +46,21 @@ body { background: #111; color: #eee; font-family: system-ui, sans-serif; }
</style> </style>
<style scoped> <style scoped>
.app { .app { display: flex; height: 100vh; }
display: flex; .sidebar { display: flex; flex-direction: column; border-right: 1px solid #333; overflow: hidden; }
height: 100vh; .main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
}
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
header { header {
display: flex; display: flex; align-items: center; justify-content: space-between;
align-items: center; padding: 0 16px; height: 48px;
justify-content: space-between; border-bottom: 1px solid #333; background: #1a1a1a; flex-shrink: 0;
padding: 0 16px;
height: 48px;
border-bottom: 1px solid #333;
background: #1a1a1a;
} }
.title { .title { font-size: 16px; font-weight: 600; color: #fff; }
font-size: 16px; .log-toggle {
font-weight: 600; background: #2a2a2a; color: #aaa; border: 1px solid #444;
color: #fff; 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> </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 { export interface SessionInfo {
id: string id: string
title: string title: string
@@ -6,22 +17,64 @@ export interface SessionInfo {
} }
export interface SSEChunk { export interface SSEChunk {
type: 'session_id' | 'chunk' | 'done' type: 'session_id' | 'chunk' | 'done' | 'error'
value?: string value?: string
} }
const BASE = '' 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[]> { export async function fetchSessions(): Promise<SessionInfo[]> {
const res = await fetch(`${BASE}/chat/sessions`) const res = await fetch(`${BASE}/chat/sessions`)
if (!res.ok) throw new Error('Failed to fetch sessions') return parseJson<SessionInfo[]>(res, 'セッション取得')
return res.json() }
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> { export async function deleteSession(sessionId: string): Promise<void> {
await fetch(`${BASE}/chat/sessions/${sessionId}`, { method: 'DELETE' }) 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( export function streamChat(
message: string, message: string,
sessionId: string | null, sessionId: string | null,
@@ -54,8 +107,8 @@ export function streamChat(
try { try {
const parsed: SSEChunk = JSON.parse(line.slice(6)) const parsed: SSEChunk = JSON.parse(line.slice(6))
onChunk(parsed) onChunk(parsed)
} catch { } catch (e) {
// ignore malformed console.error('[SSE] JSON parse error:', e, '| raw:', line)
} }
} }
} }
+18 -7
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, nextTick } from 'vue' import { ref, watch, nextTick } from 'vue'
import { useChatStore } from '../stores/chat' import { useChatStore } from '../stores/chat'
import MarkdownContent from './MarkdownContent.vue'
const store = useChatStore() const store = useChatStore()
const scrollEl = ref<HTMLElement | null>(null) const scrollEl = ref<HTMLElement | null>(null)
@@ -22,11 +23,17 @@ watch(
<div <div
v-for="(msg, i) in store.messages" v-for="(msg, i) in store.messages"
:key="i" :key="i"
:class="['message', msg.role, { pending: msg.pending }]" :class="['message', msg.role, { error: msg.error }]"
> >
<div class="bubble"> <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> </div>
</div> </div>
@@ -46,9 +53,7 @@ watch(
color: #555; color: #555;
font-size: 14px; font-size: 14px;
} }
.message { .message { display: flex; }
display: flex;
}
.message.user { justify-content: flex-end; } .message.user { justify-content: flex-end; }
.message.assistant { justify-content: flex-start; } .message.assistant { justify-content: flex-start; }
.bubble { .bubble {
@@ -57,20 +62,26 @@ watch(
border-radius: 12px; border-radius: 12px;
font-size: 14px; font-size: 14px;
line-height: 1.6; line-height: 1.6;
white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
.user .bubble { .user .bubble {
background: #2563eb; background: #2563eb;
color: #fff; color: #fff;
border-bottom-right-radius: 4px; border-bottom-right-radius: 4px;
white-space: pre-wrap;
} }
.assistant .bubble { .assistant .bubble {
background: #2a2a2a; background: #2a2a2a;
color: #eee; color: #eee;
border-bottom-left-radius: 4px; border-bottom-left-radius: 4px;
} }
.message.error .bubble {
background: #3b1a1a;
color: #f87171;
border: 1px solid #7f1d1d;
}
.cursor { .cursor {
display: inline-block;
animation: blink 0.8s step-end infinite; animation: blink 0.8s step-end infinite;
} }
@keyframes blink { 50% { opacity: 0; } } @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 store = useChatStore()
const presets = [ const presets = [
{ label: 'GPT-4o', value: 'gpt-4o' }, { label: 'GPT-5.5', value: 'gpt-5.5' },
{ label: 'GPT-4o mini', value: 'gpt-4o-mini' }, { 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 Sonnet 4.6', value: 'claude-sonnet-4-6' },
{ label: 'Claude Haiku 4.5', value: 'claude-haiku-4-5-20251001' }, { label: 'Claude Haiku 4.5', value: 'claude-haiku-4-5-20251001' },
{ label: 'Ollama llama3', value: 'ollama/llama3' }, { 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> </script>
+21 -2
View File
@@ -1,10 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useChatStore } from '../stores/chat' import { useChatStore } from '../stores/chat'
const store = useChatStore() 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> </script>
<template> <template>
@@ -13,6 +23,7 @@ onMounted(() => store.loadSessions())
<span>チャット履歴</span> <span>チャット履歴</span>
<button class="new-btn" @click="store.selectSession(null)">+ 新規</button> <button class="new-btn" @click="store.selectSession(null)">+ 新規</button>
</div> </div>
<div v-if="error" class="api-error" :title="error">⚠ 接続エラー</div>
<ul> <ul>
<li <li
v-for="s in store.sessions" v-for="s in store.sessions"
@@ -50,6 +61,14 @@ onMounted(() => store.loadSessions())
color: #888; color: #888;
border-bottom: 1px solid #2a2a2a; 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 { .new-btn {
background: #2a2a2a; background: #2a2a2a;
color: #ccc; 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 { defineStore } from 'pinia'
import { ref, computed } from 'vue' 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 { export interface Message {
role: 'user' | 'assistant' role: 'user' | 'assistant'
content: string content: string
pending?: boolean pending?: boolean
error?: boolean
} }
export interface Toast {
id: number
message: string
}
let toastSeq = 0
export const useChatStore = defineStore('chat', () => { export const useChatStore = defineStore('chat', () => {
const sessions = ref<SessionInfo[]>([]) const sessions = ref<SessionInfo[]>([])
const currentSessionId = ref<string | null>(null) const currentSessionId = ref<string | null>(null)
const messages = ref<Message[]>([]) const messages = ref<Message[]>([])
const isStreaming = ref(false) const isStreaming = ref(false)
const selectedModel = ref('gpt-4o') const selectedModel = ref('gpt-5.4')
const toasts = ref<Toast[]>([])
const currentSession = computed(() => const currentSession = computed(() =>
sessions.value.find((s) => s.id === currentSessionId.value) ?? null, 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() { async function loadSessions() {
sessions.value = await fetchSessions() sessions.value = await fetchSessions()
} }
function selectSession(sessionId: string | null) { async function selectSession(sessionId: string | null) {
currentSessionId.value = sessionId currentSessionId.value = sessionId
messages.value = [] 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) { async function removeSession(sessionId: string) {
@@ -41,8 +71,8 @@ export const useChatStore = defineStore('chat', () => {
if (isStreaming.value || !text.trim()) return if (isStreaming.value || !text.trim()) return
messages.value.push({ role: 'user', content: text }) messages.value.push({ role: 'user', content: text })
const assistantMsg: Message = { role: 'assistant', content: '', pending: true } messages.value.push({ role: 'assistant', content: '', pending: true })
messages.value.push(assistantMsg) const msgIdx = messages.value.length - 1 // reactive proxy 経由で更新するためインデックスを保持
isStreaming.value = true isStreaming.value = true
streamChat( streamChat(
@@ -52,18 +82,24 @@ export const useChatStore = defineStore('chat', () => {
(chunk) => { (chunk) => {
if (chunk.type === 'session_id' && chunk.value) { if (chunk.type === 'session_id' && chunk.value) {
currentSessionId.value = chunk.value currentSessionId.value = chunk.value
loadSessions() loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`))
} else if (chunk.type === 'chunk' && chunk.value) { } else if (chunk.type === 'chunk' && chunk.value) {
assistantMsg.content += chunk.value messages.value[msgIdx].content += chunk.value
} else if (chunk.type === 'done') { } 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 isStreaming.value = false
loadSessions()
} }
}, },
(_err) => { (err) => {
assistantMsg.content = 'エラーが発生しました。' messages.value[msgIdx].content = err.message
assistantMsg.pending = false messages.value[msgIdx].pending = false
messages.value[msgIdx].error = true
isStreaming.value = false isStreaming.value = false
}, },
) )
@@ -75,7 +111,10 @@ export const useChatStore = defineStore('chat', () => {
messages, messages,
isStreaming, isStreaming,
selectedModel, selectedModel,
toasts,
currentSession, currentSession,
pushToast,
dismissToast,
loadSessions, loadSessions,
selectSession, selectSession,
removeSession, removeSession,
+12 -1
View File
@@ -6,8 +6,19 @@ export default defineConfig({
server: { server: {
port: 5173, port: 5173,
proxy: { 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', '/memory': 'http://localhost:8000',
'/logs': 'http://localhost:8000',
'/health': 'http://localhost:8000', '/health': 'http://localhost:8000',
}, },
}, },