initial_commit

This commit is contained in:
2026-06-01 20:16:53 +09:00
commit 903d34761c
31 changed files with 1123 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"PowerShell(Head-Object -n 50)"
]
}
}
+39
View File
@@ -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
+84
View File
@@ -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` でエンドポイントを設定。
+13
View File
@@ -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
View File
View File
+91
View File
@@ -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")
+33
View File
@@ -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()
View File
+9
View File
@@ -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(",")
+29
View File
@@ -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 ""
+44
View File
@@ -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()
View File
+23
View File
@@ -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)
+41
View File
@@ -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")
+30
View File
@@ -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"}
+24
View File
@@ -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
View File
@@ -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}
特定のメモリエントリを削除。
+49
View File
@@ -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`(完了)。
+12
View File
@@ -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>
+23
View File
@@ -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"
}
}
+53
View File
@@ -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>
+69
View File
@@ -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()
}
+77
View File
@@ -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>
+69
View File
@@ -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>
+43
View File
@@ -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>
+99
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')
+84
View File
@@ -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,
}
})
+20
View File
@@ -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"]
}
+14
View File
@@ -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',
},
},
})