initial_commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"PowerShell(Head-Object -n 50)"
|
||||
]
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# ===== Python =====
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
venv/
|
||||
.uv/
|
||||
|
||||
# 環境変数・シークレット
|
||||
backend/.env
|
||||
|
||||
# SQLite DB
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Alembic
|
||||
backend/alembic/versions/*.pyc
|
||||
|
||||
# ===== Node.js =====
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
|
||||
# ===== OS =====
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# ===== IDE =====
|
||||
.vscode/
|
||||
.idea/
|
||||
*.suo
|
||||
*.user
|
||||
@@ -0,0 +1,84 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## プロジェクト概要
|
||||
|
||||
Python (FastAPI) バックエンド + Vue 3 (TypeScript) フロントエンドのチャットアプリ。
|
||||
LLM は LiteLLM 経由で OpenAI / Anthropic / Ollama など任意のプロバイダーを統一APIで扱い、会話履歴とメモリは SQLite に永続化する。
|
||||
|
||||
## コマンド
|
||||
|
||||
### バックエンド
|
||||
|
||||
```powershell
|
||||
Set-Location backend
|
||||
|
||||
# 依存インストール (uv 推奨)
|
||||
uv sync
|
||||
# または
|
||||
pip install -e .
|
||||
|
||||
# 開発サーバー起動
|
||||
uvicorn app.main:app --reload
|
||||
|
||||
# テスト
|
||||
pytest
|
||||
pytest tests/test_chat.py # 単一ファイル
|
||||
```
|
||||
|
||||
### フロントエンド
|
||||
|
||||
```powershell
|
||||
Set-Location frontend
|
||||
|
||||
# 依存インストール
|
||||
npm install
|
||||
|
||||
# 開発サーバー起動 (port 5173)
|
||||
npm run dev
|
||||
|
||||
# ビルド
|
||||
npm run build
|
||||
|
||||
# 型チェック
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
### 環境設定
|
||||
|
||||
```powershell
|
||||
Set-Location backend
|
||||
Copy-Item .env.example .env
|
||||
# .env を編集してAPIキーとモデル名を設定
|
||||
```
|
||||
|
||||
## アーキテクチャ
|
||||
|
||||
詳細は `docs/architecture.md` を参照。
|
||||
|
||||
```
|
||||
frontend (Vue3+Pinia, :5173)
|
||||
↕ REST + SSE
|
||||
backend (FastAPI, :8000)
|
||||
├── app/api/ ルーター (chat.py, memory.py)
|
||||
├── app/core/ ビジネスロジック (llm.py, memory.py, config.py)
|
||||
└── app/db/ SQLAlchemy モデル・DB接続
|
||||
↕
|
||||
SQLite (playchat.db) + LiteLLM → 任意のLLM
|
||||
```
|
||||
|
||||
### メモリ機構
|
||||
|
||||
- **短期**: セッション内の全メッセージをコンテキストに含める
|
||||
- **長期**: メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(デフォルト20) を超えると、古い半分をLLMで要約して `memories` テーブルに保存し、次回以降のシステムプロンプトに注入する
|
||||
|
||||
### SSEストリーミング
|
||||
|
||||
`POST /chat/stream` が SSE を返す。チャンク種別: `session_id`(新規セッションID)、`chunk`(テキスト断片)、`done`(完了)。
|
||||
フロントは `src/api/client.ts` の `streamChat()` で fetch + ReadableStream により受信。
|
||||
|
||||
### モデル切替
|
||||
|
||||
`.env` の `DEFAULT_MODEL` またはリクエストの `model` フィールドで指定。
|
||||
ローカル Ollama は `ollama/llama3` 形式で指定、`OLLAMA_API_BASE` でエンドポイントを設定。
|
||||
@@ -0,0 +1,13 @@
|
||||
# LLM設定
|
||||
DEFAULT_MODEL=gpt-4o
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=sk-...
|
||||
# Anthropic
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
# Ollama (ローカル) — APIキー不要、モデル名は ollama/llama3 形式で指定
|
||||
OLLAMA_API_BASE=http://localhost:11434
|
||||
|
||||
# アプリ設定
|
||||
DATABASE_URL=sqlite:///./playchat.db
|
||||
MEMORY_SUMMARIZE_THRESHOLD=20
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
@@ -0,0 +1,91 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.db.models import Session, Message
|
||||
from app.core import memory as mem
|
||||
from app.core import llm
|
||||
from app.core.config import DEFAULT_MODEL
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["chat"])
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
session_id: str | None = None
|
||||
message: str
|
||||
model: str | None = None
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
model: str
|
||||
updated_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.delete("/sessions/{session_id}", status_code=204)
|
||||
def delete_session(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")
|
||||
db.delete(session)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/stream")
|
||||
async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
|
||||
# セッション取得 or 新規作成
|
||||
if req.session_id:
|
||||
session = db.get(Session, req.session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
else:
|
||||
session = Session(
|
||||
id=str(uuid.uuid4()),
|
||||
title=req.message[:40],
|
||||
model=req.model or DEFAULT_MODEL,
|
||||
)
|
||||
db.add(session)
|
||||
db.commit()
|
||||
|
||||
# ユーザーメッセージを保存
|
||||
user_msg = Message(session_id=session.id, role="user", content=req.message)
|
||||
db.add(user_msg)
|
||||
db.commit()
|
||||
|
||||
context = mem.get_context_messages(db, session.id)
|
||||
|
||||
async def generate():
|
||||
# セッションIDをまず返す(新規セッション時にフロントへ通知)
|
||||
yield f"data: {{\"type\":\"session_id\",\"value\":\"{session.id}\"}}\n\n"
|
||||
|
||||
full_response = []
|
||||
async for chunk in llm.stream_chat(context, model=req.model or session.model):
|
||||
full_response.append(chunk)
|
||||
escaped = chunk.replace('"', '\\"').replace('\n', '\\n')
|
||||
yield f"data: {{\"type\":\"chunk\",\"value\":\"{escaped}\"}}\n\n"
|
||||
|
||||
# アシスタントメッセージを保存
|
||||
assistant_content = "".join(full_response)
|
||||
db.add(Message(session_id=session.id, role="assistant", content=assistant_content))
|
||||
session.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
# メモリ要約チェック
|
||||
await mem.maybe_summarize(db, session)
|
||||
|
||||
yield "data: {\"type\":\"done\"}\n\n"
|
||||
|
||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||
@@ -0,0 +1,33 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.db.models import Memory
|
||||
|
||||
router = APIRouter(prefix="/memory", tags=["memory"])
|
||||
|
||||
|
||||
class MemoryResponse(BaseModel):
|
||||
id: int
|
||||
session_id: str
|
||||
summary: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/{session_id}", response_model=list[MemoryResponse])
|
||||
def get_memories(session_id: str, db: DBSession = Depends(get_db)):
|
||||
return db.query(Memory).filter(Memory.session_id == session_id).order_by(Memory.created_at).all()
|
||||
|
||||
|
||||
@router.delete("/{memory_id}", status_code=204)
|
||||
def delete_memory(memory_id: int, db: DBSession = Depends(get_db)):
|
||||
memory = db.get(Memory, memory_id)
|
||||
if not memory:
|
||||
raise HTTPException(status_code=404, detail="Memory not found")
|
||||
db.delete(memory)
|
||||
db.commit()
|
||||
@@ -0,0 +1,9 @@
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
load_dotenv()
|
||||
|
||||
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(",")
|
||||
@@ -0,0 +1,29 @@
|
||||
from typing import AsyncIterator
|
||||
import litellm
|
||||
from app.core.config import DEFAULT_MODEL
|
||||
|
||||
litellm.drop_params = True
|
||||
|
||||
|
||||
async def stream_chat(
|
||||
messages: list[dict],
|
||||
model: str | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
response = await litellm.acompletion(
|
||||
model=model or DEFAULT_MODEL,
|
||||
messages=messages,
|
||||
stream=True,
|
||||
)
|
||||
async for chunk in response:
|
||||
delta = chunk.choices[0].delta.content
|
||||
if delta:
|
||||
yield delta
|
||||
|
||||
|
||||
async def complete(messages: list[dict], model: str | None = None) -> str:
|
||||
response = await litellm.acompletion(
|
||||
model=model or DEFAULT_MODEL,
|
||||
messages=messages,
|
||||
stream=False,
|
||||
)
|
||||
return response.choices[0].message.content or ""
|
||||
@@ -0,0 +1,44 @@
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
from app.db.models import Memory, Message, Session
|
||||
from app.core.config import MEMORY_SUMMARIZE_THRESHOLD
|
||||
from app.core import llm
|
||||
|
||||
|
||||
def get_context_messages(db: DBSession, session_id: str) -> list[dict]:
|
||||
"""短期メモリ(最近のメッセージ)と長期メモリ(要約)を結合してコンテキストを返す。"""
|
||||
memories = db.query(Memory).filter(Memory.session_id == session_id).order_by(Memory.created_at).all()
|
||||
messages = db.query(Message).filter(Message.session_id == session_id).order_by(Message.created_at).all()
|
||||
|
||||
context: list[dict] = []
|
||||
|
||||
if memories:
|
||||
combined_summary = "\n\n".join(m.summary for m in memories)
|
||||
context.append({
|
||||
"role": "system",
|
||||
"content": f"以下はこの会話の過去の要約です:\n\n{combined_summary}",
|
||||
})
|
||||
|
||||
context.extend({"role": m.role, "content": m.content} for m in messages)
|
||||
return context
|
||||
|
||||
|
||||
async def maybe_summarize(db: DBSession, session: Session) -> None:
|
||||
"""メッセージ数が閾値を超えたら古いメッセージを要約してMemoryに保存し、元メッセージを削除する。"""
|
||||
messages = db.query(Message).filter(Message.session_id == session.id).order_by(Message.created_at).all()
|
||||
if len(messages) < MEMORY_SUMMARIZE_THRESHOLD:
|
||||
return
|
||||
|
||||
# 古いメッセージ(閾値の半分)を要約対象にする
|
||||
half = MEMORY_SUMMARIZE_THRESHOLD // 2
|
||||
to_summarize = messages[:half]
|
||||
|
||||
summary_prompt = [
|
||||
{"role": "system", "content": "以下の会話を簡潔に要約してください。重要な事実・決定・文脈を保持してください。"},
|
||||
*[{"role": m.role, "content": m.content} for m in to_summarize],
|
||||
]
|
||||
summary_text = await llm.complete(summary_prompt)
|
||||
|
||||
db.add(Memory(session_id=session.id, summary=summary_text))
|
||||
for msg in to_summarize:
|
||||
db.delete(msg)
|
||||
db.commit()
|
||||
@@ -0,0 +1,23 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||
from app.core.config import DATABASE_URL
|
||||
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
from app.db import models # noqa: F401
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -0,0 +1,41 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class Session(Base):
|
||||
__tablename__ = "sessions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String, default="New Chat")
|
||||
model: Mapped[str] = mapped_column(String, default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
messages: Mapped[list["Message"]] = relationship(back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at")
|
||||
memories: Mapped[list["Memory"]] = relationship(back_populates="session", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[str] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE"))
|
||||
role: Mapped[str] = mapped_column(String) # "user" | "assistant" | "system"
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
session: Mapped["Session"] = relationship(back_populates="messages")
|
||||
|
||||
|
||||
class Memory(Base):
|
||||
__tablename__ = "memories"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[str] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE"))
|
||||
summary: Mapped[str] = mapped_column(Text)
|
||||
embedding_json: Mapped[str | None] = mapped_column(Text, nullable=True) # 将来のベクトル検索用
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
session: Mapped["Session"] = relationship(back_populates="memories")
|
||||
@@ -0,0 +1,30 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.config import CORS_ORIGINS
|
||||
from app.db.database import init_db
|
||||
from app.api import chat, memory
|
||||
|
||||
app = FastAPI(title="PlayChat API", version="0.1.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
init_db()
|
||||
|
||||
|
||||
app.include_router(chat.router)
|
||||
app.include_router(memory.router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,24 @@
|
||||
[project]
|
||||
name = "playchat-backend"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.111.0",
|
||||
"uvicorn[standard]>=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",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# API リファレンス
|
||||
|
||||
ベースURL: `http://localhost:8000`
|
||||
Swagger UI: `http://localhost:8000/docs`
|
||||
|
||||
## チャット
|
||||
|
||||
### POST /chat/stream
|
||||
SSE でストリーミング応答を返す。
|
||||
|
||||
**リクエストボディ**
|
||||
```json
|
||||
{
|
||||
"message": "こんにちは",
|
||||
"session_id": "uuid (省略時は新規セッション作成)",
|
||||
"model": "gpt-4o (省略時は DEFAULT_MODEL)"
|
||||
}
|
||||
```
|
||||
|
||||
**SSEイベント**
|
||||
```
|
||||
data: {"type":"session_id","value":"<uuid>"} // 新規セッション時のみ
|
||||
data: {"type":"chunk","value":"テキスト断片"}
|
||||
data: {"type":"done"}
|
||||
```
|
||||
|
||||
### GET /chat/sessions
|
||||
セッション一覧を `updated_at` 降順で返す。
|
||||
|
||||
### DELETE /chat/sessions/{session_id}
|
||||
セッションと紐づくメッセージ・メモリをすべて削除。
|
||||
|
||||
## メモリ
|
||||
|
||||
### GET /memory/{session_id}
|
||||
セッションの長期メモリ(要約)一覧を返す。
|
||||
|
||||
### DELETE /memory/{memory_id}
|
||||
特定のメモリエントリを削除。
|
||||
@@ -0,0 +1,49 @@
|
||||
# アーキテクチャ概要
|
||||
|
||||
## 全体構成
|
||||
|
||||
```
|
||||
フロントエンド (Vue 3 + TypeScript + Vite, port 5173)
|
||||
↕ REST / SSE
|
||||
バックエンド (FastAPI + Python, port 8000)
|
||||
↕
|
||||
SQLite (playchat.db)
|
||||
↕
|
||||
LiteLLM → OpenAI / Anthropic / Ollama (任意のLLM)
|
||||
```
|
||||
|
||||
## バックエンド
|
||||
|
||||
### エントリーポイント
|
||||
`app/main.py` — FastAPI インスタンス作成、CORS 設定、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}` | メモリ削除 |
|
||||
|
||||
### メモリ機構
|
||||
- **短期メモリ**: セッション内のすべてのメッセージをコンテキストとして送信
|
||||
- **長期メモリ**: メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(デフォルト20) を超えると古い半分をLLMで要約し `memories` テーブルに保存
|
||||
- 要約はシステムメッセージとして次回以降のコンテキスト先頭に注入される
|
||||
|
||||
### LLM 接続
|
||||
`app/core/llm.py` が LiteLLM を薄くラップ。モデル名は `DEFAULT_MODEL` 環境変数で制御。
|
||||
ローカルの Ollama は `ollama/llama3` のように `ollama/` プレフィックスで指定。
|
||||
|
||||
## フロントエンド
|
||||
|
||||
### 状態管理
|
||||
`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`(完了)。
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PlayChat</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "playchat-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.2.0",
|
||||
"vue-tsc": "^2.0.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import SessionList from './components/SessionList.vue'
|
||||
import ChatWindow from './components/ChatWindow.vue'
|
||||
import MessageInput from './components/MessageInput.vue'
|
||||
import ModelSelector from './components/ModelSelector.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<SessionList />
|
||||
<div class="main">
|
||||
<header>
|
||||
<span class="title">PlayChat</span>
|
||||
<ModelSelector />
|
||||
</header>
|
||||
<ChatWindow />
|
||||
<MessageInput />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body, #app { height: 100%; }
|
||||
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;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
height: 48px;
|
||||
border-bottom: 1px solid #333;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
export interface SessionInfo {
|
||||
id: string
|
||||
title: string
|
||||
model: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SSEChunk {
|
||||
type: 'session_id' | 'chunk' | 'done'
|
||||
value?: string
|
||||
}
|
||||
|
||||
const BASE = ''
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
await fetch(`${BASE}/chat/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function streamChat(
|
||||
message: string,
|
||||
sessionId: string | null,
|
||||
model: string,
|
||||
onChunk: (chunk: SSEChunk) => void,
|
||||
onError: (err: Error) => void,
|
||||
): () => void {
|
||||
const controller = new AbortController()
|
||||
|
||||
fetch(`${BASE}/chat/stream`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, session_id: sessionId, model }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.body) throw new Error('No response body')
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const parsed: SSEChunk = JSON.parse(line.slice(6))
|
||||
onChunk(parsed)
|
||||
} catch {
|
||||
// ignore malformed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name !== 'AbortError') onError(err)
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { useChatStore } from '../stores/chat'
|
||||
|
||||
const store = useChatStore()
|
||||
const scrollEl = ref<HTMLElement | null>(null)
|
||||
|
||||
watch(
|
||||
() => store.messages.length,
|
||||
async () => {
|
||||
await nextTick()
|
||||
if (scrollEl.value) scrollEl.value.scrollTop = scrollEl.value.scrollHeight
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-window" ref="scrollEl">
|
||||
<div v-if="store.messages.length === 0" class="empty">
|
||||
メッセージを送信してチャットを開始してください
|
||||
</div>
|
||||
<div
|
||||
v-for="(msg, i) in store.messages"
|
||||
:key="i"
|
||||
:class="['message', msg.role, { pending: msg.pending }]"
|
||||
>
|
||||
<div class="bubble">
|
||||
<span class="content">{{ msg.content }}</span>
|
||||
<span v-if="msg.pending" class="cursor">▌</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-window {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.empty {
|
||||
margin: auto;
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
}
|
||||
.message {
|
||||
display: flex;
|
||||
}
|
||||
.message.user { justify-content: flex-end; }
|
||||
.message.assistant { justify-content: flex-start; }
|
||||
.bubble {
|
||||
max-width: 72%;
|
||||
padding: 10px 14px;
|
||||
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;
|
||||
}
|
||||
.assistant .bubble {
|
||||
background: #2a2a2a;
|
||||
color: #eee;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
.cursor {
|
||||
animation: blink 0.8s step-end infinite;
|
||||
}
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useChatStore } from '../stores/chat'
|
||||
|
||||
const store = useChatStore()
|
||||
const text = ref('')
|
||||
|
||||
function submit() {
|
||||
const msg = text.value.trim()
|
||||
if (!msg || store.isStreaming) return
|
||||
store.sendMessage(msg)
|
||||
text.value = ''
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="input-bar">
|
||||
<textarea
|
||||
v-model="text"
|
||||
:disabled="store.isStreaming"
|
||||
placeholder="メッセージを入力 (Enter で送信、Shift+Enter で改行)"
|
||||
rows="1"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<button :disabled="store.isStreaming || !text.trim()" @click="submit">送信</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #333;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
textarea {
|
||||
flex: 1;
|
||||
background: #2a2a2a;
|
||||
color: #eee;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
}
|
||||
textarea:focus { outline: none; border-color: #2563eb; }
|
||||
button {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
button:hover:not(:disabled) { background: #1d4ed8; }
|
||||
button:disabled { background: #333; color: #666; cursor: not-allowed; }
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
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: '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' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="model-selector">
|
||||
<label>モデル</label>
|
||||
<select v-model="store.selectedModel">
|
||||
<option v-for="p in presets" :key="p.value" :value="p.value">{{ p.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.model-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
label {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
select {
|
||||
background: #2a2a2a;
|
||||
color: #eee;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useChatStore } from '../stores/chat'
|
||||
|
||||
const store = useChatStore()
|
||||
|
||||
onMounted(() => store.loadSessions())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="session-list">
|
||||
<div class="header">
|
||||
<span>チャット履歴</span>
|
||||
<button class="new-btn" @click="store.selectSession(null)">+ 新規</button>
|
||||
</div>
|
||||
<ul>
|
||||
<li
|
||||
v-for="s in store.sessions"
|
||||
:key="s.id"
|
||||
:class="{ active: s.id === store.currentSessionId }"
|
||||
@click="store.selectSession(s.id)"
|
||||
>
|
||||
<span class="title">{{ s.title }}</span>
|
||||
<button
|
||||
class="del-btn"
|
||||
@click.stop="store.removeSession(s.id)"
|
||||
title="削除"
|
||||
>✕</button>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.session-list {
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
background: #1a1a1a;
|
||||
border-right: 1px solid #333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 12px 8px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
.new-btn {
|
||||
background: #2a2a2a;
|
||||
color: #ccc;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
margin: 2px 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
li:hover { background: #2a2a2a; }
|
||||
li.active { background: #333; }
|
||||
.title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
color: #ddd;
|
||||
}
|
||||
.del-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
padding: 0 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
li:hover .del-btn { opacity: 1; }
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).use(createPinia()).mount('#app')
|
||||
@@ -0,0 +1,84 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { fetchSessions, deleteSession, streamChat, type SessionInfo } from '../api/client'
|
||||
|
||||
export interface Message {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
pending?: boolean
|
||||
}
|
||||
|
||||
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 currentSession = computed(() =>
|
||||
sessions.value.find((s) => s.id === currentSessionId.value) ?? null,
|
||||
)
|
||||
|
||||
async function loadSessions() {
|
||||
sessions.value = await fetchSessions()
|
||||
}
|
||||
|
||||
function selectSession(sessionId: string | null) {
|
||||
currentSessionId.value = sessionId
|
||||
messages.value = []
|
||||
}
|
||||
|
||||
async function removeSession(sessionId: string) {
|
||||
await deleteSession(sessionId)
|
||||
if (currentSessionId.value === sessionId) {
|
||||
currentSessionId.value = null
|
||||
messages.value = []
|
||||
}
|
||||
await loadSessions()
|
||||
}
|
||||
|
||||
function sendMessage(text: string): void {
|
||||
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)
|
||||
isStreaming.value = true
|
||||
|
||||
streamChat(
|
||||
text,
|
||||
currentSessionId.value,
|
||||
selectedModel.value,
|
||||
(chunk) => {
|
||||
if (chunk.type === 'session_id' && chunk.value) {
|
||||
currentSessionId.value = chunk.value
|
||||
loadSessions()
|
||||
} else if (chunk.type === 'chunk' && chunk.value) {
|
||||
assistantMsg.content += chunk.value
|
||||
} else if (chunk.type === 'done') {
|
||||
assistantMsg.pending = false
|
||||
isStreaming.value = false
|
||||
loadSessions()
|
||||
}
|
||||
},
|
||||
(_err) => {
|
||||
assistantMsg.content = 'エラーが発生しました。'
|
||||
assistantMsg.pending = false
|
||||
isStreaming.value = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
sessions,
|
||||
currentSessionId,
|
||||
messages,
|
||||
isStreaming,
|
||||
selectedModel,
|
||||
currentSession,
|
||||
loadSessions,
|
||||
selectSession,
|
||||
removeSession,
|
||||
sendMessage,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/chat': 'http://localhost:8000',
|
||||
'/memory': 'http://localhost:8000',
|
||||
'/health': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user