init: Kira — AI body double with Honcho memory
Full voice pipeline (Whisper STT -> DeepSeek LLM -> OpenAI TTS), animated SVG avatar (Live2D-ready), girly-pop UI, lofi music, timer/notes/pets/wardrobe widgets, 10 background scenes with particle effects, Honcho cross-session memory.
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
# ─── Kira Backend ───
|
||||||
|
# OpenAI for STT (Whisper) and TTS
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
# DeepSeek for LLM (personality/brain)
|
||||||
|
DEEPSEEK_API_KEY=sk-...
|
||||||
|
DEEPSEEK_MODEL=deepseek-chat
|
||||||
|
|
||||||
|
# ─── Honcho Memory (optional) ───
|
||||||
|
# Get API key at https://app.honcho.dev/api-keys
|
||||||
|
# Uses production cloud by default. Set HONCHO_BASE_URL for local/self-hosted.
|
||||||
|
HONCHO_API_KEY=hch-...
|
||||||
|
DEEPSEEK_API_KEY=sk-your-deepseek-api-key-here
|
||||||
|
DEEPSEEK_MODEL=deepseek-chat
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
backend/.env
|
||||||
|
.venv
|
||||||
+199
@@ -0,0 +1,199 @@
|
|||||||
|
# Kira — Architecture Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Browser-based real-time AI body double. She talks to Kira (microphone → STT → LLM → TTS → speaker), Kira talks back with lip-sync. Lo-fi from Lofi Girl streaming in the background, two cats hanging out, customizable background scenes, timers, notes, and a full wardrobe/accessory system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Choice | Why |
|
||||||
|
|-------|--------|-----|
|
||||||
|
| **Frontend** | React 18 + Vite + TypeScript | Fast dev loop, runs in any browser |
|
||||||
|
| **Styling** | TailwindCSS + custom girly-pop theme | Pink/lavender/mint palette, rounded everything |
|
||||||
|
| **Live2D** | Cubism 4 Web SDK via pixi-live2d-display | Full lip-sync, gestures, blink, idle anims |
|
||||||
|
| **Backend** | Python FastAPI + WebSockets | Async-native, easy AI API integration |
|
||||||
|
| **STT** | OpenAI Whisper API | Best transcription, <$0.01/min |
|
||||||
|
| **LLM** | DeepSeek V4 (cloud API) | Smart, fast reasoning, good personality adherence |
|
||||||
|
| **TTS** | OpenAI TTS API | Clean female voice, low latency |
|
||||||
|
| **Music** | YouTube IFrame Player API (Lofi Girl channel) | Free, endless streams, no backend proxy |
|
||||||
|
| **Audio Pipeline** | MediaRecorder → chunks → WebSocket → backend | Low-latency real-time conversation |
|
||||||
|
| **Infrastructure** | Docker Compose + Caddy reverse proxy | Deployable in homelab immediately |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow (Conversation)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Browser │
|
||||||
|
│ │
|
||||||
|
│ [Mic] → MediaRecorder → audio chunks │
|
||||||
|
│ ↓ (WebSocket) │
|
||||||
|
│ [FastAPI Backend] │
|
||||||
|
│ ↓ │
|
||||||
|
│ 1. Whisper API → text transcript │
|
||||||
|
│ 2. DeepSeek V4 (system prompt: "You are Kira...") │
|
||||||
|
│ 3. OpenAI TTS → audio buffer │
|
||||||
|
│ ↑ (WebSocket) │
|
||||||
|
│ [Audio Player + Live2D Lip-Sync] │
|
||||||
|
│ │
|
||||||
|
│ Kira's idle animations run between conversation turns │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Lo-fi music runs independently via YouTube embed — no backend involvement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
ai-body-double/
|
||||||
|
├── docker-compose.yml # Single compose for both services
|
||||||
|
├── .env.example # API keys template
|
||||||
|
├── backend/
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ ├── main.py # FastAPI app + WebSocket handler
|
||||||
|
│ ├── config.py # Environment/API config
|
||||||
|
│ ├── routers/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── conversation.py # WS: mic → STT → LLM → TTS roundtrip
|
||||||
|
│ │ ├── tools.py # REST: timers, notes, backgrounds
|
||||||
|
│ │ └── assets.py # REST: outfit/pet state, backgrounds
|
||||||
|
│ ├── services/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── stt.py # Whisper API client
|
||||||
|
│ │ ├── llm.py # DeepSeek chat client
|
||||||
|
│ │ └── tts.py # OpenAI TTS client
|
||||||
|
│ └── models/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ └── schemas.py # Pydantic models
|
||||||
|
├── frontend/
|
||||||
|
│ ├── Dockerfile (nginx)
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── vite.config.ts
|
||||||
|
│ ├── tailwind.config.js
|
||||||
|
│ ├── index.html
|
||||||
|
│ ├── public/
|
||||||
|
│ │ └── live2d/ # Live2D model files
|
||||||
|
│ │ ├── kira.model3.json
|
||||||
|
│ │ ├── kira.moc3
|
||||||
|
│ │ └── textures/
|
||||||
|
│ └── src/
|
||||||
|
│ ├── main.tsx
|
||||||
|
│ ├── App.tsx
|
||||||
|
│ ├── api/
|
||||||
|
│ │ └── ws.ts # WebSocket client
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── KiraAvatar.tsx # Live2D canvas wrapper
|
||||||
|
│ │ ├── BackgroundScene.tsx # Scene selector + overlay
|
||||||
|
│ │ ├── MusicPlayer.tsx # Lofi Girl YouTube embed
|
||||||
|
│ │ ├── Timer.tsx # Pomodoro + countdown
|
||||||
|
│ │ ├── Notes.tsx # Quick notes widget
|
||||||
|
│ │ ├── Clock.tsx # Digital clock
|
||||||
|
│ │ ├── PetZone.tsx # Two cats 🐱🐱
|
||||||
|
│ │ ├── Toolbar.tsx # Bottom nav
|
||||||
|
│ │ └── Wardrobe.tsx # Outfit/accessory picker
|
||||||
|
│ ├── hooks/
|
||||||
|
│ │ ├── useAudio.ts # Mic recording + playback
|
||||||
|
│ │ └── useKiraState.ts # Shared state
|
||||||
|
│ ├── styles/
|
||||||
|
│ │ └── theme.css # Girly-pop palette
|
||||||
|
│ └── types/
|
||||||
|
│ └── index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 Build Order
|
||||||
|
|
||||||
|
I'll build this in dependency order so each piece is testable:
|
||||||
|
|
||||||
|
### Phase 1a — Skeleton (this session)
|
||||||
|
1. Project scaffold (frontend + backend + docker-compose)
|
||||||
|
2. Basic UI layout with girly-pop styling
|
||||||
|
3. Clock widget
|
||||||
|
4. Background scene selector (CSS gradient scenes)
|
||||||
|
5. Music player (Lofi Girl YouTube embed)
|
||||||
|
6. Timer widget (Pomodoro + countdown)
|
||||||
|
|
||||||
|
### Phase 1b — Audio Pipeline
|
||||||
|
7. Backend: FastAPI + WebSocket handler
|
||||||
|
8. Backend: STT service (Whisper API)
|
||||||
|
9. Backend: LLM service (DeepSeek)
|
||||||
|
10. Backend: TTS service (OpenAI)
|
||||||
|
11. Frontend: Microphone recording → WebSocket
|
||||||
|
12. Frontend: Audio playback + conversation UI
|
||||||
|
|
||||||
|
### Phase 1c — Kira the Avatar
|
||||||
|
13. Integrate Live2D SDK with placeholder model
|
||||||
|
14. Idle animations (blink, breath, random gestures)
|
||||||
|
15. Lip-sync from TTS audio
|
||||||
|
16. Wardrobe/outfit system
|
||||||
|
|
||||||
|
### Phase 1d — Cats & Polish
|
||||||
|
17. Pet zone with two cats (CSS-animated sprites)
|
||||||
|
18. Notes widget
|
||||||
|
19. Color polish, transitions, responsive layout
|
||||||
|
20. Docker compose finalization + Caddy config
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### Live2D Model
|
||||||
|
- We'll use a free sample Live2D model (Hiyori or Mao from Cubism SDK samples) as a placeholder
|
||||||
|
- Custom "Kira" model can be commissioned and swapped in later — the SDK integration is identical
|
||||||
|
- Lip-sync driven by analyzing TTS audio amplitude (simplified: no phoneme mapping needed)
|
||||||
|
|
||||||
|
### Background Scenes
|
||||||
|
- CSS gradient + pattern scenes for Phase 1 (no image assets needed)
|
||||||
|
- Scene types: Cozy Room (warm), Coffee Shop (browns), Garden (greens), Rainy Window (blues), Starry Night (dark purples), Sakura Spring (pinks)
|
||||||
|
- Scene = animated CSS background + subtle particle overlay (rain, stars, petals)
|
||||||
|
|
||||||
|
### The Two Cats
|
||||||
|
- CSS/Canvas-animated sprites (not Live2D — overkill for cats)
|
||||||
|
- Orange fluffy: larger, follows cursor sometimes, stretches out
|
||||||
|
- Black shorthair: smaller, sleeps curled up, occasional tail twitch
|
||||||
|
|
||||||
|
### Outfit System
|
||||||
|
- Live2D models support model.json texture swapping
|
||||||
|
- Phase 1: pre-defined color palettes/outfits that swap texture files
|
||||||
|
- Outfits: Cozy Hoodie, Girly Dress, Pajama Set, Study Sweater, Going Out
|
||||||
|
|
||||||
|
### Conversation Personality
|
||||||
|
- System prompt defines: female, kind, encouraging, ADHD-aware, body double presence
|
||||||
|
- Kira checks in: "How's it going? Need a timer? Want me to pick a scene?"
|
||||||
|
- Encouragement: "You've got this! 15 minutes down." / "Time for a stretch break?"
|
||||||
|
- Never judgmental. Always supportive.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Palette (Girly-Pop)
|
||||||
|
|
||||||
|
```
|
||||||
|
Primary bg: #FFF5F5 (soft pink-white)
|
||||||
|
Accent pink: #FFB6C1 (light pink)
|
||||||
|
Accent lav: #D8B4FE (lavender)
|
||||||
|
Accent mint: #A7F3D0 (mint)
|
||||||
|
Text primary: #4A1942 (deep plum)
|
||||||
|
Text soft: #7C3AED (violet)
|
||||||
|
Card bg: #FFFFFF (with soft shadow)
|
||||||
|
Highlight: #FDF2F8 (pink glow)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
Frontend: http://kira.hobokenchicken.com (via Caddy)
|
||||||
|
Backend: ws://kira.hobokenchicken.com/api/ws (WebSocket)
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs on existing homelab stack. Add Caddy entry pointing to the frontend container.
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# AI Body Double — Project Scope Questions
|
||||||
|
|
||||||
|
## 1. Avatar Style
|
||||||
|
|
||||||
|
What visual direction for the character companion?
|
||||||
|
|
||||||
|
- **Option A: Cute 2D illustrated character** — Simple animated sprite (blink, sway, idle bounce, wave). Think a stylized flat-vector character. Fast to build, runs anywhere.
|
||||||
|
- **Option B: Live2D / Vtuber-style** — Full rigged character with lip-sync, gestures, head tracking. Looks incredible but requires custom art assets and is significantly more work.
|
||||||
|
- **Option C: Pixel art character** — Retro chibi sprite with simple animations. Cozy, low-fi aesthetic.
|
||||||
|
- **Option D: No character art yet** — Start with a clean UI dashboard, add the character later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Voice (TTS)
|
||||||
|
|
||||||
|
What level of voice quality?
|
||||||
|
|
||||||
|
- **Option A: Cloud API (ElevenLabs)** — Best quality female voices, natural intonation, can do "girly-pop" vibe. ~$5/month.
|
||||||
|
- **Option B: Local Piper TTS** — Free, self-hosted, no recurring cost. Lower quality, more robotic, but plenty of female voice models available.
|
||||||
|
- **Option C: OpenAI TTS** — Good quality, pay-per-use (~$0.015/minute). Middle ground.
|
||||||
|
- **Option D: No voice yet** — Start text-only, add TTS later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Platform
|
||||||
|
|
||||||
|
Where does this need to run?
|
||||||
|
|
||||||
|
- **Browser-based (web app)** — Accessible from any laptop/phone/tablet on the home network. Easiest to build and iterate.
|
||||||
|
- **Desktop app (Electron/Tauri)** — Native feel, offline capable. More work.
|
||||||
|
- **Mobile app** — Phone-native experience. Most work.
|
||||||
|
- **Both** — Browser for laptop, but also accessible on phone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Start Scope
|
||||||
|
|
||||||
|
How should we slice the first working version?
|
||||||
|
|
||||||
|
- **Option A: MVP — Character + Lo-fi + Timer + Notes**
|
||||||
|
Build the visual companion, lo-fi music player, pomodoro/focus timer, and a notes widget. Get the "presence" and toolset right first. Add voice interactivity in phase 2.
|
||||||
|
|
||||||
|
- **Option B: Full build — Everything including voice**
|
||||||
|
Go straight for the full pipeline: STT (microphone input) -> LLM (processes what she says) -> TTS (talks back) + all tools. Longer first delivery but one complete system.
|
||||||
|
|
||||||
|
- **Option C: Somewhere in between**
|
||||||
|
Build the dashboard + character + tools, plus TTS only (so the assistant talks to her but doesn't listen yet). Add microphone input in phase 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. LLM Backend (Brain)
|
||||||
|
|
||||||
|
What powers the assistant's responses and personality?
|
||||||
|
|
||||||
|
- **Local (Ollama)** — Self-hosted, free, private. You've got an AMD 6650 XT that can accelerate inference. Runs models like Llama 3, Mistral, Qwen.
|
||||||
|
- **Cloud API** — Better personality/instruction following. OpenAI, Anthropic, or similar. Small cost per month.
|
||||||
|
- **Not needed at first** — Start with scripted responses and canned encouragement, add AI conversational ability later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Music & Audio
|
||||||
|
|
||||||
|
How should the lo-fi / white noise work?
|
||||||
|
|
||||||
|
- **Streaming (YouTube/SoundCloud URLs)** — Curate playlists of lo-fi study beats. Free, endless variety.
|
||||||
|
- **Local audio files** — Download lo-fi tracks and ambient sounds. Works offline.
|
||||||
|
- **Generated** — Use AI music generation for custom tracks. Experimental.
|
||||||
|
- **Integrated web player** — Embed something like Spotify or YouTube Music.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Virtual Pet
|
||||||
|
|
||||||
|
What kind of pet?
|
||||||
|
|
||||||
|
- **Cat** — Fits the cozy/girly aesthetic. Classic.
|
||||||
|
- **Dog** — Energetic companion.
|
||||||
|
- **Fantasy creature** — A cute blob/slime/fairy/dragon.
|
||||||
|
- **Customizable** — Let her pick, or unlock different ones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Background Scenes
|
||||||
|
|
||||||
|
What environments should be available?
|
||||||
|
|
||||||
|
- Cozy bedroom / study
|
||||||
|
- Coffee shop / café
|
||||||
|
- Garden / nature
|
||||||
|
- Rainy window
|
||||||
|
- Starry night / space
|
||||||
|
- Underwater / aquarium
|
||||||
|
- Minimalist / clean
|
||||||
|
- Seasonal (winter cabin, spring garden, autumn library)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Name (optional)
|
||||||
|
|
||||||
|
What should we call this? Some ideas:
|
||||||
|
|
||||||
|
- **Buddy** (simple)
|
||||||
|
- **CozyFocus** / **CoPilot** (functional)
|
||||||
|
- **Luna** / **Mochi** / **Coco** (character name)
|
||||||
|
- Something else?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Once you answer these, I'll write up the full architecture plan and start building.
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Kira ✨
|
||||||
|
|
||||||
|
AI body double — a girly-pop focus companion with real-time voice conversation, lo-fi music, ADHD tools, and a customizable avatar.
|
||||||
|
|
||||||
|
Your wife's very own focus bestie who's always there when her real body double isn't available.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **🎙️ Voice conversation** — Push-to-talk microphone → STT (Whisper) → LLM (DeepSeek) → TTS (OpenAI Nova voice)
|
||||||
|
- **💬 Text chat fallback** — Type when you don't want to speak
|
||||||
|
- **🎶 Lo-fi music** — Streaming from Lofi Girl YouTube channel
|
||||||
|
- **🍅 Timer** — Pomodoro, countdown, stopwatch modes
|
||||||
|
- **📝 Notes** — Quick task list / check-in notes
|
||||||
|
- **🎨 10 background scenes** — Cozy room, coffee shop, garden, rainy window, starry night, sakura, ocean, autumn, winter cabin, sunset
|
||||||
|
- **✨ Particle effects** — Rain, stars, cherry petals, snow per scene
|
||||||
|
- **👘 Wardrobe** — 5 outfits + 5 accessories (bow, glasses, flower crown, earrings, scarf)
|
||||||
|
- **🐱 Pet zone** — Two cats: Mochi (orange fluffy) and Luna (sleepy black)
|
||||||
|
- **🕐 Live clock** — Time + date
|
||||||
|
- **🌸 Animated avatar** — Blinking, speaking mouth, waving, outfit colors (Live2D-ready)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Get API keys
|
||||||
|
|
||||||
|
| Service | What for | Where |
|
||||||
|
|---------|----------|-------|
|
||||||
|
| OpenAI | Whisper STT + TTS (Nova voice) | https://platform.openai.com/api-keys |
|
||||||
|
| DeepSeek | LLM brain | https://platform.deepseek.com/api_keys |
|
||||||
|
|
||||||
|
### 2. Configure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Projects/ai-body-double
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your API keys:
|
||||||
|
# OPENAI_API_KEY=sk-...
|
||||||
|
# DEEPSEEK_API_KEY=sk-...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Open **http://kira.hobokenchicken.com:3000** (or wherever you deploy it).
|
||||||
|
|
||||||
|
### 4. Add a Caddy entry (homelab)
|
||||||
|
|
||||||
|
```
|
||||||
|
kira.hobokenchicken.com {
|
||||||
|
reverse_proxy 172.20.0.X:3000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser ──WebSocket──▶ Backend (FastAPI)
|
||||||
|
│ │
|
||||||
|
├─ Mic audio ──────────▶ ├─ Whisper API (STT)
|
||||||
|
│ ├─ DeepSeek (LLM)
|
||||||
|
│ ◀── TTS audio ──────── ├─ OpenAI TTS
|
||||||
|
│ │
|
||||||
|
├─ YouTube embed (lo-fi) │
|
||||||
|
├─ Timer / Notes / Cats │
|
||||||
|
└─ Animated avatar │
|
||||||
|
```
|
||||||
|
|
||||||
|
## Live2D Model Setup
|
||||||
|
|
||||||
|
Kira currently uses a CSS/SVG animated placeholder avatar. To add a Live2D model:
|
||||||
|
|
||||||
|
1. Commission or obtain a `.model3.json` (Cubism 4.x format) model
|
||||||
|
2. Place the model directory in `frontend/public/live2d/models/`
|
||||||
|
3. Rename the model entry point to `kira.model3.json`
|
||||||
|
4. The WebGL renderer will auto-detect and switch
|
||||||
|
|
||||||
|
Required model files:
|
||||||
|
- `kira.model3.json` — model definition
|
||||||
|
- `kira.moc3` — mesh/deformer data
|
||||||
|
- `kira.cdi3.json` — display info (optional)
|
||||||
|
- `textures/` — PNG texture files
|
||||||
|
- `motions/` — animation files (optional)
|
||||||
|
- `expressions/` — face expression files (optional)
|
||||||
|
|
||||||
|
Recommended creators for custom Live2D models: search VGen, VTube Studio model artists, or Fiverr.
|
||||||
|
|
||||||
|
## Color Palette
|
||||||
|
|
||||||
|
| Token | Hex | Usage |
|
||||||
|
|-------|-----|-------|
|
||||||
|
| Kira Pink | `#FFB6C1` | Primary accent |
|
||||||
|
| Kira Lavender | `#D8B4FE` | Secondary accent |
|
||||||
|
| Kira Mint | `#A7F3D0` | Success/status |
|
||||||
|
| Background | `#FFF5F5` | Card/comfy bg |
|
||||||
|
| Text Plum | `#4A1942` | Body text |
|
||||||
|
| Text Violet | `#7C3AED` | Soft text |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
ai-body-double/
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── .env
|
||||||
|
├── backend/ # FastAPI + WebSocket
|
||||||
|
│ ├── main.py # WS handler (STT→LLM→TTS pipeline)
|
||||||
|
│ ├── services/
|
||||||
|
│ │ ├── stt.py # OpenAI Whisper
|
||||||
|
│ │ ├── llm.py # DeepSeek
|
||||||
|
│ │ └── tts.py # OpenAI TTS
|
||||||
|
│ └── config.py # Env config
|
||||||
|
├── frontend/ # React + Vite + TailwindCSS
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── App.tsx # Main layout
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── AnimatedAvatar.tsx # SVG animated character
|
||||||
|
│ │ │ ├── KiraAvatar.tsx # Live2D loader + fallback
|
||||||
|
│ │ │ ├── ChatBubble.tsx # Conversation display
|
||||||
|
│ │ │ ├── Timer.tsx # Pomodoro/stopwatch
|
||||||
|
│ │ │ ├── MusicPlayer.tsx # Lofi Girl embed
|
||||||
|
│ │ │ ├── PetZone.tsx # Two CSS cats
|
||||||
|
│ │ │ ├── Wardrobe.tsx # Outfit + accessories
|
||||||
|
│ │ │ └── Particles.tsx # Rain/stars/petals/snow
|
||||||
|
│ │ └── hooks/
|
||||||
|
│ │ └── useConversation.ts # WS + audio management
|
||||||
|
│ └── public/live2d/ # Cubism SDK + model slot
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
# OpenAI (used for STT + TTS)
|
||||||
|
openai_api_key: str = ""
|
||||||
|
|
||||||
|
# DeepSeek (LLM)
|
||||||
|
deepseek_api_key: str = ""
|
||||||
|
deepseek_base_url: str = "https://api.deepseek.com/v1"
|
||||||
|
deepseek_model: str = "deepseek-chat"
|
||||||
|
|
||||||
|
# Honcho (memory)
|
||||||
|
honcho_api_key: str = ""
|
||||||
|
honcho_base_url: str = ""
|
||||||
|
|
||||||
|
# Server
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
port: int = 8000
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"env_file": ".env",
|
||||||
|
"env_file_encoding": "utf-8",
|
||||||
|
"extra": "ignore",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
+217
@@ -0,0 +1,217 @@
|
|||||||
|
"""Kira — AI body double backend
|
||||||
|
|
||||||
|
Real-time speech-to-speech pipeline:
|
||||||
|
mic audio → Whisper API → text → DeepSeek LLM → response text → OpenAI TTS → audio
|
||||||
|
|
||||||
|
Honcho memory integration:
|
||||||
|
Cross-session user context injected into LLM prompts,
|
||||||
|
conversation exchanges stored for continuous learning.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from services.stt import transcribe_audio
|
||||||
|
from services.llm import get_kira_response
|
||||||
|
from services.tts import synthesize_speech
|
||||||
|
from services.memory import kira_memory
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger("kira")
|
||||||
|
|
||||||
|
app = FastAPI(title="Kira Backend")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Base system prompt (static part) ───
|
||||||
|
BASE_SYSTEM_PROMPT = (
|
||||||
|
"You are Kira, a warm, kind, and encouraging AI body double. "
|
||||||
|
"You speak in a friendly, girly-pop tone. You are helping someone with ADHD "
|
||||||
|
"stay focused and on task. Keep responses short, supportive, and uplifting. "
|
||||||
|
"Check in on them. Remind them to take breaks. Celebrate small wins. "
|
||||||
|
"Use occasional emoji but don't overdo it. Never be judgmental. "
|
||||||
|
"You remember things about them between conversations."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup():
|
||||||
|
"""Initialize Honcho memory on app startup."""
|
||||||
|
if kira_memory.init():
|
||||||
|
logger.info("Honcho memory initialized")
|
||||||
|
else:
|
||||||
|
logger.info("Honcho memory not configured — running without memory")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
mem_status = "active" if kira_memory.enabled else "disabled"
|
||||||
|
return {"status": "ok", "name": "kira", "memory": mem_status}
|
||||||
|
|
||||||
|
|
||||||
|
def build_system_prompt(user_id: str) -> dict:
|
||||||
|
"""Build system prompt with Honcho memory context injected."""
|
||||||
|
base = BASE_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
# Append memory context if Honcho is available
|
||||||
|
if kira_memory.enabled:
|
||||||
|
try:
|
||||||
|
# Get user-specific context from Honcho
|
||||||
|
kira_memory.ensure_peers(user_id)
|
||||||
|
memory_suffix = kira_memory.build_system_prompt_suffix()
|
||||||
|
if memory_suffix:
|
||||||
|
base += memory_suffix
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to build memory context: {e}")
|
||||||
|
|
||||||
|
return {"role": "system", "content": base}
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/api/ws")
|
||||||
|
async def conversation_ws(websocket: WebSocket):
|
||||||
|
await websocket.accept()
|
||||||
|
session_id = str(uuid.uuid4())[:8]
|
||||||
|
user_id = "default-user"
|
||||||
|
logger.info(f"[{session_id}] WebSocket connected")
|
||||||
|
|
||||||
|
# Audio buffer accumulates chunks from one utterance
|
||||||
|
audio_buffer = bytearray()
|
||||||
|
conversation_history: list[dict] = []
|
||||||
|
|
||||||
|
# Initialize Honcho for this session
|
||||||
|
if kira_memory.enabled:
|
||||||
|
try:
|
||||||
|
kira_memory.ensure_peers(user_id)
|
||||||
|
kira_memory.ensure_session(session_id)
|
||||||
|
logger.info(f"[{session_id}] Honcho session ready")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[{session_id}] Honcho setup failed: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
first_message = True
|
||||||
|
|
||||||
|
while True:
|
||||||
|
raw = await websocket.receive_text()
|
||||||
|
msg = json.loads(raw)
|
||||||
|
msg_type = msg.get("type", "")
|
||||||
|
|
||||||
|
# Build system prompt fresh each turn to get latest Honcho context
|
||||||
|
system_prompt = build_system_prompt(user_id)
|
||||||
|
|
||||||
|
if msg_type == "audio_chunk":
|
||||||
|
chunk = base64.b64decode(msg["data"])
|
||||||
|
audio_buffer.extend(chunk)
|
||||||
|
|
||||||
|
elif msg_type == "transcribe":
|
||||||
|
if not audio_buffer:
|
||||||
|
await websocket.send_json({"type": "error", "message": "No audio data"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"[{session_id}] Transcribing {len(audio_buffer)} bytes...")
|
||||||
|
|
||||||
|
# 1. Speech-to-text
|
||||||
|
transcript = await transcribe_audio(bytes(audio_buffer))
|
||||||
|
audio_buffer.clear()
|
||||||
|
|
||||||
|
if not transcript:
|
||||||
|
await websocket.send_json({"type": "error", "message": "Could not transcribe audio"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Echo transcript
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "transcript",
|
||||||
|
"text": transcript,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. LLM call
|
||||||
|
logger.info(f"[{session_id}] User: {transcript}")
|
||||||
|
user_msg = {"role": "user", "content": transcript}
|
||||||
|
conversation_history.append(user_msg)
|
||||||
|
|
||||||
|
messages = [system_prompt] + conversation_history[-10:]
|
||||||
|
kira_text = await get_kira_response(messages)
|
||||||
|
|
||||||
|
assistant_msg = {"role": "assistant", "content": kira_text}
|
||||||
|
conversation_history.append(assistant_msg)
|
||||||
|
logger.info(f"[{session_id}] Kira: {kira_text}")
|
||||||
|
|
||||||
|
# 3. Store in Honcho
|
||||||
|
if kira_memory.enabled:
|
||||||
|
try:
|
||||||
|
kira_memory.store_messages(transcript, kira_text)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[{session_id}] Failed to store messages: {e}")
|
||||||
|
|
||||||
|
# 4. TTS
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "speaking_start",
|
||||||
|
"text": kira_text,
|
||||||
|
})
|
||||||
|
|
||||||
|
audio_bytes = await synthesize_speech(kira_text)
|
||||||
|
audio_b64 = base64.b64encode(audio_bytes).decode("utf-8")
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "audio",
|
||||||
|
"data": audio_b64,
|
||||||
|
"text": kira_text,
|
||||||
|
})
|
||||||
|
|
||||||
|
await websocket.send_json({"type": "speaking_end"})
|
||||||
|
|
||||||
|
elif msg_type == "ping":
|
||||||
|
await websocket.send_json({"type": "pong"})
|
||||||
|
|
||||||
|
elif msg_type == "conversation_text":
|
||||||
|
user_text = msg.get("text", "").strip()
|
||||||
|
if not user_text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"[{session_id}] User (text): {user_text}")
|
||||||
|
user_msg = {"role": "user", "content": user_text}
|
||||||
|
conversation_history.append(user_msg)
|
||||||
|
|
||||||
|
messages = [system_prompt] + conversation_history[-10:]
|
||||||
|
kira_text = await get_kira_response(messages)
|
||||||
|
|
||||||
|
assistant_msg = {"role": "assistant", "content": kira_text}
|
||||||
|
conversation_history.append(assistant_msg)
|
||||||
|
logger.info(f"[{session_id}] Kira: {kira_text}")
|
||||||
|
|
||||||
|
# Store in Honcho
|
||||||
|
if kira_memory.enabled:
|
||||||
|
try:
|
||||||
|
kira_memory.store_messages(user_text, kira_text)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[{session_id}] Failed to store messages: {e}")
|
||||||
|
|
||||||
|
# TTS
|
||||||
|
await websocket.send_json({"type": "speaking_start", "text": kira_text})
|
||||||
|
audio_bytes = await synthesize_speech(kira_text)
|
||||||
|
audio_b64 = base64.b64encode(audio_bytes).decode("utf-8")
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "audio",
|
||||||
|
"data": audio_b64,
|
||||||
|
"text": kira_text,
|
||||||
|
})
|
||||||
|
await websocket.send_json({"type": "speaking_end"})
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.info(f"[{session_id}] Disconnected")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[{session_id}] Error: {e}")
|
||||||
|
try:
|
||||||
|
await websocket.send_json({"type": "error", "message": str(e)})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.34.0
|
||||||
|
python-dotenv>=1.1.0
|
||||||
|
openai>=1.55.0
|
||||||
|
websockets>=14.1
|
||||||
|
pydantic>=2.10.0
|
||||||
|
pydantic-settings>=2.7.0
|
||||||
|
httpx>=0.28.0
|
||||||
|
honcho-ai>=2.1.0
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""LLM service — DeepSeek API"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger("kira.llm")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client() -> AsyncOpenAI:
|
||||||
|
return AsyncOpenAI(
|
||||||
|
api_key=settings.deepseek_api_key,
|
||||||
|
base_url=settings.deepseek_base_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_kira_response(messages: list[dict]) -> str:
|
||||||
|
"""Get Kira's response from the LLM."""
|
||||||
|
try:
|
||||||
|
client = _get_client()
|
||||||
|
resp = await client.chat.completions.create(
|
||||||
|
model=settings.deepseek_model,
|
||||||
|
messages=messages,
|
||||||
|
max_tokens=300,
|
||||||
|
temperature=0.7,
|
||||||
|
)
|
||||||
|
return resp.choices[0].message.content or "Mhm, I'm here!"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LLM error: {e}")
|
||||||
|
return "I'm still here with you! Could you say that again?"
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""Honcho memory service for Kira.
|
||||||
|
|
||||||
|
Integrates Honcho persistent memory into Kira's conversation pipeline:
|
||||||
|
- User context retrieval before LLM calls
|
||||||
|
- Message storage after each exchange
|
||||||
|
- Cross-session memory for personalized responses
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from honcho import Honcho
|
||||||
|
from honcho.peer import Peer
|
||||||
|
from honcho.session import Session
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger("kira.memory")
|
||||||
|
|
||||||
|
|
||||||
|
class KiraMemory:
|
||||||
|
"""Manages Honcho memory for Kira conversations."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._honcho: Honcho | None = None
|
||||||
|
self._user_peer: Peer | None = None
|
||||||
|
self._kira_peer: Peer | None = None
|
||||||
|
self._session: Session | None = None
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
def init(self) -> bool:
|
||||||
|
"""Initialize Honcho connection. Returns False if not configured."""
|
||||||
|
api_key = settings.honcho_api_key
|
||||||
|
base_url = settings.honcho_base_url
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
logger.warning("HONCHO_API_KEY not set — memory disabled")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not base_url:
|
||||||
|
self._honcho = Honcho(
|
||||||
|
api_key=api_key,
|
||||||
|
workspace_id="kira",
|
||||||
|
environment="production",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._honcho = Honcho(
|
||||||
|
api_key=api_key,
|
||||||
|
base_url=base_url,
|
||||||
|
workspace_id="kira",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Honcho connected to workspace 'kira'")
|
||||||
|
self._initialized = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return self._initialized and self._honcho is not None
|
||||||
|
|
||||||
|
def ensure_peers(self, user_id: str = "default-user") -> None:
|
||||||
|
"""Get or create Honcho peers for the user and Kira."""
|
||||||
|
if not self.enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._user_peer = self._honcho.peer(user_id)
|
||||||
|
self._kira_peer = self._honcho.peer("kira")
|
||||||
|
|
||||||
|
logger.info(f"Peers ready: user={user_id}, kira")
|
||||||
|
|
||||||
|
def ensure_session(self, session_id: str) -> None:
|
||||||
|
"""Get or create a Honcho session for this conversation."""
|
||||||
|
if not self.enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._session = self._honcho.session(session_id)
|
||||||
|
|
||||||
|
# Add peers to session if not already members
|
||||||
|
if self._user_peer and self._kira_peer:
|
||||||
|
self._session.add_peers([self._user_peer, self._kira_peer])
|
||||||
|
|
||||||
|
logger.info(f"Session ready: {session_id}")
|
||||||
|
|
||||||
|
def get_user_context(self) -> str:
|
||||||
|
"""Query Honcho for context about the user.
|
||||||
|
|
||||||
|
Returns a string summary of what Honcho knows about the user,
|
||||||
|
to inject into the LLM system prompt. Empty string if no context.
|
||||||
|
"""
|
||||||
|
if not self.enabled or not self._user_peer:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Query Honcho's dialectic reasoning about the user
|
||||||
|
context = self._user_peer.chat(
|
||||||
|
"What should Kira know about this user? "
|
||||||
|
"Summarize their preferences, current projects, mood, "
|
||||||
|
"and any important context in 2-3 sentences."
|
||||||
|
)
|
||||||
|
if context:
|
||||||
|
return f"\n[Memory: {context}]"
|
||||||
|
return ""
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get user context: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_kira_context(self) -> str:
|
||||||
|
"""Get what the user knows about Kira (relationship context)."""
|
||||||
|
if not self.enabled or not self._user_peer:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
context = self._user_peer.chat(
|
||||||
|
"What is the user's relationship with Kira? "
|
||||||
|
"How do they feel about their focus sessions? "
|
||||||
|
"Summarize in 1-2 sentences.",
|
||||||
|
target="kira",
|
||||||
|
)
|
||||||
|
if context:
|
||||||
|
return f"\n[Kira Context: {context}]"
|
||||||
|
return ""
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get relationship context: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def build_system_prompt_suffix(self) -> str:
|
||||||
|
"""Build a context suffix to append to Kira's system prompt."""
|
||||||
|
if not self.enabled:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
user_ctx = self.get_user_context()
|
||||||
|
kira_ctx = self.get_kira_context()
|
||||||
|
|
||||||
|
parts = [s for s in [user_ctx, kira_ctx] if s]
|
||||||
|
if not parts:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return "\n\n---\n### What Kira remembers:" + "".join(parts)
|
||||||
|
|
||||||
|
def store_messages(
|
||||||
|
self,
|
||||||
|
user_message: str,
|
||||||
|
kira_message: str,
|
||||||
|
) -> None:
|
||||||
|
"""Store a conversation exchange in Honcho."""
|
||||||
|
if not self.enabled or not self._session:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
messages = []
|
||||||
|
if self._user_peer:
|
||||||
|
messages.append(
|
||||||
|
self._user_peer.message(user_message)
|
||||||
|
)
|
||||||
|
if self._kira_peer:
|
||||||
|
messages.append(
|
||||||
|
self._kira_peer.message(kira_message)
|
||||||
|
)
|
||||||
|
|
||||||
|
if messages:
|
||||||
|
self._session.add_messages(messages)
|
||||||
|
logger.debug("Stored conversation exchange in Honcho")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to store messages: {e}")
|
||||||
|
|
||||||
|
def store_user_message(self, text: str) -> None:
|
||||||
|
"""Store a single user message."""
|
||||||
|
if not self.enabled or not self._session or not self._user_peer:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._session.add_messages([self._user_peer.message(text)])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to store user message: {e}")
|
||||||
|
|
||||||
|
def store_kira_message(self, text: str) -> None:
|
||||||
|
"""Store a single Kira message."""
|
||||||
|
if not self.enabled or not self._session or not self._kira_peer:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._session.add_messages([self._kira_peer.message(text)])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to store Kira message: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance for the app
|
||||||
|
kira_memory = KiraMemory()
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Speech-to-text via OpenAI Whisper API"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger("kira.stt")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client() -> AsyncOpenAI:
|
||||||
|
return AsyncOpenAI(api_key=settings.openai_api_key)
|
||||||
|
|
||||||
|
|
||||||
|
async def transcribe_audio(audio_bytes: bytes) -> str | None:
|
||||||
|
"""Transcribe audio bytes to text using Whisper API."""
|
||||||
|
try:
|
||||||
|
client = _get_client()
|
||||||
|
transcript = await client.audio.transcriptions.create(
|
||||||
|
model="whisper-1",
|
||||||
|
file=("audio.webm", audio_bytes, "audio/webm"),
|
||||||
|
language="en",
|
||||||
|
response_format="text",
|
||||||
|
)
|
||||||
|
return transcript.strip() if transcript and transcript.strip() else None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"STT error: {e}")
|
||||||
|
return None
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Text-to-speech via OpenAI TTS API"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger("kira.tts")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client() -> AsyncOpenAI:
|
||||||
|
return AsyncOpenAI(api_key=settings.openai_api_key)
|
||||||
|
|
||||||
|
|
||||||
|
async def synthesize_speech(text: str, voice: str = "nova") -> bytes:
|
||||||
|
"""Synthesize text to speech audio bytes.
|
||||||
|
|
||||||
|
Voices available: alloy, echo, fable, nova, shimmer
|
||||||
|
Nova is the warmest female voice — fits Kira's personality.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = _get_client()
|
||||||
|
resp = await client.audio.speech.create(
|
||||||
|
model="tts-1",
|
||||||
|
voice=voice,
|
||||||
|
input=text,
|
||||||
|
response_format="opus",
|
||||||
|
)
|
||||||
|
return resp.content
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"TTS error: {e}")
|
||||||
|
return b""
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
container_name: kira-backend
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- ./.env:/app/.env:ro
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- kira-net
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
container_name: kira-frontend
|
||||||
|
ports:
|
||||||
|
- "3000:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- kira-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
kira-net:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json .
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/kira-icon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Kira — your focus bestie</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# WebSocket proxy for /api/ws
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static assets
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2931
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "kira",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pixi-live2d-display": "^0.4.0",
|
||||||
|
"pixi.js": "^7.4.3",
|
||||||
|
"react": "^19.2.6",
|
||||||
|
"react-dom": "^19.2.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.6",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"tailwindcss": "^4.1.6",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.0.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<circle cx="32" cy="32" r="30" fill="#FFB6C1"/>
|
||||||
|
<circle cx="24" cy="28" r="4" fill="#4A1942"/>
|
||||||
|
<circle cx="40" cy="28" r="4" fill="#4A1942"/>
|
||||||
|
<circle cx="24" cy="28" r="1.5" fill="white"/>
|
||||||
|
<circle cx="40" cy="28" r="1.5" fill="white"/>
|
||||||
|
<path d="M24 40 Q32 48 40 40" stroke="#4A1942" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
<circle cx="20" cy="14" r="5" fill="#FFD700" opacity="0.6"/>
|
||||||
|
<circle cx="44" cy="14" r="4" fill="#D8B4FE" opacity="0.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 543 B |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,125 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import Clock from './components/Clock';
|
||||||
|
import BackgroundScene from './components/BackgroundScene';
|
||||||
|
import MusicPlayer from './components/MusicPlayer';
|
||||||
|
import Timer from './components/Timer';
|
||||||
|
import Notes from './components/Notes';
|
||||||
|
import KiraAvatar from './components/KiraAvatar';
|
||||||
|
import ChatBubble from './components/ChatBubble';
|
||||||
|
import PetZone from './components/PetZone';
|
||||||
|
import Wardrobe from './components/Wardrobe';
|
||||||
|
import Toolbar from './components/Toolbar';
|
||||||
|
import Particles from './components/Particles';
|
||||||
|
import { SCENES, type Scene } from './components/scenes';
|
||||||
|
import { useConversation } from './hooks/useConversation';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [currentSceneId, setCurrentSceneId] = useState('cozy-room');
|
||||||
|
const [currentOutfit, setCurrentOutfit] = useState('cozy-hoodie');
|
||||||
|
const [currentAcc, setCurrentAcc] = useState<string | null>(null);
|
||||||
|
const [textInput, setTextInput] = useState('');
|
||||||
|
|
||||||
|
const currentScene: Scene = SCENES.find((s) => s.id === currentSceneId) ?? SCENES[0];
|
||||||
|
const { messages, isConnected, isKiraSpeaking, isRecording, sendText, startRecording, stopRecording } = useConversation();
|
||||||
|
|
||||||
|
const handleTalkToggle = () => {
|
||||||
|
if (isRecording) stopRecording();
|
||||||
|
else startRecording();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTextSend = () => {
|
||||||
|
if (!textInput.trim()) return;
|
||||||
|
sendText(textInput.trim());
|
||||||
|
setTextInput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="min-h-screen relative transition-all duration-1000"
|
||||||
|
style={{ background: currentScene.gradient }}
|
||||||
|
>
|
||||||
|
<Particles type={currentScene.particles ?? 'none'} />
|
||||||
|
|
||||||
|
<div className="relative z-20 h-screen flex flex-col">
|
||||||
|
{/* Top toolbar */}
|
||||||
|
<div className="px-4 pt-4">
|
||||||
|
<Toolbar currentScene={currentSceneId} onSceneChange={setCurrentSceneId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main grid — scrollable center */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-4 scrollbar-thin">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 max-w-7xl mx-auto">
|
||||||
|
|
||||||
|
{/* Column 1: Kira + Clock */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Clock />
|
||||||
|
<KiraAvatar
|
||||||
|
isSpeaking={isKiraSpeaking}
|
||||||
|
isListening={isRecording}
|
||||||
|
outfit={currentOutfit}
|
||||||
|
accessory={currentAcc}
|
||||||
|
onTalkToggle={handleTalkToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column 2: Timer + Music */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Timer />
|
||||||
|
<MusicPlayer />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column 3: Chat + Text Input */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<ChatBubble messages={messages} isKiraSpeaking={isKiraSpeaking} />
|
||||||
|
|
||||||
|
{/* Text input fallback */}
|
||||||
|
<div className="glass-card p-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={textInput}
|
||||||
|
onChange={(e) => setTextInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleTextSend()}
|
||||||
|
placeholder="type a message..."
|
||||||
|
className="flex-1 bg-white/60 rounded-xl px-3 py-2 text-sm text-kira-plum placeholder-kira-plum/30 border border-kira-pink/20 focus:outline-none focus:border-kira-pink/50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleTextSend}
|
||||||
|
className="btn-kira px-3 text-sm"
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Connection indicator */}
|
||||||
|
<div className="flex items-center gap-2 mt-2 text-[10px] text-kira-plum/30">
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${isConnected ? 'bg-kira-mint' : 'bg-red-300'}`} />
|
||||||
|
{isConnected ? 'connected' : 'connecting...'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column 4: Cats + Wardrobe */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PetZone />
|
||||||
|
<Wardrobe onOutfitChange={setCurrentOutfit} onAccessoryChange={setCurrentAcc} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom bar */}
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<div className="glass-card px-4 py-2 flex items-center justify-between text-xs text-kira-plum/40">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${isRecording ? 'bg-red-400 animate-pulse' : isKiraSpeaking ? 'bg-kira-pink animate-pulse' : 'bg-kira-mint'} inline-block`} />
|
||||||
|
<span>{isRecording ? 'listening...' : isKiraSpeaking ? 'kira speaking' : 'kira is here'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="hidden sm:inline">{isConnected ? 'body double mode' : 'offline'}</span>
|
||||||
|
<span>🌸</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isSpeaking: boolean;
|
||||||
|
isListening: boolean;
|
||||||
|
outfit: string;
|
||||||
|
accessory: string | null;
|
||||||
|
onTalkToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnimatedAvatar({ isSpeaking, isListening, outfit, accessory, onTalkToggle }: Props) {
|
||||||
|
const [blink, setBlink] = useState(false);
|
||||||
|
const [wave, setWave] = useState(false);
|
||||||
|
const [lookX, setLookX] = useState(0);
|
||||||
|
const [lookY, setLookY] = useState(0);
|
||||||
|
const idleRef = useRef<ReturnType<typeof setInterval>>();
|
||||||
|
|
||||||
|
// Blink cycle
|
||||||
|
useEffect(() => {
|
||||||
|
const blinkCycle = () => {
|
||||||
|
setBlink(true);
|
||||||
|
setTimeout(() => setBlink(false), 150);
|
||||||
|
};
|
||||||
|
const id = setInterval(blinkCycle, 2500 + Math.random() * 2000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Gentle idle eye movement
|
||||||
|
useEffect(() => {
|
||||||
|
idleRef.current = setInterval(() => {
|
||||||
|
setLookX(Math.sin(Date.now() / 3000) * 3);
|
||||||
|
setLookY(Math.sin(Date.now() / 4000) * 2);
|
||||||
|
}, 100);
|
||||||
|
return () => clearInterval(idleRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Wave gesture on toggle
|
||||||
|
const handleTalk = () => {
|
||||||
|
setWave(true);
|
||||||
|
setTimeout(() => setWave(false), 600);
|
||||||
|
onTalkToggle();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Outfit colors
|
||||||
|
const outfitColors: Record<string, { top: string; accent: string; skirt: string }> = {
|
||||||
|
'cozy-hoodie': { top: '#FFB6C1', accent: '#FF69B4', skirt: '#FFB6C1' },
|
||||||
|
'girly-dress': { top: '#D8B4FE', accent: '#A855F7', skirt: '#E9D5FF' },
|
||||||
|
'pajama-set': { top: '#A7F3D0', accent: '#34D399', skirt: '#6EE7B7' },
|
||||||
|
'study-sweater': { top: '#FED7AA', accent: '#F97316', skirt: '#FED7AA' },
|
||||||
|
'going-out': { top: '#FBCFE8', accent: '#EC4899', skirt: '#FBCFE8' },
|
||||||
|
};
|
||||||
|
const colors = outfitColors[outfit] || outfitColors['cozy-hoodie'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
{/* Avatar canvas area */}
|
||||||
|
<div className="relative w-36 h-44">
|
||||||
|
<svg viewBox="0 0 120 160" className="w-full h-full">
|
||||||
|
{/* Hair back layer */}
|
||||||
|
<ellipse cx="60" cy="55" rx="32" ry="38" fill="#2D1B69" />
|
||||||
|
|
||||||
|
{/* Body / Outfit */}
|
||||||
|
<ellipse cx="60" cy="110" rx="28" ry="35" fill={colors.top} />
|
||||||
|
<path d="M32 110 Q60 135 88 110" fill={colors.skirt} opacity={0.7} />
|
||||||
|
{/* Collar */}
|
||||||
|
<path d="M48 85 Q60 92 72 85" fill={colors.accent} opacity={0.6} />
|
||||||
|
|
||||||
|
{/* Arms */}
|
||||||
|
<g className={wave ? 'arm-wave' : ''}>
|
||||||
|
{/* Left arm */}
|
||||||
|
<ellipse cx="28" cy="98" rx="8" ry="20" fill="#FDBCB4" transform="rotate(10, 28, 98)" />
|
||||||
|
{/* Right arm */}
|
||||||
|
<ellipse cx="92" cy="98" rx="8" ry="20" fill="#FDBCB4" transform="rotate(-10, 92, 98)" className={wave ? 'wave-arm' : ''} />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Neck */}
|
||||||
|
<rect x="54" y="78" width="12" height="12" rx="6" fill="#FDBCB4" />
|
||||||
|
|
||||||
|
{/* Head */}
|
||||||
|
<ellipse cx="60" cy="52" rx="30" ry="32" fill="#FDBCB4" />
|
||||||
|
|
||||||
|
{/* Cheeks */}
|
||||||
|
<ellipse cx="38" cy="60" rx="6" ry="4" fill="#FFB6C1" opacity={0.5} />
|
||||||
|
<ellipse cx="82" cy="60" rx="6" ry="4" fill="#FFB6C1" opacity={0.5} />
|
||||||
|
|
||||||
|
{/* Hair front */}
|
||||||
|
<path d="M30 45 Q30 25 45 18 Q55 14 60 15 Q65 14 75 18 Q90 25 90 45 Q90 30 82 22 Q70 14 60 12 Q50 14 38 22 Q30 30 30 45Z" fill="#3D1F8A" />
|
||||||
|
{/* Hair bangs */}
|
||||||
|
<path d="M30 42 Q35 30 50 28 Q60 27 70 28 Q85 30 90 42 Q88 35 78 32 Q65 28 60 28 Q55 28 42 32 Q32 35 30 42Z" fill="#4B2C9B" />
|
||||||
|
|
||||||
|
{/* Hair side strands */}
|
||||||
|
<path d="M30 45 Q28 60 26 80 Q24 88 28 90" fill="none" stroke="#2D1B69" strokeWidth="3" strokeLinecap="round" />
|
||||||
|
<path d="M90 45 Q92 60 94 80 Q96 88 92 90" fill="none" stroke="#2D1B69" strokeWidth="3" strokeLinecap="round" />
|
||||||
|
|
||||||
|
{/* Eyes */}
|
||||||
|
<g transform={`translate(${lookX}, ${lookY})`}>
|
||||||
|
{/* Left eye */}
|
||||||
|
<ellipse cx="46" cy="50" rx="6" ry="7" fill="white" />
|
||||||
|
{/* Right eye */}
|
||||||
|
<ellipse cx="74" cy="50" rx="6" ry="7" fill="white" />
|
||||||
|
|
||||||
|
{blink ? (
|
||||||
|
<>
|
||||||
|
<line x1="40" y1="50" x2="52" y2="50" stroke="#4A1942" strokeWidth="2.5" strokeLinecap="round" />
|
||||||
|
<line x1="68" y1="50" x2="80" y2="50" stroke="#4A1942" strokeWidth="2.5" strokeLinecap="round" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Pupils */}
|
||||||
|
<circle cx="47" cy="50" r="3.5" fill="#4A1942" />
|
||||||
|
<circle cx="75" cy="50" r="3.5" fill="#4A1942" />
|
||||||
|
{/* Highlights */}
|
||||||
|
<circle cx="48.5" cy="48.5" r="1.5" fill="white" />
|
||||||
|
<circle cx="76.5" cy="48.5" r="1.5" fill="white" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Eyebrows */}
|
||||||
|
<path d="M38 42 Q46 39 54 42" fill="none" stroke="#2D1B69" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
<path d="M66 42 Q74 39 82 42" fill="none" stroke="#2D1B69" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
|
||||||
|
{/* Mouth */}
|
||||||
|
{isSpeaking ? (
|
||||||
|
<ellipse cx="60" cy="65" rx="5" ry="4" fill="#E75480" className="mouth-talk" />
|
||||||
|
) : (
|
||||||
|
<path d="M55 65 Q60 69 65 65" fill="none" stroke="#E75480" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Accessory */}
|
||||||
|
{accessory === 'bow' && (
|
||||||
|
<g>
|
||||||
|
<path d="M52 28 Q48 22 52 18 Q56 22 52 28Z" fill="#FF69B4" />
|
||||||
|
<path d="M52 28 Q56 22 52 18 Q48 22 52 28Z" fill="#FF69B4" />
|
||||||
|
<circle cx="52" cy="23" r="2" fill="#FF1493" />
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
{accessory === 'flower-crown' && (
|
||||||
|
<g>
|
||||||
|
{[48, 54, 60, 66, 72].map((x, i) => (
|
||||||
|
<circle key={i} cx={x} cy="20" r="3" fill={i % 2 === 0 ? '#FFB6C1' : '#D8B4FE'} />
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
{accessory === 'star-earrings' && (
|
||||||
|
<>
|
||||||
|
<polygon points="28,58 29,55 30,58 33,58 31,60 32,63 29,61 26,63 27,60 25,58" fill="#FDE68A" />
|
||||||
|
<polygon points="92,58 93,55 94,58 97,58 95,60 96,63 93,61 90,63 91,60 89,58" fill="#FDE68A" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{accessory === 'glasses' && (
|
||||||
|
<g stroke="#D8B4FE" strokeWidth="1.5" fill="none" opacity={0.7}>
|
||||||
|
<circle cx="46" cy="50" r="8" />
|
||||||
|
<circle cx="74" cy="50" r="8" />
|
||||||
|
<line x1="54" y1="50" x2="66" y2="50" />
|
||||||
|
<line x1="38" y1="50" x2="34" y2="48" />
|
||||||
|
<line x1="82" y1="50" x2="86" y2="48" />
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
{accessory === 'scarf' && (
|
||||||
|
<path d="M44 78 Q60 84 76 78 Q74 86 60 90 Q46 86 44 78Z" fill="#FBCFE8" stroke="#F9A8D4" strokeWidth="1" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Speaking indicator - thought dots */}
|
||||||
|
{isSpeaking && (
|
||||||
|
<g opacity={0.4}>
|
||||||
|
<circle cx="30" cy="40" r="2" fill="#D8B4FE">
|
||||||
|
<animate attributeName="opacity" values="0.3;1;0.3" dur="1s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
<circle cx="25" cy="35" r="1.5" fill="#D8B4FE">
|
||||||
|
<animate attributeName="opacity" values="1;0.3;1" dur="1s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Listening glow */}
|
||||||
|
{isListening && (
|
||||||
|
<circle cx="60" cy="52" r="40" fill="none" stroke="#D8B4FE" strokeWidth="1" opacity={0.4}>
|
||||||
|
<animate attributeName="r" values="40;44;40" dur="1.5s" repeatCount="indefinite" />
|
||||||
|
<animate attributeName="opacity" values="0.4;0.1;0.4" dur="1.5s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Talk button */}
|
||||||
|
<button
|
||||||
|
onClick={handleTalk}
|
||||||
|
className={`mt-2 flex items-center gap-2 px-5 py-2 rounded-full text-sm font-bold transition-all ${
|
||||||
|
isListening
|
||||||
|
? 'bg-red-400 text-white shadow-lg scale-105 animate-listening-pulse'
|
||||||
|
: 'bg-gradient-to-r from-kira-pink to-kira-lav text-white hover:shadow-lg hover:scale-105'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-base">{isListening ? '⏹️' : '🎤'}</span>
|
||||||
|
{isListening ? 'Listening...' : 'Talk to Kira'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.arm-wave {
|
||||||
|
animation: armWave 0.6s ease-in-out;
|
||||||
|
}
|
||||||
|
.wave-arm {
|
||||||
|
animation: waveArm 0.6s ease-in-out;
|
||||||
|
transform-origin: 92px 88px;
|
||||||
|
}
|
||||||
|
@keyframes armWave {
|
||||||
|
0%, 100% { transform: rotate(-10deg); }
|
||||||
|
25% { transform: rotate(-30deg); }
|
||||||
|
75% { transform: rotate(5deg); }
|
||||||
|
}
|
||||||
|
@keyframes waveArm {
|
||||||
|
0%, 100% { transform: rotate(-10deg); }
|
||||||
|
25% { transform: rotate(-40deg); }
|
||||||
|
75% { transform: rotate(10deg); }
|
||||||
|
}
|
||||||
|
@keyframes listening-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(248, 113, 113, 0.4); }
|
||||||
|
50% { box-shadow: 0 0 0 12px rgba(248, 113, 113, 0); }
|
||||||
|
}
|
||||||
|
.animate-listening-pulse {
|
||||||
|
animation: listening-pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
.mouth-talk {
|
||||||
|
animation: mouthOpen 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
@keyframes mouthOpen {
|
||||||
|
0%, 100% { rx: 5; ry: 4; }
|
||||||
|
50% { rx: 6; ry: 5; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { SCENES } from './scenes';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentScene: string;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BackgroundScene({ currentScene, onSelect }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="glass-card p-4">
|
||||||
|
<h3 className="text-sm font-bold text-kira-plum mb-3 flex items-center gap-2">
|
||||||
|
<span>🎨</span> Scene
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{SCENES.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => onSelect(s.id)}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-sm font-medium transition-all
|
||||||
|
${currentScene === s.id
|
||||||
|
? 'bg-kira-pink text-white shadow-md scale-105'
|
||||||
|
: 'bg-white/50 text-kira-plum/70 hover:bg-kira-glow hover:scale-102'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
title={s.name}
|
||||||
|
>
|
||||||
|
<span>{s.icon}</span>
|
||||||
|
<span className="hidden sm:inline">{s.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'kira';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
messages: Message[];
|
||||||
|
isKiraSpeaking: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatBubble({ messages, isKiraSpeaking }: Props) {
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-card p-4 flex flex-col" style={{ minHeight: 200, maxHeight: 320 }}>
|
||||||
|
<h3 className="text-sm font-bold text-kira-plum mb-3 flex items-center gap-2">
|
||||||
|
<span>💬</span> Conversation
|
||||||
|
<span className={`w-2 h-2 rounded-full ${isKiraSpeaking ? 'bg-kira-pink animate-pulse' : 'bg-kira-mint'}`} />
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-2 scrollbar-thin pr-1">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div className="text-xs text-kira-plum/30 text-center py-6">
|
||||||
|
click the mic or type to talk to Kira
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.map((msg, i) => {
|
||||||
|
const isLastKira = msg.role === 'kira' && i === messages.length - 1 && isKiraSpeaking;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={`flex gap-2 ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||||
|
>
|
||||||
|
{msg.role === 'kira' && (
|
||||||
|
<span className="text-sm mt-1">🌸</span>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
max-w-[80%] px-3 py-2 rounded-2xl text-sm leading-relaxed
|
||||||
|
${msg.role === 'user'
|
||||||
|
? 'bg-kira-lav/30 text-kira-plum rounded-br-md'
|
||||||
|
: 'bg-white/60 text-kira-plum rounded-bl-md'
|
||||||
|
}
|
||||||
|
${isLastKira ? 'animate-fade-in' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{msg.text}
|
||||||
|
{isLastKira && (
|
||||||
|
<span className="inline-block w-1.5 h-4 bg-kira-pink/60 ml-0.5 animate-blink" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{msg.role === 'user' && (
|
||||||
|
<span className="text-sm mt-1">👤</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
.animate-fade-in { animation: fade-in 0.3s ease-out; }
|
||||||
|
.animate-blink { animation: blink 0.8s infinite; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export default function Clock() {
|
||||||
|
const [time, setTime] = useState(new Date());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setTime(new Date()), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-card px-5 py-3 text-center">
|
||||||
|
<div className="text-3xl font-bold tracking-tight text-kira-plum">
|
||||||
|
{time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-kira-violet/60 font-medium">
|
||||||
|
{time.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import AnimatedAvatar from './AnimatedAvatar';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isSpeaking: boolean;
|
||||||
|
isListening: boolean;
|
||||||
|
outfit: string;
|
||||||
|
accessory: string | null;
|
||||||
|
onTalkToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KiraAvatar(props: Props) {
|
||||||
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [live2dReady, setLive2dReady] = useState(false);
|
||||||
|
const [loadError, setLoadError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Try to load Live2D model — if it fails, show animated placeholder
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
const tryLoadLive2D = async () => {
|
||||||
|
try {
|
||||||
|
// Check if model files exist
|
||||||
|
const resp = await fetch('/live2d/models/kira.model3.json', { method: 'HEAD' });
|
||||||
|
if (!resp.ok) {
|
||||||
|
if (mounted) setLoadError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load Cubism core
|
||||||
|
await loadScript('/live2d/cubism/live2dcubismcore.min.js');
|
||||||
|
|
||||||
|
// Model exists — ready for Live2D
|
||||||
|
// (full Live2D rendering will require the Cubism4 framework bundle)
|
||||||
|
if (mounted) setLive2dReady(false); // Show placeholder until framework is fully wired
|
||||||
|
} catch {
|
||||||
|
if (mounted) setLoadError(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tryLoadLive2D();
|
||||||
|
|
||||||
|
return () => { mounted = false; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Show animated fallback while Live2D model isn't available
|
||||||
|
return (
|
||||||
|
<div className="glass-card p-4 flex flex-col items-center" style={{ minHeight: 290 }}>
|
||||||
|
{/* Live2D canvas area (hidden until model is loaded) */}
|
||||||
|
{live2dReady && (
|
||||||
|
<div ref={canvasRef} className="w-36 h-44 relative" id="live2d-canvas" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Animated SVG placeholder */}
|
||||||
|
{(!live2dReady || loadError) && (
|
||||||
|
<AnimatedAvatar
|
||||||
|
isSpeaking={props.isSpeaking}
|
||||||
|
isListening={props.isListening}
|
||||||
|
outfit={props.outfit}
|
||||||
|
accessory={props.accessory}
|
||||||
|
onTalkToggle={props.onTalkToggle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!live2dReady && !loadError && (
|
||||||
|
<p className="text-[10px] text-kira-plum/30 mt-1">✨ Live2D model slot ready</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status info */}
|
||||||
|
<div className="mt-3 flex items-center gap-3 text-xs text-kira-plum/40">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${props.isSpeaking ? 'bg-kira-pink animate-pulse' : props.isListening ? 'bg-red-400 animate-pulse' : 'bg-kira-mint'}`} />
|
||||||
|
<span>
|
||||||
|
{props.isSpeaking ? 'speaking...' : props.isListening ? 'listening...' : 'here with you'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Outfit + accessory indicator */}
|
||||||
|
<div className="flex gap-2 mt-1 text-[10px] text-kira-plum/30">
|
||||||
|
<span>{props.outfit.replace('-', ' ')}</span>
|
||||||
|
{props.accessory && <span>· {props.accessory}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadScript(src: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = src;
|
||||||
|
script.onload = () => resolve();
|
||||||
|
script.onerror = () => reject(new Error(`Failed to load ${src}`));
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
const LOFI_PLAYLISTS = [
|
||||||
|
{ id: 'lofi-girl', name: 'lofi hip hop radio', url: 'https://www.youtube.com/embed/jfKfPfyJRdk', icon: '🎧' },
|
||||||
|
{ id: 'lofi-chill', name: 'Chill lofi', url: 'https://www.youtube.com/embed/5qap5aO4i9A', icon: '🎵' },
|
||||||
|
{ id: 'lofi-synth', name: 'Synthwave lofi', url: 'https://www.youtube.com/embed/MVPTmgNG4x0', icon: '🌃' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MusicPlayer() {
|
||||||
|
const [active, setActive] = useState<string | null>('lofi-girl');
|
||||||
|
const [volume, setVolume] = useState(0.3);
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Post volume to YouTube iframe when it changes
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (iframeRef.current?.contentWindow) {
|
||||||
|
iframeRef.current.contentWindow.postMessage(
|
||||||
|
JSON.stringify({ event: 'command', func: 'setVolume', args: [volume * 100] }),
|
||||||
|
'*'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [volume]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-card p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-bold text-kira-plum flex items-center gap-2">
|
||||||
|
<span>🎶</span> Lo-Fi
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-kira-violet/50">vol</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.05"
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
||||||
|
className="w-20 accent-kira-pink"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
{LOFI_PLAYLISTS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => setActive(p.id)}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium transition-all
|
||||||
|
${active === p.id
|
||||||
|
? 'bg-kira-lav text-white shadow-md'
|
||||||
|
: 'bg-white/50 text-kira-plum/60 hover:bg-kira-glow'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span>{p.icon}</span>
|
||||||
|
<span className="hidden sm:inline">{p.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative rounded-xl overflow-hidden" style={{ height: 80 }}>
|
||||||
|
{active && (
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
src={`${LOFI_PLAYLISTS.find(p => p.id === active)?.url}?autoplay=1&controls=0&showinfo=0&loop=1&enablejsapi=1`}
|
||||||
|
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||||
|
style={{ transform: 'scale(1.5)', transformOrigin: '0 0', opacity: 0.01 }}
|
||||||
|
allow="autoplay"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-kira-plum/30 text-xs">
|
||||||
|
{active ? '🎵 streaming...' : 'pick a station'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface Note {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
done: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Notes() {
|
||||||
|
const [notes, setNotes] = useState<Note[]>([]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
|
||||||
|
const addNote = () => {
|
||||||
|
if (!input.trim()) return;
|
||||||
|
setNotes([...notes, { id: crypto.randomUUID(), text: input.trim(), done: false }]);
|
||||||
|
setInput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
setNotes(notes.map((n) => (n.id === id ? { ...n, done: !n.done } : n)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (id: string) => {
|
||||||
|
setNotes(notes.filter((n) => n.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-card p-4">
|
||||||
|
<h3 className="text-sm font-bold text-kira-plum mb-3 flex items-center gap-2">
|
||||||
|
<span>📝</span> Notes
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && addNote()}
|
||||||
|
placeholder="what are you working on?"
|
||||||
|
className="flex-1 bg-white/60 rounded-xl px-3 py-2 text-sm text-kira-plum placeholder-kira-plum/30 border border-kira-pink/20 focus:outline-none focus:border-kira-pink/50"
|
||||||
|
/>
|
||||||
|
<button onClick={addNote} className="btn-kira px-3 text-sm">+</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 max-h-32 overflow-y-auto scrollbar-thin">
|
||||||
|
{notes.length === 0 && (
|
||||||
|
<p className="text-xs text-kira-plum/30 text-center py-3">nothing yet</p>
|
||||||
|
)}
|
||||||
|
{notes.map((note) => (
|
||||||
|
<div
|
||||||
|
key={note.id}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-xl text-sm transition-all cursor-pointer ${
|
||||||
|
note.done ? 'bg-kira-mint/30 line-through text-kira-plum/40' : 'bg-white/40 hover:bg-kira-glow'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggle(note.id)}
|
||||||
|
>
|
||||||
|
<span className="text-xs">{note.done ? '✅' : '⬜'}</span>
|
||||||
|
<span className="flex-1 truncate">{note.text}</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); remove(note.id); }}
|
||||||
|
className="text-kira-plum/20 hover:text-red-300 text-xs"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface Particle {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
size: number;
|
||||||
|
speed: number;
|
||||||
|
opacity: number;
|
||||||
|
sway: number;
|
||||||
|
swaySpeed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
type: 'rain' | 'stars' | 'petals' | 'snow' | 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Particles({ type }: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const particlesRef = useRef<Particle[]>([]);
|
||||||
|
const frameRef = useRef<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (type === 'none') return;
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
|
const count = type === 'snow' || type === 'petals' ? 40 : type === 'stars' ? 60 : 80;
|
||||||
|
particlesRef.current = Array.from({ length: count }, () => ({
|
||||||
|
x: Math.random() * canvas.width,
|
||||||
|
y: Math.random() * canvas.height,
|
||||||
|
size: type === 'rain' ? 1.5 : type === 'stars' ? Math.random() * 2 + 0.5 : type === 'petals' ? Math.random() * 4 + 3 : Math.random() * 3 + 1,
|
||||||
|
speed: type === 'rain' ? 6 + Math.random() * 4 : type === 'petals' ? 0.5 + Math.random() * 1 : type === 'snow' ? 0.5 + Math.random() * 1.5 : 0.1,
|
||||||
|
opacity: type === 'stars' ? 0.3 + Math.random() * 0.7 : 0.3 + Math.random() * 0.4,
|
||||||
|
sway: Math.random() * Math.PI * 2,
|
||||||
|
swaySpeed: 0.01 + Math.random() * 0.02,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
rain: 'rgba(160, 174, 192, 0.3)',
|
||||||
|
stars: 'rgba(255, 255, 255, 0.8)',
|
||||||
|
petals: 'rgba(255, 182, 193, 0.6)',
|
||||||
|
snow: 'rgba(255, 255, 255, 0.6)',
|
||||||
|
};
|
||||||
|
|
||||||
|
let frame = 0;
|
||||||
|
const animate = () => {
|
||||||
|
frame++;
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
const color = colors[type];
|
||||||
|
|
||||||
|
particlesRef.current.forEach((p) => {
|
||||||
|
p.sway += p.swaySpeed;
|
||||||
|
p.y += p.speed;
|
||||||
|
|
||||||
|
if (type === 'rain') {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(p.x, p.y);
|
||||||
|
ctx.lineTo(p.x + Math.sin(p.sway) * 3, p.y + 8);
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = p.size;
|
||||||
|
ctx.stroke();
|
||||||
|
} else if (type === 'stars') {
|
||||||
|
const twinkle = 0.5 + Math.sin(frame * 0.02 + p.x) * 0.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = `rgba(255, 255, 255, ${p.opacity * twinkle})`;
|
||||||
|
ctx.fill();
|
||||||
|
} else if (type === 'petals') {
|
||||||
|
const rotation = Math.sin(p.sway * 2) * 0.3;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(p.x + Math.sin(p.sway) * 15, p.y);
|
||||||
|
ctx.rotate(rotation);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(0, 0, p.size, p.size * 0.6, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
} else if (type === 'snow') {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x + Math.sin(p.sway) * 10, p.y, p.size, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap around
|
||||||
|
if (p.y > canvas.height + 15) {
|
||||||
|
p.y = -10;
|
||||||
|
p.x = Math.random() * canvas.width;
|
||||||
|
}
|
||||||
|
if (p.x > canvas.width + 15) p.x = -5;
|
||||||
|
if (p.x < -15) p.x = canvas.width + 5;
|
||||||
|
});
|
||||||
|
|
||||||
|
frameRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
animate();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frameRef.current);
|
||||||
|
window.removeEventListener('resize', resize);
|
||||||
|
};
|
||||||
|
}, [type]);
|
||||||
|
|
||||||
|
if (type === 'none') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="fixed inset-0 pointer-events-none z-10"
|
||||||
|
style={{ opacity: type === 'stars' ? 1 : 0.5 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
export default function PetZone() {
|
||||||
|
return (
|
||||||
|
<div className="glass-card p-4 relative overflow-hidden" style={{ minHeight: 120 }}>
|
||||||
|
<h3 className="text-sm font-bold text-kira-plum mb-2 flex items-center gap-2">
|
||||||
|
<span>🐱</span> Pet Zone
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-end justify-around gap-4">
|
||||||
|
{/* Orange fluffy cat */}
|
||||||
|
<div className="flex flex-col items-center animate-bounce-slow">
|
||||||
|
<div className="relative">
|
||||||
|
{/* Body */}
|
||||||
|
<div className="w-14 h-10 bg-orange-300 rounded-3xl rounded-br-xl relative">
|
||||||
|
{/* Head */}
|
||||||
|
<div className="w-10 h-9 bg-orange-300 rounded-full absolute -top-5 left-2">
|
||||||
|
{/* Ears */}
|
||||||
|
<div className="absolute -top-2 left-0 w-3 h-3 bg-orange-300 rounded-tl-full transform -rotate-12" />
|
||||||
|
<div className="absolute -top-2 right-0 w-3 h-3 bg-orange-300 rounded-tr-full transform rotate-12" />
|
||||||
|
{/* Eyes */}
|
||||||
|
<div className="absolute top-2 left-1.5 w-2 h-2.5 bg-amber-800 rounded-full" />
|
||||||
|
<div className="absolute top-2 right-1.5 w-2 h-2.5 bg-amber-800 rounded-full" />
|
||||||
|
{/* Nose */}
|
||||||
|
<div className="absolute top-3.5 left-3.5 w-1.5 h-1 bg-pink-300 rounded-full" />
|
||||||
|
</div>
|
||||||
|
{/* Tail */}
|
||||||
|
<div className="absolute -right-3 top-1 w-8 h-2.5 bg-orange-300 rounded-full origin-left rotate-12" />
|
||||||
|
</div>
|
||||||
|
{/* Paws */}
|
||||||
|
<div className="flex gap-3 mt-0.5 ml-2">
|
||||||
|
<div className="w-2.5 h-1.5 bg-orange-200 rounded-full" />
|
||||||
|
<div className="w-2.5 h-1.5 bg-orange-200 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-kira-plum/60 mt-1 font-medium">Mochi</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Black shorthair cat - sleeping */}
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="relative animate-float-slow">
|
||||||
|
{/* Sleeping body (curled up) */}
|
||||||
|
<div className="w-16 h-8 bg-gray-900 rounded-full relative overflow-hidden">
|
||||||
|
{/* Head tucked in */}
|
||||||
|
<div className="w-8 h-6 bg-gray-800 rounded-full absolute -left-2 -top-1">
|
||||||
|
{/* Ears */}
|
||||||
|
<div className="absolute -top-1.5 left-1 w-2.5 h-2.5 bg-gray-900 rounded-tl-full transform -rotate-12" />
|
||||||
|
<div className="absolute -top-1.5 right-1 w-2.5 h-2.5 bg-gray-900 rounded-tr-full transform rotate-12" />
|
||||||
|
{/* Closed eyes (sleeping) */}
|
||||||
|
<div className="absolute top-2 left-1 w-2 h-0.5 bg-gray-600 rounded-full" />
|
||||||
|
<div className="absolute top-2 right-1 w-2 h-0.5 bg-gray-600 rounded-full" />
|
||||||
|
</div>
|
||||||
|
{/* Tail curled around */}
|
||||||
|
<div className="absolute -right-2 top-2 w-6 h-2 bg-gray-900 rounded-full" />
|
||||||
|
</div>
|
||||||
|
{/* ZZZ */}
|
||||||
|
<div className="absolute -top-3 -right-2 text-[10px] text-kira-lav font-bold opacity-60 animate-zzz">Z z z</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-kira-plum/60 mt-1 font-medium">Luna</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes bounce-slow {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-4px); }
|
||||||
|
}
|
||||||
|
@keyframes float-slow {
|
||||||
|
0%, 100% { transform: translateY(0) scale(1); }
|
||||||
|
50% { transform: translateY(-2px) scale(1.01); }
|
||||||
|
}
|
||||||
|
@keyframes zzz {
|
||||||
|
0%, 100% { opacity: 0.3; transform: translateX(0); }
|
||||||
|
50% { opacity: 0.8; transform: translateX(3px); }
|
||||||
|
}
|
||||||
|
.animate-bounce-slow { animation: bounce-slow 3s ease-in-out infinite; }
|
||||||
|
.animate-float-slow { animation: float-slow 4s ease-in-out infinite; }
|
||||||
|
.animate-zzz { animation: zzz 2s ease-in-out infinite; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
type TimerMode = 'pomodoro' | 'countdown' | 'stopwatch';
|
||||||
|
|
||||||
|
interface Preset {
|
||||||
|
label: string;
|
||||||
|
minutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const POMODORO_PRESETS: Preset[] = [
|
||||||
|
{ label: 'Focus', minutes: 25 },
|
||||||
|
{ label: 'Short', minutes: 5 },
|
||||||
|
{ label: 'Long', minutes: 15 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const COUNTDOWN_PRESETS: Preset[] = [
|
||||||
|
{ label: '5 min', minutes: 5 },
|
||||||
|
{ label: '10 min', minutes: 10 },
|
||||||
|
{ label: '30 min', minutes: 30 },
|
||||||
|
{ label: '1 hr', minutes: 60 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatTime(seconds: number): string {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Timer() {
|
||||||
|
const [mode, setMode] = useState<TimerMode>('pomodoro');
|
||||||
|
const [presetMinutes, setPresetMinutes] = useState(25);
|
||||||
|
const [remaining, setRemaining] = useState(25 * 60);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [sessions, setSessions] = useState(0);
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
setRunning(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
stop();
|
||||||
|
setRemaining(presetMinutes * 60);
|
||||||
|
}, [presetMinutes, stop]);
|
||||||
|
|
||||||
|
const startStop = () => {
|
||||||
|
if (running) {
|
||||||
|
stop();
|
||||||
|
} else {
|
||||||
|
if (remaining <= 0) reset();
|
||||||
|
setRunning(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectPreset = (mins: number) => {
|
||||||
|
stop();
|
||||||
|
setPresetMinutes(mins);
|
||||||
|
setRemaining(mins * 60);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTime = (mins: number) => {
|
||||||
|
setRemaining((r) => r + mins * 60);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running) return;
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
setRemaining((r) => {
|
||||||
|
if (r <= 1) {
|
||||||
|
stop();
|
||||||
|
if (mode === 'pomodoro') setSessions((s) => s + 1);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return r - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return stop;
|
||||||
|
}, [running, mode, stop]);
|
||||||
|
|
||||||
|
const progress = presetMinutes > 0 ? 1 - remaining / (presetMinutes * 60) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-card p-4">
|
||||||
|
{/* Mode tabs */}
|
||||||
|
<div className="flex gap-1 mb-3 bg-kira-glow rounded-xl p-1">
|
||||||
|
{(['pomodoro', 'countdown', 'stopwatch'] as TimerMode[]).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => { setMode(m); stop(); setRemaining(m === 'pomodoro' ? 25 * 60 : m === 'countdown' ? 5 * 60 : 0); setPresetMinutes(m === 'pomodoro' ? 25 : m === 'countdown' ? 5 : 0); }}
|
||||||
|
className={`flex-1 text-xs font-semibold py-1.5 rounded-lg transition-all capitalize ${mode === m ? 'bg-white text-kira-plum shadow-sm' : 'text-kira-plum/50 hover:text-kira-plum'}`}
|
||||||
|
>
|
||||||
|
{m === 'pomodoro' ? '🍅 Focus' : m === 'countdown' ? '⏳ Timer' : '⏱️ Stopwatch'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Presets */}
|
||||||
|
<div className="flex gap-2 mb-3 flex-wrap">
|
||||||
|
{(mode === 'pomodoro' ? POMODORO_PRESETS : COUNTDOWN_PRESETS).map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.label}
|
||||||
|
onClick={() => selectPreset(p.minutes)}
|
||||||
|
className={`text-xs font-medium px-3 py-1 rounded-full transition-all ${presetMinutes === p.minutes && !running ? 'bg-kira-pink text-white' : 'bg-white/60 text-kira-plum/60 hover:bg-kira-glow'}`}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timer display */}
|
||||||
|
<div className="text-center py-2">
|
||||||
|
<div className="text-5xl font-extrabold tracking-widest text-kira-plum tabular-nums">
|
||||||
|
{mode === 'stopwatch'
|
||||||
|
? formatTime(Math.floor((presetMinutes * 60 - remaining + presetMinutes * 60) || remaining))
|
||||||
|
: formatTime(remaining)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="w-full h-1.5 bg-kira-glow rounded-full mt-3 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-kira-pink to-kira-lav rounded-full transition-all duration-1000"
|
||||||
|
style={{ width: `${Math.min(100, progress * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'pomodoro' && sessions > 0 && (
|
||||||
|
<div className="text-xs text-kira-violet/50 mt-1">
|
||||||
|
{sessions} focus session{sessions > 1 ? 's' : ''} completed
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex gap-2 justify-center mt-2">
|
||||||
|
<button onClick={startStop} className="btn-kira px-8 py-2 text-sm">
|
||||||
|
{running ? '⏸️ Pause' : remaining === 0 && mode !== 'stopwatch' ? '🔄 Restart' : '▶️ Start'}
|
||||||
|
</button>
|
||||||
|
{running && (
|
||||||
|
<button onClick={() => addTime(5)} className="bg-white/60 text-kira-plum/60 px-3 py-2 rounded-xl text-sm font-medium hover:bg-kira-glow transition-all">
|
||||||
|
+5m
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{remaining > 0 && !running && (
|
||||||
|
<button onClick={reset} className="bg-white/60 text-kira-plum/40 px-3 py-2 rounded-xl text-sm hover:bg-kira-glow transition-all">
|
||||||
|
🔄 Reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { SCENES } from './scenes';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentScene: string;
|
||||||
|
onSceneChange: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Toolbar({ currentScene, onSceneChange }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="glass-card px-4 py-3 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-lg font-bold text-kira-plum">Kira</span>
|
||||||
|
<span className="text-kira-plum/20">|</span>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{SCENES.filter((_, i) => i < 4).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => onSceneChange(s.id)}
|
||||||
|
className={`text-sm ${currentScene === s.id ? 'opacity-100' : 'opacity-40 hover:opacity-70'} transition-opacity`}
|
||||||
|
title={s.name}
|
||||||
|
>
|
||||||
|
{s.icon}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-sm text-kira-plum/50">
|
||||||
|
<span>🐱 2</span>
|
||||||
|
<span className="hidden sm:inline">focus bestie</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export interface Outfit {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OUTFITS: Outfit[] = [
|
||||||
|
{ id: 'cozy-hoodie', name: 'Cozy Hoodie', icon: '🧸', color: '#FFB6C1' },
|
||||||
|
{ id: 'girly-dress', name: 'Girly Dress', icon: '👗', color: '#D8B4FE' },
|
||||||
|
{ id: 'pajama-set', name: 'Pajama Set', icon: '🌙', color: '#A7F3D0' },
|
||||||
|
{ id: 'study-sweater', name: 'Study Sweater', icon: '📚', color: '#FED7AA' },
|
||||||
|
{ id: 'going-out', name: 'Going Out', icon: '✨', color: '#FBCFE8' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ACCESSORIES: Outfit[] = [
|
||||||
|
{ id: 'bow', name: 'Bow', icon: '🎀', color: '#FFB6C1' },
|
||||||
|
{ id: 'glasses', name: 'Glasses', icon: '👓', color: '#D8B4FE' },
|
||||||
|
{ id: 'flower-crown', name: 'Flower Crown', icon: '🌼', color: '#A7F3D0' },
|
||||||
|
{ id: 'star-earrings', name: 'Star Earrings', icon: '⭐', color: '#FDE68A' },
|
||||||
|
{ id: 'scarf', name: 'Scarf', icon: '🧣', color: '#FBCFE8' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onOutfitChange: (id: string) => void;
|
||||||
|
onAccessoryChange: (id: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Wardrobe({ onOutfitChange, onAccessoryChange }: Props) {
|
||||||
|
const [currentOutfit, setCurrentOutfit] = useState('cozy-hoodie');
|
||||||
|
const [currentAcc, setCurrentAcc] = useState<string | null>(null);
|
||||||
|
const [showAcc, setShowAcc] = useState(false);
|
||||||
|
|
||||||
|
const selectOutfit = (id: string) => {
|
||||||
|
setCurrentOutfit(id);
|
||||||
|
onOutfitChange(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAcc = (id: string) => {
|
||||||
|
const next = currentAcc === id ? null : id;
|
||||||
|
setCurrentAcc(next);
|
||||||
|
onAccessoryChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-card p-4">
|
||||||
|
<h3 className="text-sm font-bold text-kira-plum mb-3 flex items-center gap-2">
|
||||||
|
<span>👘</span> Wardrobe
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Outfits */}
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
{OUTFITS.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.id}
|
||||||
|
onClick={() => selectOutfit(o.id)}
|
||||||
|
className={`flex items-center gap-1 px-3 py-1.5 rounded-xl text-xs font-medium transition-all ${
|
||||||
|
currentOutfit === o.id
|
||||||
|
? 'text-white shadow-md'
|
||||||
|
: 'bg-white/40 text-kira-plum/60 hover:bg-kira-glow'
|
||||||
|
}`}
|
||||||
|
style={currentOutfit === o.id ? { background: o.color } : {}}
|
||||||
|
>
|
||||||
|
<span>{o.icon}</span>
|
||||||
|
<span>{o.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Accessories toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAcc(!showAcc)}
|
||||||
|
className="text-xs text-kira-violet/50 hover:text-kira-violet flex items-center gap-1 mb-2"
|
||||||
|
>
|
||||||
|
<span>{showAcc ? '▾' : '▸'}</span> Accessories
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAcc && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ACCESSORIES.map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.id}
|
||||||
|
onClick={() => selectAcc(a.id)}
|
||||||
|
className={`flex items-center gap-1 px-3 py-1 rounded-xl text-xs font-medium transition-all ${
|
||||||
|
currentAcc === a.id
|
||||||
|
? 'text-white shadow-md'
|
||||||
|
: 'bg-white/40 text-kira-plum/60 hover:bg-kira-glow'
|
||||||
|
}`}
|
||||||
|
style={currentAcc === a.id ? { background: a.color } : {}}
|
||||||
|
>
|
||||||
|
<span>{a.icon}</span>
|
||||||
|
<span>{a.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export interface Scene {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
gradient: string;
|
||||||
|
particles?: 'rain' | 'stars' | 'petals' | 'snow' | 'none';
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SCENES: Scene[] = [
|
||||||
|
{ id: 'cozy-room', name: 'Cozy Room', gradient: 'linear-gradient(135deg, #FFF5F5 0%, #FFE4E1 40%, #FFDAB9 100%)', icon: '🏠' },
|
||||||
|
{ id: 'coffee-shop', name: 'Coffee Shop', gradient: 'linear-gradient(135deg, #F5E6D3 0%, #E8D5C4 40%, #D4A574 100%)', icon: '☕' },
|
||||||
|
{ id: 'garden', name: 'Garden', gradient: 'linear-gradient(135deg, #F0FFF4 0%, #C6F6D5 40%, #9AE6B4 100%)', icon: '🌿' },
|
||||||
|
{ id: 'rainy-window', name: 'Rainy Window', gradient: 'linear-gradient(135deg, #E2E8F0 0%, #CBD5E0 40%, #A0AEC0 100%)', icon: '🌧️' },
|
||||||
|
{ id: 'starry-night', name: 'Starry Night', gradient: 'linear-gradient(135deg, #1A1A2E 0%, #16213E 40%, #0F3460 100%)', icon: '🌙' },
|
||||||
|
{ id: 'sakura', name: 'Sakura Spring', gradient: 'linear-gradient(135deg, #FFF0F5 0%, #FFB6C1 30%, #FF69B4 100%)', icon: '🌸' },
|
||||||
|
{ id: 'ocean', name: 'Ocean View', gradient: 'linear-gradient(135deg, #E0F7FA 0%, #B2EBF2 40%, #4DD0E1 100%)', icon: '🌊' },
|
||||||
|
{ id: 'autumn', name: 'Autumn Library', gradient: 'linear-gradient(135deg, #FFF8E1 0%, #FFE0B2 40%, #FFCC80 100%)', icon: '🍂' },
|
||||||
|
{ id: 'winter-cabin', name: 'Winter Cabin', gradient: 'linear-gradient(135deg, #EDF2F7 0%, #E2E8F0 40%, #CBD5E0 100%)', icon: '❄️' },
|
||||||
|
{ id: 'sunset', name: 'Sunset Beach', gradient: 'linear-gradient(135deg, #FFF5F5 0%, #FED7E2 30%, #F687B3 60%, #D53F8C 100%)', icon: '🌅' },
|
||||||
|
];
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'kira';
|
||||||
|
text: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WS_URL = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/ws`;
|
||||||
|
|
||||||
|
export function useConversation() {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [isKiraSpeaking, setIsKiraSpeaking] = useState(false);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
|
||||||
|
// Connect WebSocket
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
const ws = new WebSocket(WS_URL);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => setIsConnected(true);
|
||||||
|
ws.onclose = () => {
|
||||||
|
setIsConnected(false);
|
||||||
|
setTimeout(connect, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
handleMessage(msg);
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Audio playback element
|
||||||
|
useEffect(() => {
|
||||||
|
if (!audioRef.current) {
|
||||||
|
audioRef.current = new Audio();
|
||||||
|
audioRef.current.onended = () => setIsKiraSpeaking(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle incoming WS messages
|
||||||
|
const handleMessage = useCallback((msg: any) => {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'transcript':
|
||||||
|
addMessage('user', msg.text);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'speaking_start':
|
||||||
|
setIsKiraSpeaking(true);
|
||||||
|
addMessage('kira', msg.text || '...');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'audio':
|
||||||
|
if (msg.data && audioRef.current) {
|
||||||
|
const binary = atob(msg.data);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const blob = new Blob([bytes], { type: 'audio/ogg' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
audioRef.current.src = url;
|
||||||
|
audioRef.current.play().catch(() => {});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'speaking_end':
|
||||||
|
setIsKiraSpeaking(false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
console.error('[Kira]', msg.message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addMessage = useCallback((role: 'user' | 'kira', text: string) => {
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id: crypto.randomUUID(), role, text, timestamp: Date.now() },
|
||||||
|
]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Send text directly (no microphone)
|
||||||
|
const sendText = useCallback((text: string) => {
|
||||||
|
if (!text.trim()) return;
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify({ type: 'conversation_text', text: text.trim() }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Push-to-talk: start recording
|
||||||
|
const startRecording = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
streamRef.current = stream;
|
||||||
|
|
||||||
|
const recorder = new MediaRecorder(stream, {
|
||||||
|
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||||
|
? 'audio/webm;codecs=opus'
|
||||||
|
: 'audio/webm',
|
||||||
|
});
|
||||||
|
|
||||||
|
const chunks: BlobPart[] = [];
|
||||||
|
recorder.ondataavailable = (e) => {
|
||||||
|
if (e.data.size > 0) chunks.push(e.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.onstop = () => {
|
||||||
|
// Send all audio chunks as one blob
|
||||||
|
const blob = new Blob(chunks, { type: 'audio/webm' });
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const base64 = (reader.result as string).split(',')[1];
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify({
|
||||||
|
type: 'audio_chunk',
|
||||||
|
data: base64,
|
||||||
|
}));
|
||||||
|
wsRef.current.send(JSON.stringify({ type: 'transcribe' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
setIsRecording(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.start();
|
||||||
|
recorderRef.current = recorder;
|
||||||
|
setIsRecording(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Kira Mic] failed:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Push-to-talk: stop recording
|
||||||
|
const stopRecording = useCallback(() => {
|
||||||
|
recorderRef.current?.stop();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Connect on mount
|
||||||
|
useEffect(() => {
|
||||||
|
connect();
|
||||||
|
return () => {
|
||||||
|
wsRef.current?.close();
|
||||||
|
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||||
|
};
|
||||||
|
}, [connect]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
isConnected,
|
||||||
|
isKiraSpeaking,
|
||||||
|
isRecording,
|
||||||
|
sendText,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-kira-pink: #FFB6C1;
|
||||||
|
--color-kira-lav: #D8B4FE;
|
||||||
|
--color-kira-mint: #A7F3D0;
|
||||||
|
--color-kira-bg: #FFF5F5;
|
||||||
|
--color-kira-plum: #4A1942;
|
||||||
|
--color-kira-violet: #7C3AED;
|
||||||
|
--color-kira-card: #FFFFFF;
|
||||||
|
--color-kira-glow: #FDF2F8;
|
||||||
|
--font-nunito: 'Nunito', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
background-color: var(--color-kira-bg);
|
||||||
|
color: var(--color-kira-plum);
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card {
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 182, 193, 0.3);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 4px 24px rgba(74, 25, 66, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-kira {
|
||||||
|
background: linear-gradient(135deg, #FFB6C1, #D8B4FE);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-kira:hover {
|
||||||
|
transform: scale(1.03);
|
||||||
|
box-shadow: 0 4px 16px rgba(255, 182, 193, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-kira:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { SCENES, type Scene } from './scenes';
|
||||||
|
|
||||||
|
export interface KiraState {
|
||||||
|
currentScene: Scene;
|
||||||
|
isListening: boolean;
|
||||||
|
isSpeaking: boolean;
|
||||||
|
currentOutfit: string;
|
||||||
|
currentAccessory: string | null;
|
||||||
|
sessionNotes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Scene };
|
||||||
|
export { SCENES };
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://backend:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user