From 903d34761c4d55ba953724a93ca0ee2af4a8db90 Mon Sep 17 00:00:00 2001 From: zukki259 Date: Mon, 1 Jun 2026 20:16:53 +0900 Subject: [PATCH] initial_commit --- .claude/settings.local.json | 7 ++ .gitignore | 39 +++++++++ CLAUDE.md | 84 +++++++++++++++++++ backend/.env.example | 13 +++ backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/chat.py | 91 +++++++++++++++++++++ backend/app/api/memory.py | 33 ++++++++ backend/app/core/__init__.py | 0 backend/app/core/config.py | 9 +++ backend/app/core/llm.py | 29 +++++++ backend/app/core/memory.py | 44 ++++++++++ backend/app/db/__init__.py | 0 backend/app/db/database.py | 23 ++++++ backend/app/db/models.py | 41 ++++++++++ backend/app/main.py | 30 +++++++ backend/pyproject.toml | 24 ++++++ docs/api.md | 39 +++++++++ docs/architecture.md | 49 +++++++++++ frontend/index.html | 12 +++ frontend/package.json | 23 ++++++ frontend/src/App.vue | 53 ++++++++++++ frontend/src/api/client.ts | 69 ++++++++++++++++ frontend/src/components/ChatWindow.vue | 77 ++++++++++++++++++ frontend/src/components/MessageInput.vue | 69 ++++++++++++++++ frontend/src/components/ModelSelector.vue | 43 ++++++++++ frontend/src/components/SessionList.vue | 99 +++++++++++++++++++++++ frontend/src/main.ts | 5 ++ frontend/src/stores/chat.ts | 84 +++++++++++++++++++ frontend/tsconfig.json | 20 +++++ frontend/vite.config.ts | 14 ++++ 31 files changed, 1123 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 backend/.env.example create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/chat.py create mode 100644 backend/app/api/memory.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/llm.py create mode 100644 backend/app/core/memory.py create mode 100644 backend/app/db/__init__.py create mode 100644 backend/app/db/database.py create mode 100644 backend/app/db/models.py create mode 100644 backend/app/main.py create mode 100644 backend/pyproject.toml create mode 100644 docs/api.md create mode 100644 docs/architecture.md create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/components/ChatWindow.vue create mode 100644 frontend/src/components/MessageInput.vue create mode 100644 frontend/src/components/ModelSelector.vue create mode 100644 frontend/src/components/SessionList.vue create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/stores/chat.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..7070c16 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "PowerShell(Head-Object -n 50)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3e48d92 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7f2de02 --- /dev/null +++ b/CLAUDE.md @@ -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` でエンドポイントを設定。 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..1e9a1ef --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py new file mode 100644 index 0000000..ee0ccab --- /dev/null +++ b/backend/app/api/chat.py @@ -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") diff --git a/backend/app/api/memory.py b/backend/app/api/memory.py new file mode 100644 index 0000000..cc34190 --- /dev/null +++ b/backend/app/api/memory.py @@ -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() diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..ae78b5e --- /dev/null +++ b/backend/app/core/config.py @@ -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(",") diff --git a/backend/app/core/llm.py b/backend/app/core/llm.py new file mode 100644 index 0000000..4642b21 --- /dev/null +++ b/backend/app/core/llm.py @@ -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 "" diff --git a/backend/app/core/memory.py b/backend/app/core/memory.py new file mode 100644 index 0000000..836732f --- /dev/null +++ b/backend/app/core/memory.py @@ -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() diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db/database.py b/backend/app/db/database.py new file mode 100644 index 0000000..b545a38 --- /dev/null +++ b/backend/app/db/database.py @@ -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) diff --git a/backend/app/db/models.py b/backend/app/db/models.py new file mode 100644 index 0000000..4142922 --- /dev/null +++ b/backend/app/db/models.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..0caf0ae --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..26bf402 --- /dev/null +++ b/backend/pyproject.toml @@ -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" diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..f5dd4b8 --- /dev/null +++ b/docs/api.md @@ -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":""} // 新規セッション時のみ +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} +特定のメモリエントリを削除。 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..1be4ed6 --- /dev/null +++ b/docs/architecture.md @@ -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`(完了)。 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..34f2c26 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + PlayChat + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1504985 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..cdf46a6 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,53 @@ + + + + + + + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..6df468f --- /dev/null +++ b/frontend/src/api/client.ts @@ -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 { + 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 { + 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() +} diff --git a/frontend/src/components/ChatWindow.vue b/frontend/src/components/ChatWindow.vue new file mode 100644 index 0000000..7732832 --- /dev/null +++ b/frontend/src/components/ChatWindow.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/frontend/src/components/MessageInput.vue b/frontend/src/components/MessageInput.vue new file mode 100644 index 0000000..9124c1f --- /dev/null +++ b/frontend/src/components/MessageInput.vue @@ -0,0 +1,69 @@ + + +