yuy chat init

This commit is contained in:
aguitauwu
2026-02-06 21:29:55 -06:00
parent eecf0b9ac2
commit 0392a6b96d
19 changed files with 2257 additions and 112 deletions

9
yuy-chat-complete/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
/target
Cargo.lock
*.swp
*.swo
*~
.DS_Store
.vscode/
.idea/
*.log

View File

@@ -0,0 +1,59 @@
[package]
name = "yuy-chat"
version = "0.1.0"
edition = "2021"
authors = ["Yuuki Team"]
description = "Beautiful TUI chat interface for local AI models"
license = "MIT"
repository = "https://github.com/YuuKi-OS/yuy-chat"
[dependencies]
# TUI Framework
ratatui = "0.26"
crossterm = "0.27"
# Async runtime
tokio = { version = "1.40", features = ["full"] }
# HTTP client for HuggingFace API
reqwest = { version = "0.12", features = ["json"] }
# File system operations
walkdir = "2.4"
dirs = "5.0"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
# Error handling
anyhow = "1.0"
thiserror = "1.0"
# Date/time for conversations
chrono = { version = "0.4", features = ["serde"] }
# Logging
tracing = "0.1"
tracing-subscriber = "0.3"
# Terminal colors
colored = "2.1"
# Async process management
tokio-util = { version = "0.7", features = ["codec"] }
futures = "0.3"
# Command detection
which = "6.0"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
[[bin]]
name = "yuy-chat"
path = "src/main.rs"

180
yuy-chat-complete/README.md Normal file
View File

@@ -0,0 +1,180 @@
# yuy-chat
<div align="center">
```
$$\ $$\
\$$\ $$ |
\$$\ $$ /$$\ $$\ $$\ $$\
\$$$$ / $$ | $$ |$$ | $$ |
\$$ / $$ | $$ |$$ | $$ |
$$ | $$ | $$ |$$ | $$ |
$$ | \$$$$$$ |\$$$$$$$ |
\__| \______/ \____$$ |
$$\ $$ |
\$$$$$$ |
\______/
```
**Beautiful TUI chat interface for local AI models**
[![Rust](https://img.shields.io/badge/rust-1.70%2B-orange.svg)](https://www.rust-lang.org)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
</div>
---
## 🌟 Features
-**Beautiful TUI** - Gorgeous terminal interface powered by ratatui
- 🔍 **Auto-discovery** - Automatically finds `.gguf` and `.llamafile` models
- 🎨 **Presets** - Creative, Balanced, and Precise modes
- 💾 **Save conversations** - Keep your chat history
- 🌐 **HuggingFace API** - Use models from HuggingFace (optional)
-**Fast & Lightweight** - ~5MB binary, minimal dependencies
- 🚀 **Streaming responses** - See words appear as they're generated
- 🎯 **Zero configuration** - Just run and chat
## 📦 Installation
### From source:
```bash
git clone https://github.com/YuuKi-OS/yuy-chat
cd yuy-chat
cargo build --release
```
### Install globally:
```bash
cargo install --path .
```
## 🚀 Quick Start
```bash
# Run yuy-chat
yuy-chat
# It will auto-scan ~/.yuuki/models/ for .gguf and .llamafile files
# Select a model and start chatting!
```
## 📁 Supported Model Formats
-**GGUF** (`.gguf`) - Runs with llama.cpp
-**Llamafile** (`.llamafile`) - Self-contained executables
## 🎮 Controls
### Model Selector
- `↑/↓` or `j/k` - Navigate models
- `Enter` - Select model
- `R` - Refresh model list
- `Q` - Quit
### Chat
- `Type` - Write your message
- `Enter` - Send message
- `Shift+Enter` - New line
- `Ctrl+Enter` - Send (always)
- `Ctrl+C` - Open menu
- `Ctrl+L` - Clear chat
- `Ctrl+S` - Save conversation
- `↑/↓` - Scroll chat (when input is empty)
### Menu
- `1` - Change model
- `2` - Change preset
- `3` - Save conversation
- `4` - Load conversation
- `5` - Clear chat
- `6` - Settings
- `Q` - Back to chat
## ⚙️ Configuration
Config file location: `~/.config/yuy-chat/config.toml`
```toml
models_dir = "/home/user/.yuuki/models"
hf_token = "hf_xxxxxxxxxxxxx" # Optional
default_preset = "Balanced"
save_history = true
theme = "Dark"
```
## 🎯 Presets
- **Creative** (temp: 0.8, top_p: 0.9) - More random and creative
- **Balanced** (temp: 0.6, top_p: 0.7) - Good middle ground
- **Precise** (temp: 0.3, top_p: 0.5) - More focused and deterministic
## 🌐 HuggingFace Integration
Add your HuggingFace token in settings to use models via API:
1. Press `Ctrl+C``6` (Settings)
2. Edit `HuggingFace Token`
3. Paste your token from https://huggingface.co/settings/tokens
4. Save and refresh models
## 📚 Directory Structure
```
~/.config/yuy-chat/
├── config.toml # Configuration
└── conversations/ # Saved chats
├── conversation-20240206-143022.json
└── conversation-20240206-150133.json
```
## 🔧 Requirements
- **Rust 1.70+** (for building)
- **llama.cpp** (for .gguf models) - Install with: `yuy runtime install llama-cpp`
- **chmod +x** (for .llamafile models)
## 🤝 Integration with yuy
yuy-chat is designed to work alongside [yuy](https://github.com/YuuKi-OS/yuy):
```bash
# Download models with yuy
yuy download Yuuki-best
# Chat with yuy-chat
yuy-chat
```
## 🐛 Troubleshooting
**No models found?**
- Make sure you have models in `~/.yuuki/models/`
- Or specify custom directory: `yuy-chat --models-dir /path/to/models`
**llama.cpp not found?**
- Install with: `yuy runtime install llama-cpp`
- Or: `brew install llama.cpp` (macOS)
- Or: `pkg install llama-cpp` (Termux)
**Streaming not working?**
- Ensure llama.cpp is installed and in PATH
- Check model file permissions
## 📝 License
MIT License - see [LICENSE](LICENSE) file
## 🌸 Credits
Made with love by the Yuuki team
- TUI Framework: [ratatui](https://github.com/ratatui-org/ratatui)
- Inference: [llama.cpp](https://github.com/ggerganov/llama.cpp)
---
**For model management, see [yuy](https://github.com/YuuKi-OS/yuy)**

495
yuy-chat-complete/USAGE.md Normal file
View File

@@ -0,0 +1,495 @@
# yuy-chat - Guía de Uso Completa
## 📖 Contenido
1. [Instalación](#instalación)
2. [Primera Vez](#primera-vez)
3. [Uso Diario](#uso-diario)
4. [Configuración Avanzada](#configuración-avanzada)
5. [Integración con HuggingFace](#integración-con-huggingface)
6. [Tips y Trucos](#tips-y-trucos)
7. [Troubleshooting](#troubleshooting)
---
## 🔧 Instalación
### Termux (Android)
```bash
# Instalar Rust
pkg install rust
# Clonar y compilar
git clone https://github.com/YuuKi-OS/yuy-chat
cd yuy-chat
cargo build --release -j 1 # Usar 1 thread en Termux
# Instalar globalmente
cargo install --path .
```
### Linux/macOS
```bash
# Clonar y compilar
git clone https://github.com/YuuKi-OS/yuy-chat
cd yuy-chat
cargo build --release
# Instalar
cargo install --path .
```
### Windows
```bash
# Mismo proceso que Linux/macOS
git clone https://github.com/YuuKi-OS/yuy-chat
cd yuy-chat
cargo build --release
cargo install --path .
```
---
## 🎬 Primera Vez
### 1. Asegúrate de tener modelos
yuy-chat busca modelos en `~/.yuuki/models/` por defecto.
**Opción A: Usar yuy**
```bash
yuy download Yuuki-best
```
**Opción B: Copiar manualmente**
```bash
mkdir -p ~/.yuuki/models/
cp /path/to/your/model.gguf ~/.yuuki/models/
```
### 2. Instalar llama.cpp
**Termux:**
```bash
pkg install llama-cpp
```
**macOS:**
```bash
brew install llama.cpp
```
**Linux:**
```bash
# Descargar desde releases
wget https://github.com/ggerganov/llama.cpp/releases/...
chmod +x llama-cli
sudo mv llama-cli /usr/local/bin/
```
### 3. Ejecutar yuy-chat
```bash
yuy-chat
```
Verás el selector de modelos. Usa `↑/↓` para navegar y `Enter` para seleccionar.
---
## 💬 Uso Diario
### Flujo Básico
```
1. Ejecuta: yuy-chat
2. Selecciona modelo con ↑/↓ y Enter
3. Escribe tu mensaje
4. Presiona Enter para enviar
5. Yuuki responde (streaming)
6. Continúa la conversación
```
### Atajos de Teclado Útiles
**En chat:**
- `Enter` - Enviar mensaje
- `Shift+Enter` - Nueva línea (para mensajes multi-línea)
- `Ctrl+L` - Limpiar chat
- `Ctrl+S` - Guardar conversación
- `Ctrl+C` - Abrir menú
**Escribir código:**
```
You: Dame un ejemplo de código Python
[Shift+Enter para nueva línea]
def hello():
print("Hola")
[Shift+Enter]
hello()
[Ctrl+Enter para enviar]
```
### Cambiar Preset
```
1. Ctrl+C (abrir menú)
2. Presiona 2 (Change Preset)
Cicla entre: Creative → Balanced → Precise
```
**Cuándo usar cada preset:**
- **Creative**: Escribir historias, brainstorming, ideas
- **Balanced**: Uso general, conversación
- **Precise**: Código, matemáticas, datos exactos
---
## ⚙️ Configuración Avanzada
### Cambiar Directorio de Modelos
**Método 1: Configuración**
```bash
yuy-chat
Ctrl+C → 6 (Settings)
Editar "Models Directory"
```
**Método 2: Archivo config**
```bash
nano ~/.config/yuy-chat/config.toml
```
```toml
models_dir = "/custom/path/to/models"
```
### Personalizar Presets
Edita el código o usa parámetros de llama.cpp directamente:
```bash
# En models/runtime.rs, modifica:
pub fn temperature(&self) -> f32 {
match self {
Preset::Creative => 0.9, // Más aleatorio
// ...
}
}
```
### Tema Claro
```toml
theme = "Light"
```
---
## 🌐 Integración con HuggingFace
### 1. Obtener Token
1. Ve a https://huggingface.co/settings/tokens
2. Click "Create new token"
3. Tipo: "Read"
4. Copia el token
### 2. Configurar en yuy-chat
**Método A: UI**
```
Ctrl+C → 6 (Settings)
Navigate to "HuggingFace Token"
Enter → Pega tu token
```
**Método B: Config file**
```toml
hf_token = "hf_abcdefghijklmnopqrstuvwxyz1234567890"
```
### 3. Usar Modelos de HF
Después de configurar el token:
```
yuy-chat
[Verás modelos locales + modelos HF API]
> Yuuki-best.gguf (Local)
Yuuki-3.7.gguf (Local)
Yuuki-best (HF API) <-- Usa la API
```
**Ventajas:**
- No ocupa espacio local
- Siempre actualizado
- Acceso a modelos privados
**Desventajas:**
- Requiere internet
- Más lento que local
- Rate limits en plan gratis
---
## 💡 Tips y Trucos
### Guardar Conversaciones Importantes
```
Ctrl+S mientras chateas
→ Se guarda en ~/.config/yuy-chat/conversations/
```
### Cargar Conversación Anterior
```
Ctrl+C → 4 (Load Conversation)
↑/↓ para navegar
Enter para cargar
```
### Prompt Engineering
**Para mejores respuestas, sé específico:**
❌ Malo:
```
You: Explica Rust
```
✅ Bueno:
```
You: Explica el sistema de ownership en Rust con un ejemplo simple de borrowing. Quiero entender por qué evita memory leaks.
```
### Conversaciones Multi-paso
```
You: Vamos a diseñar una API REST
Yuuki: Claro, ¿qué tipo de API?
You: Para gestionar tareas tipo TODO
Yuuki: Perfecto, estos son los endpoints...
```
### Usar Presets Dinámicamente
- **Creative preset**: "Escribe un cuento de terror"
- **Precise preset**: "¿Cuál es la complejidad de quicksort?"
- **Balanced preset**: "Explícame cómo funciona Git"
---
## 🔧 Troubleshooting
### Error: "No models found"
**Solución:**
```bash
# Verifica que tienes modelos
ls ~/.yuuki/models/
# Si está vacío, descarga uno
yuy download Yuuki-best
# O especifica otro directorio
yuy-chat --models-dir /path/to/models
```
### Error: "llama.cpp binary not found"
**Solución:**
```bash
# Termux
pkg install llama-cpp
# macOS
brew install llama.cpp
# Linux - verifica que está en PATH
which llama-cli
# Si no, instala o agrega al PATH
export PATH=$PATH:/path/to/llama-cpp
```
### Error: "Permission denied" (llamafile)
**Solución:**
```bash
chmod +x ~/.yuuki/models/*.llamafile
```
### Chat no responde / se congela
**Diagnóstico:**
1. Verifica que llama.cpp funciona:
```bash
llama-cli -m ~/.yuuki/models/Yuuki-best.gguf -p "Hola"
```
2. Revisa logs:
```bash
RUST_LOG=debug yuy-chat
```
3. Reduce context size si es falta de RAM
### Respuestas muy lentas
**Causas comunes:**
- Modelo muy grande para tu RAM
- Cuantización muy alta (F32, Q8)
- Sin aceleración GPU
**Solución:**
```bash
# Descarga versión cuantizada más pequeña
yuy download Yuuki-best --quant q4_0
# Verifica RAM disponible
free -h # Linux
top # macOS/Linux
```
### No puedo escribir mensajes largos
El input box tiene límite visual pero **no de contenido**:
- Usa `Shift+Enter` para multi-línea
- Scroll automático después de 5 líneas
- O escribe en editor externo y pega
### HuggingFace API no funciona
**Verifica:**
```bash
# Test manual
curl https://api-inference.huggingface.co/models/OpceanAI/Yuuki-best \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"inputs": "test"}'
```
**Problemas comunes:**
- Token expirado → Genera nuevo
- Rate limit → Espera o upgrade plan
- Modelo privado → Verifica permisos
---
## 📊 Performance Tips
### Termux/Móvil
```bash
# Usa modelos pequeños
yuy download Yuuki-best --quant q4_0
# Preset Balanced o Precise
# Creative es más lento
```
### Desktop High-end
```bash
# Usa Q8 o F32 para mejor calidad
yuy download Yuuki-best --quant q8_0
# Habilita GPU en llama.cpp
llama-cli -m model.gguf -ngl 32 # 32 layers en GPU
```
---
## 🎓 Casos de Uso
### 1. Coding Assistant
```
Preset: Precise
You: Cómo implemento un servidor HTTP en Rust?
You: Muestra ejemplo con tokio
You: Agrega manejo de errores
You: Ahora agrega logging
```
### 2. Creative Writing
```
Preset: Creative
You: Escribe el inicio de una novela de ciencia ficción ambientada en Marte en el año 2157
You: Continúa describiendo al protagonista
You: ¿Qué conflicto enfrenta?
```
### 3. Learning/Study
```
Preset: Balanced
You: Explícame la diferencia entre mutex y semaphore
You: Dame un ejemplo de cuándo usar cada uno
You: ¿Qué pasa si no uso sincronización?
```
---
## 🚀 Workflow Recomendado
### Developer
```bash
# Mañana: Coding
yuy-chat # Preset: Precise
> Ayuda con bugs, arquitectura, código
# Tarde: Docs
yuy-chat # Preset: Balanced
> Escribir documentación, READMEs
# Noche: Ideas
yuy-chat # Preset: Creative
> Brainstorming features
```
### Writer
```bash
yuy-chat # Preset: Creative
> Generar ideas
> Escribir borradores
> Feedback de historias
```
### Estudiante
```bash
yuy-chat # Preset: Balanced
> Explicaciones de conceptos
> Resolver dudas
> Preparar exámenes
```
---
**¿Preguntas? Abre un issue en GitHub!**
🌸 Hecho con amor por el equipo Yuuki

View File

@@ -0,0 +1,227 @@
use crate::config::{Config, Preset};
use crate::conversation::{Conversation, Message};
use crate::models::{Model, ModelScanner, ModelRuntime};
use anyhow::Result;
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Debug, Clone, PartialEq)]
pub enum AppState {
ModelSelector,
Chat,
Menu,
Settings,
ConversationList,
}
pub struct App {
pub state: AppState,
pub config: Config,
// Models
pub models: Vec<Model>,
pub selected_model_idx: usize,
pub current_model: Option<Model>,
pub runtime: Option<Arc<Mutex<ModelRuntime>>>,
// Chat
pub conversation: Conversation,
pub input: String,
pub scroll_offset: usize,
pub is_streaming: bool,
// Conversations history
pub saved_conversations: Vec<String>,
pub selected_conversation_idx: usize,
// Settings
pub selected_setting_idx: usize,
// Preset
pub current_preset: Preset,
}
impl App {
pub async fn new() -> Result<Self> {
let config = Config::load()?;
let scanner = ModelScanner::new();
let models = scanner.scan_all(&config).await?;
let saved_conversations = Conversation::list_saved()?;
Ok(Self {
state: if models.is_empty() {
AppState::ModelSelector
} else {
AppState::ModelSelector
},
config,
models,
selected_model_idx: 0,
current_model: None,
runtime: None,
conversation: Conversation::new(),
input: String::new(),
scroll_offset: 0,
is_streaming: false,
saved_conversations,
selected_conversation_idx: 0,
selected_setting_idx: 0,
current_preset: Preset::Balanced,
})
}
pub fn previous_model(&mut self) {
if self.selected_model_idx > 0 {
self.selected_model_idx -= 1;
}
}
pub fn next_model(&mut self) {
if self.selected_model_idx < self.models.len().saturating_sub(1) {
self.selected_model_idx += 1;
}
}
pub async fn refresh_models(&mut self) -> Result<()> {
let scanner = ModelScanner::new();
self.models = scanner.scan_all(&self.config).await?;
self.selected_model_idx = 0;
Ok(())
}
pub async fn load_selected_model(&mut self) -> Result<()> {
if let Some(model) = self.models.get(self.selected_model_idx).cloned() {
let runtime = ModelRuntime::new(model.clone(), self.current_preset.clone()).await?;
self.current_model = Some(model);
self.runtime = Some(Arc::new(Mutex::new(runtime)));
self.state = AppState::Chat;
}
Ok(())
}
pub async fn send_message(&mut self) -> Result<()> {
if self.input.trim().is_empty() {
return Ok(());
}
let user_message = self.input.clone();
self.conversation.add_message(Message::user(user_message.clone()));
self.input.clear();
if let Some(runtime) = &self.runtime {
self.is_streaming = true;
let runtime = runtime.clone();
let user_msg = user_message.clone();
tokio::spawn(async move {
let mut rt = runtime.lock().await;
if let Err(e) = rt.generate(&user_msg).await {
tracing::error!("Error generating response: {:?}", e);
}
});
}
Ok(())
}
pub async fn poll_response(&mut self) -> Result<Option<String>> {
if let Some(runtime) = &self.runtime {
let mut rt = runtime.lock().await;
Ok(rt.poll_chunk().await?)
} else {
Ok(None)
}
}
pub fn handle_response_chunk(&mut self, chunk: String) {
if chunk == "[DONE]" {
self.is_streaming = false;
return;
}
if let Some(last_msg) = self.conversation.messages.last_mut() {
if last_msg.role == "assistant" {
last_msg.content.push_str(&chunk);
} else {
self.conversation.add_message(Message::assistant(chunk));
}
} else {
self.conversation.add_message(Message::assistant(chunk));
}
}
pub fn clear_chat(&mut self) {
self.conversation = Conversation::new();
self.scroll_offset = 0;
}
pub fn scroll_up(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
pub fn scroll_down(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_add(1);
}
pub fn cycle_preset(&mut self) {
self.current_preset = match self.current_preset {
Preset::Creative => Preset::Balanced,
Preset::Balanced => Preset::Precise,
Preset::Precise => Preset::Creative,
};
}
pub fn save_conversation(&mut self) -> Result<()> {
let filename = self.conversation.save()?;
self.saved_conversations.push(filename);
Ok(())
}
pub fn previous_conversation(&mut self) {
if self.selected_conversation_idx > 0 {
self.selected_conversation_idx -= 1;
}
}
pub fn next_conversation(&mut self) {
if self.selected_conversation_idx < self.saved_conversations.len().saturating_sub(1) {
self.selected_conversation_idx += 1;
}
}
pub fn load_selected_conversation(&mut self) -> Result<()> {
if let Some(filename) = self.saved_conversations.get(self.selected_conversation_idx) {
self.conversation = Conversation::load(filename)?;
}
Ok(())
}
pub fn delete_selected_conversation(&mut self) -> Result<()> {
if let Some(filename) = self.saved_conversations.get(self.selected_conversation_idx) {
Conversation::delete(filename)?;
self.saved_conversations.remove(self.selected_conversation_idx);
if self.selected_conversation_idx > 0 {
self.selected_conversation_idx -= 1;
}
}
Ok(())
}
pub fn previous_setting(&mut self) {
if self.selected_setting_idx > 0 {
self.selected_setting_idx -= 1;
}
}
pub fn next_setting(&mut self) {
if self.selected_setting_idx < 5 {
self.selected_setting_idx += 1;
}
}
pub fn edit_setting(&mut self) {
// Placeholder for setting editing
// Would open input dialog
}
}

View File

@@ -0,0 +1,112 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub models_dir: PathBuf,
pub hf_token: Option<String>,
pub default_preset: Preset,
pub save_history: bool,
pub theme: Theme,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum Preset {
Creative,
Balanced,
Precise,
}
impl Preset {
pub fn temperature(&self) -> f32 {
match self {
Preset::Creative => 0.8,
Preset::Balanced => 0.6,
Preset::Precise => 0.3,
}
}
pub fn top_p(&self) -> f32 {
match self {
Preset::Creative => 0.9,
Preset::Balanced => 0.7,
Preset::Precise => 0.5,
}
}
pub fn as_str(&self) -> &str {
match self {
Preset::Creative => "Creative",
Preset::Balanced => "Balanced",
Preset::Precise => "Precise",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Theme {
Dark,
Light,
}
impl Default for Config {
fn default() -> Self {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
Self {
models_dir: home.join(".yuuki").join("models"),
hf_token: None,
default_preset: Preset::Balanced,
save_history: true,
theme: Theme::Dark,
}
}
}
impl Config {
pub fn load() -> Result<Self> {
let config_path = Self::config_path()?;
if config_path.exists() {
let content = fs::read_to_string(&config_path)?;
let config: Config = toml::from_str(&content)?;
Ok(config)
} else {
let config = Config::default();
config.save()?;
Ok(config)
}
}
pub fn save(&self) -> Result<()> {
let config_path = Self::config_path()?;
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self)?;
fs::write(&config_path, content)?;
Ok(())
}
fn config_path() -> Result<PathBuf> {
let config_dir = dirs::config_dir()
.context("Could not find config directory")?
.join("yuy-chat");
fs::create_dir_all(&config_dir)?;
Ok(config_dir.join("config.toml"))
}
pub fn conversations_dir() -> Result<PathBuf> {
let config_dir = dirs::config_dir()
.context("Could not find config directory")?
.join("yuy-chat")
.join("conversations");
fs::create_dir_all(&config_dir)?;
Ok(config_dir)
}
}

View File

@@ -0,0 +1,120 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use crate::config::Config;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub role: String,
pub content: String,
pub timestamp: DateTime<Utc>,
}
impl Message {
pub fn user(content: String) -> Self {
Self {
role: "user".to_string(),
content,
timestamp: Utc::now(),
}
}
pub fn assistant(content: String) -> Self {
Self {
role: "assistant".to_string(),
content,
timestamp: Utc::now(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Conversation {
pub messages: Vec<Message>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl Conversation {
pub fn new() -> Self {
let now = Utc::now();
Self {
messages: Vec::new(),
created_at: now,
updated_at: now,
}
}
pub fn add_message(&mut self, message: Message) {
self.messages.push(message);
self.updated_at = Utc::now();
}
pub fn save(&self) -> Result<String> {
let conversations_dir = Config::conversations_dir()?;
let filename = format!("conversation-{}.json", self.created_at.format("%Y%m%d-%H%M%S"));
let path = conversations_dir.join(&filename);
let json = serde_json::to_string_pretty(self)?;
fs::write(&path, json)?;
Ok(filename)
}
pub fn load(filename: &str) -> Result<Self> {
let conversations_dir = Config::conversations_dir()?;
let path = conversations_dir.join(filename);
let content = fs::read_to_string(&path)?;
let conversation: Conversation = serde_json::from_str(&content)?;
Ok(conversation)
}
pub fn list_saved() -> Result<Vec<String>> {
let conversations_dir = Config::conversations_dir()?;
let mut conversations = Vec::new();
if conversations_dir.exists() {
for entry in fs::read_dir(&conversations_dir)? {
let entry = entry?;
if entry.path().extension().map_or(false, |e| e == "json") {
if let Some(filename) = entry.file_name().to_str() {
conversations.push(filename.to_string());
}
}
}
}
conversations.sort();
conversations.reverse(); // Most recent first
Ok(conversations)
}
pub fn delete(filename: &str) -> Result<()> {
let conversations_dir = Config::conversations_dir()?;
let path = conversations_dir.join(filename);
if path.exists() {
fs::remove_file(&path)?;
}
Ok(())
}
pub fn get_summary(&self) -> String {
if let Some(first_msg) = self.messages.first() {
let preview = first_msg.content.chars().take(50).collect::<String>();
if first_msg.content.len() > 50 {
format!("{}...", preview)
} else {
preview
}
} else {
"Empty conversation".to_string()
}
}
}

View File

@@ -0,0 +1,193 @@
mod app;
mod config;
mod conversation;
mod models;
mod ui;
use anyhow::Result;
use app::{App, AppState};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
Terminal,
};
use std::io;
use tracing_subscriber;
#[tokio::main]
async fn main() -> Result<()> {
// Initialize logging
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.init();
// Setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Create app
let mut app = App::new().await?;
// Run app
let res = run_app(&mut terminal, &mut app).await;
// Restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
eprintln!("Error: {:?}", err);
}
Ok(())
}
async fn run_app<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
app: &mut App,
) -> Result<()> {
loop {
terminal.draw(|f| ui::render(f, app))?;
if event::poll(std::time::Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
match app.state {
AppState::ModelSelector => {
match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Up | KeyCode::Char('k') => app.previous_model(),
KeyCode::Down | KeyCode::Char('j') => app.next_model(),
KeyCode::Enter => {
app.load_selected_model().await?;
}
KeyCode::Char('r') => {
app.refresh_models().await?;
}
_ => {}
}
}
AppState::Chat => {
match (key.modifiers, key.code) {
// Ctrl+C: Open menu
(KeyModifiers::CONTROL, KeyCode::Char('c')) => {
app.state = AppState::Menu;
}
// Ctrl+L: Clear chat
(KeyModifiers::CONTROL, KeyCode::Char('l')) => {
app.clear_chat();
}
// Ctrl+S: Save conversation
(KeyModifiers::CONTROL, KeyCode::Char('s')) => {
app.save_conversation()?;
}
// Enter: Send message
(_, KeyCode::Enter) if !key.modifiers.contains(KeyModifiers::SHIFT) => {
app.send_message().await?;
}
// Shift+Enter: New line
(KeyModifiers::SHIFT, KeyCode::Enter) => {
app.input.push('\n');
}
// Ctrl+Enter: Send (always)
(KeyModifiers::CONTROL, KeyCode::Enter) => {
app.send_message().await?;
}
// Backspace
(_, KeyCode::Backspace) => {
app.input.pop();
}
// Character input
(_, KeyCode::Char(c)) => {
app.input.push(c);
}
// Up arrow: Scroll chat up
(_, KeyCode::Up) if app.input.is_empty() => {
app.scroll_up();
}
// Down arrow: Scroll chat down
(_, KeyCode::Down) if app.input.is_empty() => {
app.scroll_down();
}
_ => {}
}
}
AppState::Menu => {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => {
app.state = AppState::Chat;
}
KeyCode::Char('1') => {
app.state = AppState::ModelSelector;
}
KeyCode::Char('2') => {
app.cycle_preset();
}
KeyCode::Char('3') => {
app.save_conversation()?;
app.state = AppState::Chat;
}
KeyCode::Char('4') => {
app.state = AppState::ConversationList;
}
KeyCode::Char('5') => {
app.clear_chat();
app.state = AppState::Chat;
}
KeyCode::Char('6') => {
app.state = AppState::Settings;
}
_ => {}
}
}
AppState::Settings => {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
app.state = AppState::Menu;
}
KeyCode::Up => app.previous_setting(),
KeyCode::Down => app.next_setting(),
KeyCode::Enter => app.edit_setting(),
_ => {}
}
}
AppState::ConversationList => {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
app.state = AppState::Menu;
}
KeyCode::Up => app.previous_conversation(),
KeyCode::Down => app.next_conversation(),
KeyCode::Enter => {
app.load_selected_conversation()?;
app.state = AppState::Chat;
}
KeyCode::Char('d') => {
app.delete_selected_conversation()?;
}
_ => {}
}
}
}
}
}
// Handle streaming responses
if app.is_streaming {
if let Some(response) = app.poll_response().await? {
app.handle_response_chunk(response);
}
}
}
}

View File

@@ -0,0 +1,70 @@
use anyhow::Result;
use reqwest::Client;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize)]
struct HFRequest {
inputs: String,
parameters: HFParameters,
}
#[derive(Debug, Serialize)]
struct HFParameters {
temperature: f32,
top_p: f32,
max_new_tokens: u32,
}
#[derive(Debug, Deserialize)]
struct HFResponse {
generated_text: String,
}
pub struct HuggingFaceAPI {
client: Client,
token: String,
model: String,
}
impl HuggingFaceAPI {
pub fn new(token: String, org: String, model: String) -> Self {
Self {
client: Client::new(),
token,
model: format!("{}/{}", org, model),
}
}
pub async fn generate(&self, prompt: &str, temperature: f32, top_p: f32) -> Result<String> {
let url = format!("https://api-inference.huggingface.co/models/{}", self.model);
let request = HFRequest {
inputs: prompt.to_string(),
parameters: HFParameters {
temperature,
top_p,
max_new_tokens: 512,
},
};
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.token))
.json(&request)
.send()
.await?;
if !response.status().is_success() {
anyhow::bail!("HuggingFace API error: {}", response.status());
}
let hf_response: Vec<HFResponse> = response.json().await?;
if let Some(first) = hf_response.first() {
Ok(first.generated_text.clone())
} else {
Ok(String::new())
}
}
}

View File

@@ -0,0 +1,7 @@
mod scanner;
mod runtime;
mod hf_api;
pub use scanner::{Model, ModelScanner, ModelSource};
pub use runtime::ModelRuntime;
pub use hf_api::HuggingFaceAPI;

View File

@@ -0,0 +1,146 @@
use super::{Model, ModelFormat, ModelSource};
use crate::config::Preset;
use anyhow::{Context, Result};
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::mpsc;
pub struct ModelRuntime {
model: Model,
preset: Preset,
process: Option<Child>,
response_rx: Option<mpsc::Receiver<String>>,
}
impl ModelRuntime {
pub async fn new(model: Model, preset: Preset) -> Result<Self> {
Ok(Self {
model,
preset,
process: None,
response_rx: None,
})
}
pub async fn generate(&mut self, prompt: &str) -> Result<()> {
match &self.model.source {
ModelSource::Local(_) => self.generate_local(prompt).await,
ModelSource::HuggingFace { .. } => self.generate_hf(prompt).await,
}
}
async fn generate_local(&mut self, prompt: &str) -> Result<()> {
let command = match self.model.format {
ModelFormat::GGUF => self.build_llama_cpp_command(prompt)?,
ModelFormat::Llamafile => self.build_llamafile_command(prompt)?,
};
let (tx, rx) = mpsc::channel(100);
self.response_rx = Some(rx);
let prompt_owned = prompt.to_string();
tokio::spawn(async move {
if let Ok(mut child) = command.spawn() {
if let Some(stdout) = child.stdout.take() {
let reader = BufReader::new(stdout);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
if tx.send(line).await.is_err() {
break;
}
}
}
let _ = child.wait().await;
let _ = tx.send("[DONE]".to_string()).await;
}
});
Ok(())
}
fn build_llama_cpp_command(&self, prompt: &str) -> Result<Command> {
let llama_cmd = self.find_llama_binary()?;
let mut cmd = Command::new(llama_cmd);
cmd.arg("-m")
.arg(&self.model.path)
.arg("--temp")
.arg(self.preset.temperature().to_string())
.arg("--top-p")
.arg(self.preset.top_p().to_string())
.arg("-c")
.arg("4096")
.arg("-p")
.arg(prompt)
.stdout(Stdio::piped())
.stderr(Stdio::null());
Ok(cmd)
}
fn build_llamafile_command(&self, prompt: &str) -> Result<Command> {
let mut cmd = Command::new(&self.model.path);
cmd.arg("--temp")
.arg(self.preset.temperature().to_string())
.arg("--top-p")
.arg(self.preset.top_p().to_string())
.arg("-c")
.arg("4096")
.arg("-p")
.arg(prompt)
.stdout(Stdio::piped())
.stderr(Stdio::null());
Ok(cmd)
}
fn find_llama_binary(&self) -> Result<String> {
for binary in &["llama-cli", "llama", "main"] {
if which::which(binary).is_ok() {
return Ok(binary.to_string());
}
}
anyhow::bail!("llama.cpp binary not found. Install with: yuy runtime install llama-cpp")
}
async fn generate_hf(&mut self, prompt: &str) -> Result<()> {
// Placeholder for HuggingFace API call
let (tx, rx) = mpsc::channel(100);
self.response_rx = Some(rx);
let prompt_owned = prompt.to_string();
tokio::spawn(async move {
// Simulated streaming response
let response = format!("Response to: {}", prompt_owned);
for word in response.split_whitespace() {
let _ = tx.send(format!("{} ", word)).await;
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
let _ = tx.send("[DONE]".to_string()).await;
});
Ok(())
}
pub async fn poll_chunk(&mut self) -> Result<Option<String>> {
if let Some(rx) = &mut self.response_rx {
Ok(rx.recv().await)
} else {
Ok(None)
}
}
}
impl Drop for ModelRuntime {
fn drop(&mut self) {
if let Some(mut process) = self.process.take() {
let _ = process.start_kill();
}
}
}

View File

@@ -0,0 +1,148 @@
use crate::config::Config;
use anyhow::Result;
use std::path::PathBuf;
use walkdir::WalkDir;
#[derive(Debug, Clone)]
pub enum ModelSource {
Local(PathBuf),
HuggingFace { org: String, model: String },
}
#[derive(Debug, Clone)]
pub struct Model {
pub name: String,
pub path: PathBuf,
pub source: ModelSource,
pub format: ModelFormat,
pub size: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ModelFormat {
GGUF,
Llamafile,
}
impl Model {
pub fn display_name(&self) -> String {
format!("{} ({}) [{}]",
self.name,
format_size(self.size),
match &self.source {
ModelSource::Local(_) => "Local",
ModelSource::HuggingFace { .. } => "HuggingFace API",
}
)
}
pub fn format_name(&self) -> &str {
match self.format {
ModelFormat::GGUF => "GGUF",
ModelFormat::Llamafile => "Llamafile",
}
}
}
pub struct ModelScanner;
impl ModelScanner {
pub fn new() -> Self {
Self
}
pub async fn scan_all(&self, config: &Config) -> Result<Vec<Model>> {
let mut models = Vec::new();
// Scan local models
models.extend(self.scan_local(&config.models_dir)?);
// Scan HuggingFace if token is available
if let Some(token) = &config.hf_token {
if let Ok(hf_models) = self.scan_huggingface(token).await {
models.extend(hf_models);
}
}
Ok(models)
}
fn scan_local(&self, models_dir: &PathBuf) -> Result<Vec<Model>> {
let mut models = Vec::new();
if !models_dir.exists() {
return Ok(models);
}
for entry in WalkDir::new(models_dir)
.max_depth(3)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() {
continue;
}
let extension = path.extension().and_then(|s| s.to_str());
let format = match extension {
Some("gguf") => ModelFormat::GGUF,
Some("llamafile") => ModelFormat::Llamafile,
_ => continue,
};
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Unknown")
.to_string();
let size = path.metadata().map(|m| m.len()).unwrap_or(0);
models.push(Model {
name,
path: path.to_path_buf(),
source: ModelSource::Local(path.to_path_buf()),
format,
size,
});
}
Ok(models)
}
async fn scan_huggingface(&self, _token: &str) -> Result<Vec<Model>> {
// Placeholder for HuggingFace API integration
// Would query API for available Yuuki models
let hf_models = vec![
Model {
name: "Yuuki-best (HF API)".to_string(),
path: PathBuf::from(""),
source: ModelSource::HuggingFace {
org: "OpceanAI".to_string(),
model: "Yuuki-best".to_string(),
},
format: ModelFormat::GGUF,
size: 0,
},
];
Ok(hf_models)
}
}
fn format_size(bytes: u64) -> String {
const GB: u64 = 1024 * 1024 * 1024;
const MB: u64 = 1024 * 1024;
if bytes >= GB {
format!("{:.2} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.2} MB", bytes as f64 / MB as f64)
} else {
format!("{} B", bytes)
}
}

View File

@@ -0,0 +1,132 @@
use crate::app::App;
use ratatui::{
backend::Backend,
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
Frame,
};
pub fn render<B: Backend>(f: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Min(0), // Messages
Constraint::Length(5), // Input
Constraint::Length(1), // Help
])
.split(f.area());
// Header
render_header(f, app, chunks[0]);
// Messages
render_messages(f, app, chunks[1]);
// Input box
render_input(f, app, chunks[2]);
// Help bar
render_help(f, chunks[3]);
}
fn render_header<B: Backend>(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let model_name = app
.current_model
.as_ref()
.map(|m| m.name.clone())
.unwrap_or_else(|| "No model".to_string());
let tokens = app.conversation.messages.iter()
.map(|m| m.content.len())
.sum::<usize>();
let header_text = format!(
"Model: {} | Preset: {} | Tokens: {}/4096 | Messages: {}",
model_name,
app.current_preset.as_str(),
tokens,
app.conversation.messages.len()
);
let header = Paragraph::new(header_text)
.style(Style::default().fg(Color::Cyan))
.alignment(Alignment::Left)
.block(
Block::default()
.borders(Borders::ALL)
.title("yuy-chat")
.style(Style::default().fg(Color::Magenta)),
);
f.render_widget(header, area);
}
fn render_messages<B: Backend>(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let mut lines = Vec::new();
for msg in &app.conversation.messages {
let (prefix, style) = if msg.role == "user" {
("You: ", Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD))
} else {
("Yuuki: ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))
};
lines.push(Line::from(vec![
Span::styled(prefix, style),
Span::raw(&msg.content),
]));
lines.push(Line::from(""));
}
// Add streaming indicator
if app.is_streaming {
lines.push(Line::from(vec![
Span::styled("Yuuki: ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::styled("●●●", Style::default().fg(Color::Yellow)),
]));
}
let messages = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL))
.wrap(Wrap { trim: false })
.scroll((app.scroll_offset as u16, 0));
f.render_widget(messages, area);
}
fn render_input<B: Backend>(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let input_lines: Vec<Line> = app
.input
.split('\n')
.map(|line| Line::from(line.to_string()))
.collect();
let input_widget = Paragraph::new(input_lines)
.style(Style::default().fg(Color::White))
.block(
Block::default()
.borders(Borders::ALL)
.title("Message")
.style(Style::default().fg(Color::Yellow)),
)
.wrap(Wrap { trim: false });
f.render_widget(input_widget, area);
// Set cursor position
f.set_cursor_position((
area.x + 1 + (app.input.len() as u16 % (area.width - 2)),
area.y + 1 + (app.input.len() as u16 / (area.width - 2)),
));
}
fn render_help<B: Backend>(f: &mut Frame, area: ratatui::layout::Rect) {
let help = Paragraph::new("Enter: Send | Shift+Enter: New line | Ctrl+C: Menu | Ctrl+L: Clear | Ctrl+S: Save")
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center);
f.render_widget(help, area);
}

View File

@@ -0,0 +1,67 @@
use crate::app::App;
use ratatui::{
backend::Backend,
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
pub fn render<B: Backend>(f: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(f.area());
// Title
let title = Paragraph::new("Saved Conversations")
.style(Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(title, chunks[0]);
// Conversation list
let items: Vec<ListItem> = if app.saved_conversations.is_empty() {
vec![ListItem::new(Line::from(Span::styled(
"No saved conversations",
Style::default().fg(Color::Gray),
)))]
} else {
app.saved_conversations
.iter()
.enumerate()
.map(|(i, filename)| {
let content = if i == app.selected_conversation_idx {
Line::from(vec![
Span::styled("> ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::styled(filename, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
])
} else {
Line::from(vec![
Span::raw(" "),
Span::raw(filename),
])
};
ListItem::new(content)
})
.collect()
};
let list = List::new(items)
.block(Block::default().borders(Borders::ALL));
f.render_widget(list, chunks[1]);
// Help
let help = Paragraph::new("↑/↓: Navigate | Enter: Load | D: Delete | Esc: Back")
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(help, chunks[2]);
}

View File

@@ -0,0 +1,73 @@
use crate::app::App;
use ratatui::{
backend::Backend,
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
pub fn render<B: Backend>(f: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(f.area());
// Title
let title = Paragraph::new("Menu")
.style(Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(title, chunks[0]);
// Menu items
let items = vec![
ListItem::new(Line::from(vec![
Span::styled("1. ", Style::default().fg(Color::Yellow)),
Span::raw("Change Model"),
])),
ListItem::new(Line::from(vec![
Span::styled("2. ", Style::default().fg(Color::Yellow)),
Span::raw(format!("Change Preset (Current: {})", app.current_preset.as_str())),
])),
ListItem::new(Line::from(vec![
Span::styled("3. ", Style::default().fg(Color::Yellow)),
Span::raw("Save Conversation"),
])),
ListItem::new(Line::from(vec![
Span::styled("4. ", Style::default().fg(Color::Yellow)),
Span::raw("Load Conversation"),
])),
ListItem::new(Line::from(vec![
Span::styled("5. ", Style::default().fg(Color::Yellow)),
Span::raw("Clear Chat"),
])),
ListItem::new(Line::from(vec![
Span::styled("6. ", Style::default().fg(Color::Yellow)),
Span::raw("Settings"),
])),
ListItem::new(""),
ListItem::new(Line::from(vec![
Span::styled("Q. ", Style::default().fg(Color::Red)),
Span::raw("Back to Chat"),
])),
];
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title("Options"));
f.render_widget(list, chunks[1]);
// Help
let help = Paragraph::new("Press number key or Q to go back")
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(help, chunks[2]);
}

View File

@@ -0,0 +1,22 @@
mod selector;
mod chat;
mod menu;
mod settings;
mod conversations;
use crate::app::{App, AppState};
use ratatui::{
backend::Backend,
layout::{Constraint, Direction, Layout},
Frame,
};
pub fn render<B: Backend>(f: &mut Frame, app: &App) {
match app.state {
AppState::ModelSelector => selector::render(f, app),
AppState::Chat => chat::render(f, app),
AppState::Menu => menu::render(f, app),
AppState::Settings => settings::render(f, app),
AppState::ConversationList => conversations::render(f, app),
}
}

View File

@@ -0,0 +1,72 @@
use crate::app::App;
use ratatui::{
backend::Backend,
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
pub fn render<B: Backend>(f: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(f.area());
// Title
let title = Paragraph::new("yuy-chat v0.1.0")
.style(Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(title, chunks[0]);
// Model list
let items: Vec<ListItem> = app
.models
.iter()
.enumerate()
.map(|(i, model)| {
let content = if i == app.selected_model_idx {
Line::from(vec![
Span::styled("> ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::styled(&model.name, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::raw(format!(" {} ", model.display_name())),
])
} else {
Line::from(vec![
Span::raw(" "),
Span::raw(&model.name),
Span::styled(format!(" {} ", model.display_name()), Style::default().fg(Color::Gray)),
])
};
ListItem::new(content)
})
.collect();
let list_widget = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title("📋 Select a Model")
.style(Style::default().fg(Color::Cyan)),
);
f.render_widget(list_widget, chunks[1]);
// Help
let help = if app.models.is_empty() {
Paragraph::new("⚠️ No models found | Download with: yuy download Yuuki-best | R: Refresh | Q: Quit")
} else {
Paragraph::new("↑/↓: Navigate | Enter: Select | R: Refresh | Q: Quit")
}
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(help, chunks[2]);
}

View File

@@ -0,0 +1,77 @@
use crate::app::App;
use ratatui::{
backend::Backend,
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
pub fn render<B: Backend>(f: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(f.area());
// Title
let title = Paragraph::new("Settings")
.style(Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(title, chunks[0]);
// Settings items
let items: Vec<ListItem> = vec![
create_setting_item(0, app, "Models Directory", &app.config.models_dir.display().to_string()),
create_setting_item(1, app, "HuggingFace Token",
if app.config.hf_token.is_some() { "hf_****..." } else { "Not set" }),
create_setting_item(2, app, "Default Preset", app.config.default_preset.as_str()),
create_setting_item(3, app, "Save History",
if app.config.save_history { "Enabled" } else { "Disabled" }),
create_setting_item(4, app, "Theme",
match app.config.theme {
crate::config::Theme::Dark => "Dark",
crate::config::Theme::Light => "Light",
}),
];
let list = List::new(items)
.block(Block::default().borders(Borders::ALL));
f.render_widget(list, chunks[1]);
// Help
let help = Paragraph::new("↑/↓: Navigate | Enter: Edit | Esc: Back")
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(help, chunks[2]);
}
fn create_setting_item(idx: usize, app: &App, label: &str, value: &str) -> ListItem {
let is_selected = idx == app.selected_setting_idx;
let line = if is_selected {
Line::from(vec![
Span::styled("> ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::styled(label, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::raw(": "),
Span::styled(value, Style::default().fg(Color::Cyan)),
])
} else {
Line::from(vec![
Span::raw(" "),
Span::raw(label),
Span::raw(": "),
Span::styled(value, Style::default().fg(Color::Gray)),
])
};
ListItem::new(line)
}