Initial commit: Desktop Waifu MVP foundation
- Project structure with modular architecture - State management system (emotion states, conversation history) - Transparent, draggable PyQt6 window - OpenGL rendering widget with placeholder cube - Discord bot framework (commands, event handlers) - Complete documentation (README, project plan, research findings) - Environment configuration template - Dependencies defined in requirements.txt MVP features working: - Transparent window appears at bottom-right - Window can be dragged around - Placeholder 3D cube renders and rotates - Emotion state changes on interaction - Event-driven state management Next steps: VRM model loading and rendering
This commit is contained in:
19
.env.example
Normal file
19
.env.example
Normal file
@@ -0,0 +1,19 @@
|
||||
# Discord Bot Configuration
|
||||
DISCORD_BOT_TOKEN=your_bot_token_here
|
||||
|
||||
# LLM Configuration
|
||||
LLM_API_URL=http://localhost:11434 # Ollama default
|
||||
LLM_MODEL_NAME=llama2
|
||||
|
||||
# Waifu Configuration
|
||||
WAIFU_NAME=Waifu
|
||||
VRM_MODEL_PATH=./models/waifu.vrm
|
||||
|
||||
# Audio Configuration
|
||||
TTS_VOICE_RATE=150
|
||||
TTS_VOICE_VOLUME=0.9
|
||||
|
||||
# System Configuration
|
||||
ENABLE_VOICE_INPUT=true
|
||||
ENABLE_VOICE_OUTPUT=true
|
||||
ENABLE_SYSTEM_ACCESS=false # Requires elevated permissions
|
54
.gitignore
vendored
Normal file
54
.gitignore
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.venv
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
||||
# Models and Assets (optional - keep if you want to commit them)
|
||||
models/*.vrm
|
||||
assets/sounds/*.wav
|
||||
assets/sounds/*.mp3
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Build artifacts
|
||||
*.exe
|
||||
*.spec
|
174
CURRENT_STATUS.md
Normal file
174
CURRENT_STATUS.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Current Development Status
|
||||
**Last Updated:** 2025-09-30
|
||||
|
||||
## ✅ Completed
|
||||
|
||||
### Project Setup
|
||||
- Project structure created with modular architecture
|
||||
- Dependencies defined in `requirements.txt`
|
||||
- Environment configuration (`.env.example`)
|
||||
- Documentation (`README.md`, `PROJECT_PLAN.md`, `RESEARCH_FINDINGS.md`)
|
||||
|
||||
### Core Systems
|
||||
- **State Manager** (`src/core/state_manager.py`): Event-driven state synchronization
|
||||
- Emotion states
|
||||
- Conversation history
|
||||
- Event listeners for state changes
|
||||
|
||||
### UI Framework
|
||||
- **Waifu Window** (`src/ui/waifu_window.py`): Main transparent window
|
||||
- Frameless, transparent, always-on-top
|
||||
- Drag functionality with mouse events
|
||||
- Window positioning (bottom-right by default)
|
||||
- Integration with state manager
|
||||
|
||||
- **VRM Widget** (`src/ui/vrm_widget.py`): OpenGL rendering widget
|
||||
- Basic OpenGL setup with transparency support
|
||||
- Placeholder rendering (rotating cube)
|
||||
- Animation timer (60 FPS)
|
||||
- Emotion state integration
|
||||
- Ready for VRM model loading
|
||||
|
||||
### Discord Integration (Stub)
|
||||
- **Bot Framework** (`src/discord_bot/bot.py`): Basic bot structure
|
||||
- Command system (hello, status)
|
||||
- Event handlers (on_ready, on_message)
|
||||
- State manager integration
|
||||
- Mention/DM detection
|
||||
|
||||
## 🔄 Next Steps (In Priority Order)
|
||||
|
||||
### 1. Test Basic Functionality
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run the app (will show placeholder cube)
|
||||
python main.py
|
||||
```
|
||||
|
||||
Expected result:
|
||||
- Transparent window appears at bottom-right
|
||||
- Rotating colored cube visible
|
||||
- Window can be dragged
|
||||
- Emotion changes when grabbed (check console)
|
||||
|
||||
### 2. Add Your VRM Model
|
||||
- Place `.vrm` file in `models/` folder
|
||||
- Update `.env` with path: `VRM_MODEL_PATH=./models/your_model.vrm`
|
||||
|
||||
### 3. Implement VRM Loading
|
||||
**File:** `src/ui/vrm_widget.py`
|
||||
|
||||
Tasks:
|
||||
- [ ] Use `pygltflib` to parse VRM file
|
||||
- [ ] Extract mesh data (vertices, normals, UVs, indices)
|
||||
- [ ] Extract material data (textures, colors)
|
||||
- [ ] Extract blend shape data (facial expressions)
|
||||
- [ ] Load textures into OpenGL
|
||||
- [ ] Create VBO/VAO for mesh rendering
|
||||
|
||||
**Reference:** `RESEARCH_FINDINGS.md` for VRM structure
|
||||
|
||||
### 4. Implement MToon Shader
|
||||
**New files:** `src/rendering/shaders/`
|
||||
|
||||
Tasks:
|
||||
- [ ] Create vertex shader (GLSL)
|
||||
- [ ] Create MToon fragment shader (GLSL)
|
||||
- [ ] Shader compilation and linking
|
||||
- [ ] Pass material parameters to shader
|
||||
- [ ] Render VRM mesh with shader
|
||||
|
||||
**Reference:** https://github.com/Santarh/MToon
|
||||
|
||||
### 5. Add Sound Effects
|
||||
**New file:** `src/audio/sound_manager.py`
|
||||
|
||||
Tasks:
|
||||
- [ ] Initialize pygame mixer
|
||||
- [ ] Load sound files from `assets/sounds/`
|
||||
- [ ] Play squeak on grab/drag
|
||||
- [ ] Play click on double-click
|
||||
- [ ] Integrate with state manager events
|
||||
|
||||
### 6. Create Chat Interface
|
||||
**New file:** `src/ui/chat_window.py`
|
||||
|
||||
Tasks:
|
||||
- [ ] Create chat window (QDialog or QWidget)
|
||||
- [ ] Text input field
|
||||
- [ ] Message history display
|
||||
- [ ] Show/hide on double-click
|
||||
- [ ] Integration with LLM backend
|
||||
|
||||
### 7. Integrate LLM
|
||||
**New file:** `src/llm/llm_backend.py`
|
||||
|
||||
Tasks:
|
||||
- [ ] Abstract LLM interface
|
||||
- [ ] Ollama implementation
|
||||
- [ ] llama.cpp implementation
|
||||
- [ ] Message processing
|
||||
- [ ] Emotion detection from responses
|
||||
- [ ] Update state based on LLM output
|
||||
|
||||
### 8. Complete Discord Bot
|
||||
**File:** `src/discord_bot/bot.py`
|
||||
|
||||
Tasks:
|
||||
- [ ] Connect Discord bot to LLM backend
|
||||
- [ ] Implement proper message responses
|
||||
- [ ] Add more commands
|
||||
- [ ] Sync desktop emotion with Discord messages
|
||||
- [ ] Handle async communication with desktop app
|
||||
|
||||
### 9. Package as .exe
|
||||
Tasks:
|
||||
- [ ] Test with PyInstaller
|
||||
- [ ] Create spec file
|
||||
- [ ] Bundle assets (models, sounds)
|
||||
- [ ] Test standalone .exe
|
||||
|
||||
## 🐛 Known Issues
|
||||
None yet - basic structure is in place
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
### VRM Model Requirements
|
||||
- Format: `.vrm` (based on glTF 2.0)
|
||||
- Should contain blend shapes for expressions
|
||||
- MToon material support needed
|
||||
|
||||
### LLM Integration Options
|
||||
1. **Ollama** (easiest): `ollama pull llama2` then use API
|
||||
2. **llama.cpp**: Faster, more control
|
||||
3. **Custom**: Your own model server
|
||||
|
||||
### Discord Bot Token
|
||||
- Get from: https://discord.com/developers/applications
|
||||
- Enable Message Content Intent
|
||||
- Add to `.env`: `DISCORD_BOT_TOKEN=your_token_here`
|
||||
|
||||
## 🔗 Important Files to Review
|
||||
|
||||
- `PROJECT_PLAN.md`: Overall project plan and architecture
|
||||
- `RESEARCH_FINDINGS.md`: Technical research on VRM rendering
|
||||
- `README.md`: Setup and usage instructions
|
||||
- `main.py`: Application entry point
|
||||
- `src/core/state_manager.py`: Central state system
|
||||
- `src/ui/vrm_widget.py`: Where VRM rendering happens
|
||||
|
||||
## 🎯 Testing Checklist
|
||||
|
||||
- [ ] Window appears and is transparent
|
||||
- [ ] Window can be dragged
|
||||
- [ ] Placeholder renders correctly
|
||||
- [ ] Console shows emotion changes
|
||||
- [ ] Install dependencies works
|
||||
- [ ] .env configuration works
|
||||
- [ ] (After VRM) Model loads without errors
|
||||
- [ ] (After VRM) Model renders correctly
|
||||
- [ ] (After sound) Sound plays on interaction
|
||||
- [ ] (After LLM) Chat works
|
||||
- [ ] (After Discord) Bot connects and responds
|
130
PROJECT_PLAN.md
Normal file
130
PROJECT_PLAN.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Desktop Waifu Project
|
||||
|
||||
## Overview
|
||||
Desktop companion application controlled by LLM that can interact both on desktop and Discord.
|
||||
|
||||
## Tech Stack
|
||||
- **Language**: Python
|
||||
- **Character Model**: VRM format
|
||||
- **LLM**: Local (TBD which model)
|
||||
- **Distribution**: .exe packaging
|
||||
- **Platforms**: Desktop app + Discord bot
|
||||
|
||||
## Core Features
|
||||
|
||||
### Desktop Visuals
|
||||
- VRM model rendering in transparent window
|
||||
- Draggable character
|
||||
- Sound effects on interaction (squeaks, touch sounds)
|
||||
- Multiple poses/expressions controlled by LLM
|
||||
- Always on top unless asked to hide
|
||||
- VRM animations (TBD - see notes below)
|
||||
|
||||
### AI & Interaction
|
||||
- Local LLM (custom/TBD)
|
||||
- Memory/context persistence
|
||||
- AI-chosen personality
|
||||
- Always-on chat interface
|
||||
- Voice input (STT)
|
||||
- Voice output (TTS - local)
|
||||
|
||||
### Discord Integration
|
||||
- Respond in servers/channels and DMs
|
||||
- Proactive messaging
|
||||
- Desktop-Discord state sync
|
||||
|
||||
### System Integration
|
||||
- System access (notifications, apps, searches, etc.)
|
||||
- Designed for cross-platform deployment
|
||||
- Future: OS-level integration
|
||||
|
||||
## VRM Animation Notes
|
||||
VRM models support:
|
||||
- **Blend shapes** (facial expressions): smile, blink, surprised, angry, sad, etc.
|
||||
- **Bone animations**: waving, pointing, head tilts, body movements
|
||||
- **Presets**: if your VRM has animation clips embedded
|
||||
- **IK (Inverse Kinematics)**: dynamic movements like looking at cursor
|
||||
|
||||
We can trigger these based on:
|
||||
- LLM emotional state (happy → smile + wave)
|
||||
- User interaction (grabbed → surprised expression + squeak)
|
||||
- Idle states (occasional blinks, breathing animation)
|
||||
- Context (thinking → hand on chin pose)
|
||||
|
||||
## MVP (Phase 1)
|
||||
1. VRM model renders on screen
|
||||
2. Transparent, draggable window
|
||||
3. Basic sound on interaction
|
||||
4. Simple chat interface (text only)
|
||||
5. Basic LLM connection (local)
|
||||
6. Simple expression changes (happy/neutral/sad)
|
||||
7. **Discord bot integration** (respond in servers/DMs, basic sync with desktop)
|
||||
|
||||
## Post-MVP Features
|
||||
- Voice I/O (STT/TTS)
|
||||
- Advanced animations
|
||||
- Full system integration (notifications, app control, etc.)
|
||||
- Memory persistence (database)
|
||||
- Proactive messaging
|
||||
- .exe packaging
|
||||
- Cross-platform support
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
1. **VRM Renderer** (PyOpenGL + VRM loader)
|
||||
2. **LLM Backend** (local inference)
|
||||
3. **Audio System** (TTS, STT, sound effects)
|
||||
4. **Discord Client** (discord.py)
|
||||
5. **State Manager** (sync between desktop/Discord)
|
||||
6. **System Interface** (OS interactions)
|
||||
7. **GUI Framework** (PyQt/tkinter with transparency)
|
||||
|
||||
### Data Flow
|
||||
```
|
||||
User Input (voice/text/click)
|
||||
↓
|
||||
State Manager
|
||||
↓
|
||||
LLM Processing
|
||||
↓
|
||||
Output (animation + voice + text + actions)
|
||||
↓
|
||||
Desktop Display + Discord Bot
|
||||
```
|
||||
|
||||
## Tech Stack Candidates
|
||||
|
||||
### VRM Rendering
|
||||
- PyVRM (if available)
|
||||
- PyOpenGL + custom VRM parser
|
||||
- Unity Python wrapper (heavy)
|
||||
- Godot Python binding (alternative)
|
||||
|
||||
### LLM
|
||||
- llama.cpp Python bindings
|
||||
- Ollama API
|
||||
- Custom model server
|
||||
- Transformers library
|
||||
|
||||
### Voice
|
||||
- **TTS**: pyttsx3, Coqui TTS, XTTS
|
||||
- **STT**: Whisper (local), Vosk
|
||||
|
||||
### Discord
|
||||
- discord.py
|
||||
|
||||
### Packaging
|
||||
- PyInstaller
|
||||
- Nuitka (better performance)
|
||||
|
||||
## Current Status
|
||||
- Phase: Planning
|
||||
- Last Updated: 2025-09-30
|
||||
|
||||
## Next Steps
|
||||
1. Research VRM rendering in Python
|
||||
2. Create basic window with transparency
|
||||
3. Load and display VRM model
|
||||
4. Implement dragging
|
||||
5. Add sound effects
|
182
README.md
Normal file
182
README.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# Desktop Waifu 🎀
|
||||
|
||||
An AI-powered desktop companion with VRM model rendering and Discord integration.
|
||||
|
||||
## Features
|
||||
|
||||
### Current (MVP)
|
||||
- ✅ Transparent desktop widget
|
||||
- ✅ Draggable VRM character
|
||||
- ✅ Always-on-top window
|
||||
- ✅ Basic state management
|
||||
- ⏳ VRM model rendering (in progress)
|
||||
- ⏳ Sound effects on interaction
|
||||
- ⏳ Text chat interface
|
||||
- ⏳ Local LLM integration
|
||||
- ⏳ Expression changes based on emotion
|
||||
- ⏳ Discord bot integration
|
||||
|
||||
### Planned
|
||||
- Voice input/output (STT/TTS)
|
||||
- Advanced animations
|
||||
- System integration (notifications, app control)
|
||||
- Memory persistence
|
||||
- Proactive messaging
|
||||
- Cross-platform support
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
- Python 3.10+
|
||||
- VRM model file (`.vrm`)
|
||||
- Local LLM (Ollama, llama.cpp, etc.) - optional for testing
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Clone/Download the project**
|
||||
|
||||
2. **Install dependencies:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. **Configure environment:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings
|
||||
```
|
||||
|
||||
4. **Add your VRM model:**
|
||||
- Place your `.vrm` file in the `models/` folder
|
||||
- Update `VRM_MODEL_PATH` in `.env`
|
||||
|
||||
5. **Add sound effects (optional):**
|
||||
- Place `.wav` files in `assets/sounds/`
|
||||
- Examples: `squeak.wav`, `click.wav`
|
||||
|
||||
### Discord Setup (Optional)
|
||||
|
||||
1. Create a Discord bot at https://discord.com/developers/applications
|
||||
2. Enable these intents:
|
||||
- Message Content Intent
|
||||
- Server Members Intent
|
||||
3. Copy bot token to `DISCORD_BOT_TOKEN` in `.env`
|
||||
4. Invite bot to your server with permissions:
|
||||
- Send Messages
|
||||
- Read Message History
|
||||
- Use Slash Commands
|
||||
|
||||
### LLM Setup (Optional)
|
||||
|
||||
#### Using Ollama:
|
||||
```bash
|
||||
# Install Ollama from https://ollama.ai
|
||||
ollama pull llama2
|
||||
# Update .env: LLM_API_URL=http://localhost:11434
|
||||
```
|
||||
|
||||
#### Using llama.cpp:
|
||||
```bash
|
||||
pip install llama-cpp-python
|
||||
# Configure model path in code
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Desktop Interactions
|
||||
- **Click and drag**: Move the waifu around your screen
|
||||
- **Double-click**: Open chat interface (coming soon)
|
||||
- **Right-click**: Context menu (coming soon)
|
||||
|
||||
### Discord Commands
|
||||
- `!hello`: Greet the waifu
|
||||
- `!status`: Check current mood/status
|
||||
- `@mention`: Talk to the waifu in any channel
|
||||
- **DM**: Send direct messages
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Waifu/
|
||||
├── main.py # Entry point
|
||||
├── requirements.txt # Dependencies
|
||||
├── .env # Configuration (create from .env.example)
|
||||
├── PROJECT_PLAN.md # Development plan
|
||||
├── RESEARCH_FINDINGS.md # Technical research
|
||||
├── models/ # VRM model files
|
||||
│ └── .gitkeep
|
||||
├── assets/
|
||||
│ └── sounds/ # Sound effects
|
||||
│ └── .gitkeep
|
||||
└── src/
|
||||
├── core/
|
||||
│ └── state_manager.py # State synchronization
|
||||
├── ui/
|
||||
│ ├── waifu_window.py # Main window
|
||||
│ └── vrm_widget.py # VRM renderer
|
||||
├── discord_bot/
|
||||
│ └── bot.py # Discord integration
|
||||
├── llm/ # LLM integration (TODO)
|
||||
└── audio/ # Audio system (TODO)
|
||||
```
|
||||
|
||||
## Development Status
|
||||
|
||||
### Phase 1: MVP (In Progress)
|
||||
- [x] Project structure
|
||||
- [x] State management system
|
||||
- [x] Transparent window framework
|
||||
- [x] Drag functionality
|
||||
- [x] Basic OpenGL setup
|
||||
- [ ] VRM model loading
|
||||
- [ ] VRM rendering with MToon shader
|
||||
- [ ] Sound effects
|
||||
- [ ] Chat interface
|
||||
- [ ] LLM integration
|
||||
- [ ] Discord bot
|
||||
- [ ] Expression control
|
||||
|
||||
### Phase 2: Enhancement (Planned)
|
||||
- [ ] Voice I/O
|
||||
- [ ] Advanced animations
|
||||
- [ ] System integration
|
||||
- [ ] Memory/database
|
||||
- [ ] .exe packaging
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Architecture
|
||||
- **UI**: PyQt6 with transparent windows
|
||||
- **Rendering**: OpenGL via QOpenGLWidget
|
||||
- **VRM Parsing**: pygltflib
|
||||
- **State Management**: Custom event-driven system
|
||||
- **Discord**: discord.py
|
||||
- **LLM**: Flexible (Ollama, llama.cpp, custom)
|
||||
|
||||
### VRM Rendering
|
||||
- VRM models are glTF 2.0 with extensions
|
||||
- Custom MToon shader implementation needed
|
||||
- Blend shapes for facial expressions
|
||||
- Bone animations for gestures
|
||||
|
||||
See `RESEARCH_FINDINGS.md` for detailed technical research.
|
||||
|
||||
## Contributing
|
||||
|
||||
This is a personal project, but suggestions and feedback are welcome!
|
||||
|
||||
## License
|
||||
|
||||
TBD
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- VRM Consortium for the VRM specification
|
||||
- PyQt project for the excellent GUI framework
|
||||
- discord.py for Discord integration
|
105
RESEARCH_FINDINGS.md
Normal file
105
RESEARCH_FINDINGS.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# VRM Rendering Research Findings
|
||||
|
||||
## Date: 2025-09-30
|
||||
|
||||
## Problem
|
||||
Need to render VRM 3D model in a transparent desktop window using Python.
|
||||
|
||||
## VRM Format
|
||||
- VRM is based on glTF 2.0 with VRM-specific extensions
|
||||
- Uses MToon shader for toon/cel-shaded rendering
|
||||
- Contains blend shapes for facial expressions
|
||||
- Contains bone data for animations
|
||||
|
||||
## Python VRM/glTF Libraries
|
||||
|
||||
### pygltflib (RECOMMENDED)
|
||||
- **Pros**: Can parse VRM files, read extension data, well-maintained
|
||||
- **Cons**: Parsing only, doesn't handle rendering
|
||||
- **Use**: Load VRM data, extract meshes/materials/animations
|
||||
|
||||
### Alternatives
|
||||
- `gltflib`: Fork of pygltflib with divergent features
|
||||
- `pyrender`: Can render glTF but **doesn't support MToon shader properly** (produces holes)
|
||||
|
||||
## Rendering Solutions
|
||||
|
||||
### ✅ CHOSEN: PyQt6 + QOpenGLWidget
|
||||
**Best fit for desktop widget**
|
||||
|
||||
**Pros:**
|
||||
- Native transparent window support (`WA_TranslucentBackground`)
|
||||
- Always-on-top windows (`WindowStaysOnTopHint`)
|
||||
- Frameless windows (`FramelessWindowHint`)
|
||||
- Direct OpenGL rendering via QOpenGLWidget
|
||||
- Excellent for desktop widgets/mascots
|
||||
- Can implement custom MToon shader in GLSL
|
||||
|
||||
**Implementation:**
|
||||
```python
|
||||
class VRMWidget(QOpenGLWidget):
|
||||
def initializeGL(self):
|
||||
# Setup OpenGL state, load shaders
|
||||
def resizeGL(self, w, h):
|
||||
# Handle window resize
|
||||
def paintGL(self):
|
||||
# Render VRM model
|
||||
```
|
||||
|
||||
**Window Setup:**
|
||||
```python
|
||||
window.setAttribute(Qt.WA_TranslucentBackground)
|
||||
window.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
|
||||
```
|
||||
|
||||
### Alternative: Panda3D
|
||||
**Not chosen due to:**
|
||||
- Limited transparent window support
|
||||
- Requires embedding in another GUI framework anyway
|
||||
- More complexity for our use case
|
||||
|
||||
### Alternative: Blender Python API
|
||||
**Not chosen due to:**
|
||||
- Too heavyweight for a desktop widget
|
||||
- Not designed for real-time desktop applications
|
||||
|
||||
## MToon Shader Implementation
|
||||
Since Python libraries don't support MToon, we need to:
|
||||
1. Parse VRM using pygltflib to get material properties
|
||||
2. Implement custom GLSL MToon shader in OpenGL
|
||||
3. Apply shader during rendering
|
||||
|
||||
MToon shader resources:
|
||||
- Reference: https://github.com/Santarh/MToon
|
||||
- Can port GLSL version to PyQt's OpenGL context
|
||||
|
||||
## Recommended Tech Stack
|
||||
|
||||
### Core
|
||||
- **PyQt6**: Window management, transparency, always-on-top
|
||||
- **QOpenGLWidget**: OpenGL rendering context
|
||||
- **pygltflib**: VRM/glTF parsing
|
||||
- **PyOpenGL**: OpenGL bindings for Python
|
||||
|
||||
### Audio
|
||||
- **pygame**: Sound effects (squeaks, etc.)
|
||||
- **pyttsx3** or **Coqui TTS**: Text-to-speech
|
||||
- **whisper.cpp Python bindings**: Speech-to-text
|
||||
|
||||
### LLM
|
||||
- **llama-cpp-python**: Local LLM inference
|
||||
- **ollama-python**: Alternative if using Ollama
|
||||
|
||||
### Discord
|
||||
- **discord.py**: Discord bot
|
||||
|
||||
### Packaging
|
||||
- **PyInstaller** or **Nuitka**: .exe compilation
|
||||
|
||||
## Next Steps
|
||||
1. Install PyQt6, PyOpenGL, pygltflib
|
||||
2. Create basic transparent window
|
||||
3. Load VRM using pygltflib
|
||||
4. Implement basic OpenGL renderer
|
||||
5. Port MToon shader to GLSL
|
||||
6. Add interactivity (drag, click)
|
2
assets/sounds/.gitkeep
Normal file
2
assets/sounds/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
||||
# Place sound effect files here
|
||||
# Example: squeak.wav, click.wav, etc.
|
41
main.py
Normal file
41
main.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Desktop Waifu - Main Entry Point
|
||||
A VRM-based AI desktop companion with Discord integration
|
||||
"""
|
||||
import sys
|
||||
import asyncio
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
from PyQt6.QtCore import Qt
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Import application modules
|
||||
from src.ui.waifu_window import WaifuWindow
|
||||
from src.discord_bot.bot import WaifuBot
|
||||
from src.core.state_manager import StateManager
|
||||
|
||||
def main():
|
||||
"""Main application entry point"""
|
||||
# Create Qt Application
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Desktop Waifu")
|
||||
|
||||
# Initialize state manager (shared between desktop and Discord)
|
||||
state_manager = StateManager()
|
||||
|
||||
# Create main window
|
||||
window = WaifuWindow(state_manager)
|
||||
window.show()
|
||||
|
||||
# Start Discord bot in background (if configured)
|
||||
# TODO: Implement Discord bot integration
|
||||
# discord_bot = WaifuBot(state_manager)
|
||||
# asyncio.create_task(discord_bot.start())
|
||||
|
||||
# Run application
|
||||
sys.exit(app.exec())
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
2
models/.gitkeep
Normal file
2
models/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
||||
# Place your .vrm model file here
|
||||
# Example: waifu.vrm
|
25
requirements.txt
Normal file
25
requirements.txt
Normal file
@@ -0,0 +1,25 @@
|
||||
# Core GUI and Rendering
|
||||
PyQt6>=6.6.0
|
||||
PyOpenGL>=3.1.7
|
||||
PyOpenGL-accelerate>=3.1.7
|
||||
|
||||
# VRM/glTF Loading
|
||||
pygltflib>=1.16.1
|
||||
numpy>=1.24.0
|
||||
pillow>=10.0.0
|
||||
|
||||
# Audio
|
||||
pygame>=2.5.0
|
||||
pyttsx3>=2.90
|
||||
|
||||
# LLM (choose one or install as needed)
|
||||
# llama-cpp-python>=0.2.0
|
||||
# ollama>=0.1.0
|
||||
|
||||
# Discord
|
||||
discord.py>=2.3.0
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Utilities
|
||||
aiohttp>=3.9.0
|
||||
asyncio-mqtt>=0.16.0
|
2
src/__init__.py
Normal file
2
src/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Desktop Waifu - Source Package"""
|
||||
__version__ = "0.1.0"
|
1
src/audio/__init__.py
Normal file
1
src/audio/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Audio system for Desktop Waifu (TTS, STT, sound effects)"""
|
1
src/core/__init__.py
Normal file
1
src/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Core functionality for Desktop Waifu"""
|
99
src/core/state_manager.py
Normal file
99
src/core/state_manager.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
State Manager - Synchronizes state between desktop and Discord
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Optional, Callable, Dict, Any
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
class EmotionState(Enum):
|
||||
"""Waifu emotional states"""
|
||||
NEUTRAL = "neutral"
|
||||
HAPPY = "happy"
|
||||
SAD = "sad"
|
||||
SURPRISED = "surprised"
|
||||
THINKING = "thinking"
|
||||
EXCITED = "excited"
|
||||
ANNOYED = "annoyed"
|
||||
|
||||
@dataclass
|
||||
class WaifuState:
|
||||
"""Current state of the waifu"""
|
||||
emotion: EmotionState = EmotionState.NEUTRAL
|
||||
is_speaking: bool = False
|
||||
is_listening: bool = False
|
||||
current_animation: Optional[str] = None
|
||||
last_interaction: Optional[datetime] = None
|
||||
conversation_context: list = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.conversation_context is None:
|
||||
self.conversation_context = []
|
||||
|
||||
class StateManager:
|
||||
"""Manages and synchronizes waifu state across components"""
|
||||
|
||||
def __init__(self):
|
||||
self.state = WaifuState()
|
||||
self._listeners: Dict[str, list[Callable]] = {
|
||||
'emotion_change': [],
|
||||
'speech_start': [],
|
||||
'speech_end': [],
|
||||
'interaction': [],
|
||||
}
|
||||
|
||||
def register_listener(self, event: str, callback: Callable):
|
||||
"""Register a callback for state changes"""
|
||||
if event in self._listeners:
|
||||
self._listeners[event].append(callback)
|
||||
|
||||
def unregister_listener(self, event: str, callback: Callable):
|
||||
"""Unregister a callback"""
|
||||
if event in self._listeners and callback in self._listeners[event]:
|
||||
self._listeners[event].remove(callback)
|
||||
|
||||
def _notify_listeners(self, event: str, *args, **kwargs):
|
||||
"""Notify all listeners of an event"""
|
||||
for callback in self._listeners.get(event, []):
|
||||
try:
|
||||
callback(*args, **kwargs)
|
||||
except Exception as e:
|
||||
print(f"Error in listener callback: {e}")
|
||||
|
||||
def set_emotion(self, emotion: EmotionState):
|
||||
"""Change waifu's emotional state"""
|
||||
if self.state.emotion != emotion:
|
||||
old_emotion = self.state.emotion
|
||||
self.state.emotion = emotion
|
||||
self._notify_listeners('emotion_change', old_emotion, emotion)
|
||||
|
||||
def start_speaking(self):
|
||||
"""Mark waifu as speaking"""
|
||||
self.state.is_speaking = True
|
||||
self._notify_listeners('speech_start')
|
||||
|
||||
def stop_speaking(self):
|
||||
"""Mark waifu as not speaking"""
|
||||
self.state.is_speaking = False
|
||||
self._notify_listeners('speech_end')
|
||||
|
||||
def add_to_conversation(self, role: str, message: str):
|
||||
"""Add message to conversation history"""
|
||||
self.state.conversation_context.append({
|
||||
'role': role,
|
||||
'message': message,
|
||||
'timestamp': datetime.now()
|
||||
})
|
||||
# Keep only last 20 messages
|
||||
if len(self.state.conversation_context) > 20:
|
||||
self.state.conversation_context = self.state.conversation_context[-20:]
|
||||
|
||||
def record_interaction(self):
|
||||
"""Record that an interaction occurred"""
|
||||
self.state.last_interaction = datetime.now()
|
||||
self._notify_listeners('interaction')
|
||||
|
||||
def get_conversation_history(self, limit: int = 10) -> list:
|
||||
"""Get recent conversation history"""
|
||||
return self.state.conversation_context[-limit:]
|
1
src/discord_bot/__init__.py
Normal file
1
src/discord_bot/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Discord bot integration for Desktop Waifu"""
|
79
src/discord_bot/bot.py
Normal file
79
src/discord_bot/bot.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Discord Bot - Waifu Discord integration
|
||||
"""
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from src.core.state_manager import StateManager, EmotionState
|
||||
|
||||
class WaifuBot(commands.Bot):
|
||||
"""Discord bot for waifu interactions"""
|
||||
|
||||
def __init__(self, state_manager: StateManager):
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
intents.guilds = True
|
||||
intents.dm_messages = True
|
||||
|
||||
super().__init__(command_prefix='!', intents=intents)
|
||||
|
||||
self.state_manager = state_manager
|
||||
self.setup_commands()
|
||||
self.setup_event_handlers()
|
||||
|
||||
def setup_commands(self):
|
||||
"""Setup bot commands"""
|
||||
|
||||
@self.command(name='hello')
|
||||
async def hello(ctx):
|
||||
"""Say hello to the waifu"""
|
||||
self.state_manager.set_emotion(EmotionState.HAPPY)
|
||||
self.state_manager.record_interaction()
|
||||
await ctx.send("Hello! 👋 I'm here!")
|
||||
|
||||
@self.command(name='status')
|
||||
async def status(ctx):
|
||||
"""Check waifu status"""
|
||||
emotion = self.state_manager.state.emotion.value
|
||||
await ctx.send(f"Current mood: {emotion}")
|
||||
|
||||
def setup_event_handlers(self):
|
||||
"""Setup Discord event handlers"""
|
||||
|
||||
@self.event
|
||||
async def on_ready():
|
||||
print(f'{self.user} has connected to Discord!')
|
||||
print(f'Bot is in {len(self.guilds)} guilds')
|
||||
|
||||
@self.event
|
||||
async def on_message(message):
|
||||
# Don't respond to self
|
||||
if message.author == self.user:
|
||||
return
|
||||
|
||||
# Check if bot is mentioned or in DM
|
||||
if self.user.mentioned_in(message) or isinstance(message.channel, discord.DMChannel):
|
||||
# TODO: Process message with LLM
|
||||
self.state_manager.add_to_conversation('user', message.content)
|
||||
self.state_manager.record_interaction()
|
||||
|
||||
# Placeholder response
|
||||
response = "I heard you! (LLM integration coming soon)"
|
||||
await message.channel.send(response)
|
||||
|
||||
# Process commands
|
||||
await self.process_commands(message)
|
||||
|
||||
async def start_bot(self):
|
||||
"""Start the Discord bot"""
|
||||
token = os.getenv('DISCORD_BOT_TOKEN')
|
||||
if not token:
|
||||
print("Warning: DISCORD_BOT_TOKEN not set in .env file")
|
||||
return
|
||||
|
||||
try:
|
||||
await self.start(token)
|
||||
except Exception as e:
|
||||
print(f"Error starting Discord bot: {e}")
|
1
src/llm/__init__.py
Normal file
1
src/llm/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""LLM integration for Desktop Waifu"""
|
1
src/ui/__init__.py
Normal file
1
src/ui/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""UI components for Desktop Waifu"""
|
151
src/ui/vrm_widget.py
Normal file
151
src/ui/vrm_widget.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
VRM Widget - OpenGL widget for rendering VRM models
|
||||
"""
|
||||
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
from PyQt6.QtGui import QSurfaceFormat
|
||||
from OpenGL.GL import *
|
||||
from OpenGL.GLU import *
|
||||
import numpy as np
|
||||
|
||||
from src.core.state_manager import StateManager, EmotionState
|
||||
|
||||
class VRMWidget(QOpenGLWidget):
|
||||
"""OpenGL widget for rendering VRM character model"""
|
||||
|
||||
def __init__(self, state_manager: StateManager, parent=None):
|
||||
# Configure OpenGL format with alpha channel for transparency
|
||||
fmt = QSurfaceFormat()
|
||||
fmt.setVersion(2, 1) # Use OpenGL 2.1 for compatibility with fixed-function pipeline
|
||||
fmt.setAlphaBufferSize(8)
|
||||
QSurfaceFormat.setDefaultFormat(fmt)
|
||||
|
||||
super().__init__(parent)
|
||||
self.state_manager = state_manager
|
||||
self.vrm_model = None
|
||||
self.current_emotion = EmotionState.NEUTRAL
|
||||
|
||||
# Setup state change listener
|
||||
self.state_manager.register_listener('emotion_change', self.on_emotion_change)
|
||||
|
||||
# Animation timer
|
||||
self.animation_timer = QTimer(self)
|
||||
self.animation_timer.timeout.connect(self.update_animation)
|
||||
self.animation_timer.start(16) # ~60 FPS
|
||||
|
||||
def initializeGL(self):
|
||||
"""Initialize OpenGL state"""
|
||||
# Enable depth testing
|
||||
glEnable(GL_DEPTH_TEST)
|
||||
|
||||
# Enable blending for transparency
|
||||
glEnable(GL_BLEND)
|
||||
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
|
||||
|
||||
# Set clear color (transparent)
|
||||
glClearColor(0.0, 0.0, 0.0, 0.0)
|
||||
|
||||
# TODO: Load VRM model
|
||||
# self.load_vrm_model()
|
||||
|
||||
# TODO: Setup shaders (MToon shader)
|
||||
# self.setup_shaders()
|
||||
|
||||
print("OpenGL initialized")
|
||||
|
||||
def resizeGL(self, w: int, h: int):
|
||||
"""Handle window resize"""
|
||||
glViewport(0, 0, w, h)
|
||||
|
||||
# Setup projection matrix
|
||||
glMatrixMode(GL_PROJECTION)
|
||||
glLoadIdentity()
|
||||
aspect = w / h if h != 0 else 1
|
||||
gluPerspective(45, aspect, 0.1, 100.0)
|
||||
|
||||
glMatrixMode(GL_MODELVIEW)
|
||||
|
||||
def paintGL(self):
|
||||
"""Render the VRM model"""
|
||||
# Clear buffers
|
||||
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
|
||||
|
||||
# Reset modelview matrix
|
||||
glLoadIdentity()
|
||||
|
||||
# Move camera back
|
||||
glTranslatef(0.0, -0.5, -3.0)
|
||||
|
||||
# TODO: Render VRM model
|
||||
# For now, render a placeholder
|
||||
self.render_placeholder()
|
||||
|
||||
def render_placeholder(self):
|
||||
"""Render a placeholder (cube) until VRM is loaded"""
|
||||
# Rotate for animation
|
||||
import time
|
||||
angle = (time.time() % 4) * 90
|
||||
glRotatef(angle, 0, 1, 0)
|
||||
|
||||
# Draw a simple cube as placeholder
|
||||
glBegin(GL_QUADS)
|
||||
|
||||
# Front face (red)
|
||||
glColor4f(1.0, 0.0, 0.0, 0.8)
|
||||
glVertex3f(-0.5, -0.5, 0.5)
|
||||
glVertex3f( 0.5, -0.5, 0.5)
|
||||
glVertex3f( 0.5, 0.5, 0.5)
|
||||
glVertex3f(-0.5, 0.5, 0.5)
|
||||
|
||||
# Back face (green)
|
||||
glColor4f(0.0, 1.0, 0.0, 0.8)
|
||||
glVertex3f(-0.5, -0.5, -0.5)
|
||||
glVertex3f(-0.5, 0.5, -0.5)
|
||||
glVertex3f( 0.5, 0.5, -0.5)
|
||||
glVertex3f( 0.5, -0.5, -0.5)
|
||||
|
||||
# Other faces (blue)
|
||||
glColor4f(0.0, 0.0, 1.0, 0.8)
|
||||
# Top
|
||||
glVertex3f(-0.5, 0.5, -0.5)
|
||||
glVertex3f(-0.5, 0.5, 0.5)
|
||||
glVertex3f( 0.5, 0.5, 0.5)
|
||||
glVertex3f( 0.5, 0.5, -0.5)
|
||||
# Bottom
|
||||
glVertex3f(-0.5, -0.5, -0.5)
|
||||
glVertex3f( 0.5, -0.5, -0.5)
|
||||
glVertex3f( 0.5, -0.5, 0.5)
|
||||
glVertex3f(-0.5, -0.5, 0.5)
|
||||
|
||||
# Right face (yellow)
|
||||
glColor4f(1.0, 1.0, 0.0, 0.8)
|
||||
glVertex3f( 0.5, -0.5, -0.5)
|
||||
glVertex3f( 0.5, 0.5, -0.5)
|
||||
glVertex3f( 0.5, 0.5, 0.5)
|
||||
glVertex3f( 0.5, -0.5, 0.5)
|
||||
|
||||
# Left face (magenta)
|
||||
glColor4f(1.0, 0.0, 1.0, 0.8)
|
||||
glVertex3f(-0.5, -0.5, -0.5)
|
||||
glVertex3f(-0.5, -0.5, 0.5)
|
||||
glVertex3f(-0.5, 0.5, 0.5)
|
||||
glVertex3f(-0.5, 0.5, -0.5)
|
||||
|
||||
glEnd()
|
||||
|
||||
def update_animation(self):
|
||||
"""Update animation frame"""
|
||||
# Trigger repaint
|
||||
self.update()
|
||||
|
||||
def on_emotion_change(self, old_emotion: EmotionState, new_emotion: EmotionState):
|
||||
"""Handle emotion state changes"""
|
||||
self.current_emotion = new_emotion
|
||||
# TODO: Update blend shapes/expressions based on emotion
|
||||
print(f"VRM Widget: Emotion changed to {new_emotion.value}")
|
||||
|
||||
def load_vrm_model(self, model_path: str):
|
||||
"""Load VRM model from file"""
|
||||
# TODO: Implement VRM loading using pygltflib
|
||||
print(f"Loading VRM model from {model_path}")
|
||||
pass
|
108
src/ui/waifu_window.py
Normal file
108
src/ui/waifu_window.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Waifu Window - Main transparent desktop widget
|
||||
"""
|
||||
from PyQt6.QtWidgets import QMainWindow, QWidget, QVBoxLayout
|
||||
from PyQt6.QtCore import Qt, QPoint
|
||||
from PyQt6.QtGui import QPalette, QColor
|
||||
|
||||
from src.core.state_manager import StateManager, EmotionState
|
||||
from src.ui.vrm_widget import VRMWidget
|
||||
|
||||
class WaifuWindow(QMainWindow):
|
||||
"""Main window for desktop waifu"""
|
||||
|
||||
def __init__(self, state_manager: StateManager):
|
||||
super().__init__()
|
||||
self.state_manager = state_manager
|
||||
self.dragging = False
|
||||
self.drag_position = QPoint()
|
||||
|
||||
self.init_ui()
|
||||
self.setup_event_listeners()
|
||||
|
||||
def init_ui(self):
|
||||
"""Initialize user interface"""
|
||||
# Window properties
|
||||
self.setWindowTitle("Desktop Waifu")
|
||||
self.setFixedSize(400, 600) # Adjust based on VRM model size
|
||||
|
||||
# Make window frameless and transparent
|
||||
self.setWindowFlags(
|
||||
Qt.WindowType.FramelessWindowHint |
|
||||
Qt.WindowType.WindowStaysOnTopHint |
|
||||
Qt.WindowType.Tool # Prevents taskbar icon
|
||||
)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
|
||||
# Create central widget
|
||||
central_widget = QWidget()
|
||||
self.setCentralWidget(central_widget)
|
||||
|
||||
# Layout
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
central_widget.setLayout(layout)
|
||||
|
||||
# VRM rendering widget
|
||||
self.vrm_widget = VRMWidget(self.state_manager)
|
||||
layout.addWidget(self.vrm_widget)
|
||||
|
||||
# Position window (bottom-right corner by default)
|
||||
self.position_window()
|
||||
|
||||
def position_window(self):
|
||||
"""Position window at bottom-right of screen"""
|
||||
from PyQt6.QtGui import QScreen
|
||||
screen: QScreen = self.screen()
|
||||
screen_geometry = screen.availableGeometry()
|
||||
|
||||
x = screen_geometry.width() - self.width() - 50
|
||||
y = screen_geometry.height() - self.height() - 50
|
||||
self.move(x, y)
|
||||
|
||||
def setup_event_listeners(self):
|
||||
"""Setup state manager event listeners"""
|
||||
self.state_manager.register_listener('emotion_change', self.on_emotion_change)
|
||||
|
||||
def on_emotion_change(self, old_emotion: EmotionState, new_emotion: EmotionState):
|
||||
"""Handle emotion state changes"""
|
||||
print(f"Emotion changed: {old_emotion.value} -> {new_emotion.value}")
|
||||
# VRM widget will handle animation changes
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
"""Handle mouse press - start dragging"""
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self.dragging = True
|
||||
self.drag_position = event.globalPosition().toPoint() - self.frameGeometry().topLeft()
|
||||
|
||||
# Play squeak sound
|
||||
# TODO: Implement sound effects
|
||||
self.state_manager.record_interaction()
|
||||
self.state_manager.set_emotion(EmotionState.SURPRISED)
|
||||
|
||||
event.accept()
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
"""Handle mouse move - dragging"""
|
||||
if self.dragging:
|
||||
self.move(event.globalPosition().toPoint() - self.drag_position)
|
||||
event.accept()
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
"""Handle mouse release - stop dragging"""
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self.dragging = False
|
||||
|
||||
# Return to neutral emotion after a moment
|
||||
# TODO: Add delay before returning to neutral
|
||||
self.state_manager.set_emotion(EmotionState.NEUTRAL)
|
||||
|
||||
event.accept()
|
||||
|
||||
def mouseDoubleClickEvent(self, event):
|
||||
"""Handle double-click - open chat"""
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
# TODO: Open chat interface
|
||||
print("Double-click detected - open chat")
|
||||
self.state_manager.record_interaction()
|
||||
event.accept()
|
Reference in New Issue
Block a user