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