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:
@@ -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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from . import chat, memory, logs, system
|
||||
|
||||
+40
-6
@@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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}%と高いです"
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Generated
+2081
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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` を付与。
|
||||
|
||||
Generated
+1381
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user