From 0392a6b96da989c2d9250a043d7680c127716b14 Mon Sep 17 00:00:00 2001 From: aguitauwu Date: Fri, 6 Feb 2026 21:29:55 -0600 Subject: [PATCH] yuy chat init --- LICENSE | 160 +++---- yuy-chat-complete/.gitignore | 9 + yuy-chat-complete/Cargo.toml | 59 +++ yuy-chat-complete/README.md | 180 ++++++++ yuy-chat-complete/USAGE.md | 495 ++++++++++++++++++++++ yuy-chat-complete/src/app.rs | 227 ++++++++++ yuy-chat-complete/src/config.rs | 112 +++++ yuy-chat-complete/src/conversation.rs | 120 ++++++ yuy-chat-complete/src/main.rs | 193 +++++++++ yuy-chat-complete/src/models/hf_api.rs | 70 +++ yuy-chat-complete/src/models/mod.rs | 7 + yuy-chat-complete/src/models/runtime.rs | 146 +++++++ yuy-chat-complete/src/models/scanner.rs | 148 +++++++ yuy-chat-complete/src/ui/chat.rs | 132 ++++++ yuy-chat-complete/src/ui/conversations.rs | 67 +++ yuy-chat-complete/src/ui/menu.rs | 73 ++++ yuy-chat-complete/src/ui/mod.rs | 22 + yuy-chat-complete/src/ui/selector.rs | 72 ++++ yuy-chat-complete/src/ui/settings.rs | 77 ++++ 19 files changed, 2257 insertions(+), 112 deletions(-) create mode 100644 yuy-chat-complete/.gitignore create mode 100644 yuy-chat-complete/Cargo.toml create mode 100644 yuy-chat-complete/README.md create mode 100644 yuy-chat-complete/USAGE.md create mode 100644 yuy-chat-complete/src/app.rs create mode 100644 yuy-chat-complete/src/config.rs create mode 100644 yuy-chat-complete/src/conversation.rs create mode 100644 yuy-chat-complete/src/main.rs create mode 100644 yuy-chat-complete/src/models/hf_api.rs create mode 100644 yuy-chat-complete/src/models/mod.rs create mode 100644 yuy-chat-complete/src/models/runtime.rs create mode 100644 yuy-chat-complete/src/models/scanner.rs create mode 100644 yuy-chat-complete/src/ui/chat.rs create mode 100644 yuy-chat-complete/src/ui/conversations.rs create mode 100644 yuy-chat-complete/src/ui/menu.rs create mode 100644 yuy-chat-complete/src/ui/mod.rs create mode 100644 yuy-chat-complete/src/ui/selector.rs create mode 100644 yuy-chat-complete/src/ui/settings.rs diff --git a/LICENSE b/LICENSE index 261eeb9..122ef87 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,9 @@ - Apache License +Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ +Copyright 2026 OpceanAI, Yuuki + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. @@ -63,130 +65,64 @@ on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. + 2. Grant of Copyright License. + Subject to the terms and conditions of this License, each Contributor + hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, + royalty-free, irrevocable copyright license to reproduce, prepare + Derivative Works of, publicly display, publicly perform, sublicense, + and distribute the Work and such Derivative Works in Source or Object + form. - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. + 3. Grant of Patent License. + Subject to the terms and conditions of this License, each Contributor + hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, + royalty-free, irrevocable (except as stated in this section) patent + license to make, have made, use, offer to sell, sell, import, and + otherwise transfer the Work, where such license applies only to those + patent claims licensable by such Contributor that are necessarily + infringed by their Contribution(s) alone or by combination of their + Contribution(s) with the Work to which such Contribution(s) was + submitted. If You institute patent litigation against any entity + alleging that the Work or a Contribution constitutes patent + infringement, then any patent licenses granted under this License + shall terminate as of the date such litigation is filed. - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: + 4. Redistribution. + You may reproduce and distribute copies of the Work or Derivative + Works thereof in any medium, with or without modifications, provided + that You meet the following conditions: - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and + (a) You must give recipients a copy of this License; and + (b) You must cause modified files to carry prominent notices stating + that You changed the files; and + (c) You must retain all copyright, patent, trademark, and attribution + notices; and + (d) Any NOTICE file must be included if present. - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and + 5. Submission of Contributions. + Unless You explicitly state otherwise, any Contribution submitted + shall be under the terms of this License. - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and + 6. Trademarks. + This License does not grant permission to use the trade names, + trademarks, or service marks of the Licensor. - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. + 7. Disclaimer of Warranty. + The Work is provided on an "AS IS" BASIS, WITHOUT WARRANTIES OR + CONDITIONS OF ANY KIND. - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. + 8. Limitation of Liability. + In no event shall any Contributor be liable for damages arising from + the use of the Work. - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. + 9. Accepting Warranty or Additional Liability. + You may offer support or warranty only on Your own behalf. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] + Copyright 2026 OpceanAI Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/yuy-chat-complete/.gitignore b/yuy-chat-complete/.gitignore new file mode 100644 index 0000000..80a8901 --- /dev/null +++ b/yuy-chat-complete/.gitignore @@ -0,0 +1,9 @@ +/target +Cargo.lock +*.swp +*.swo +*~ +.DS_Store +.vscode/ +.idea/ +*.log diff --git a/yuy-chat-complete/Cargo.toml b/yuy-chat-complete/Cargo.toml new file mode 100644 index 0000000..ddb528d --- /dev/null +++ b/yuy-chat-complete/Cargo.toml @@ -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" diff --git a/yuy-chat-complete/README.md b/yuy-chat-complete/README.md new file mode 100644 index 0000000..696ecd9 --- /dev/null +++ b/yuy-chat-complete/README.md @@ -0,0 +1,180 @@ +# yuy-chat + +
+ +``` +$$\ $$\ +\$$\ $$ | + \$$\ $$ /$$\ $$\ $$\ $$\ + \$$$$ / $$ | $$ |$$ | $$ | + \$$ / $$ | $$ |$$ | $$ | + $$ | $$ | $$ |$$ | $$ | + $$ | \$$$$$$ |\$$$$$$$ | + \__| \______/ \____$$ | + $$\ $$ | + \$$$$$$ | + \______/ +``` + +**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) + +
+ +--- + +## 🌟 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)** diff --git a/yuy-chat-complete/USAGE.md b/yuy-chat-complete/USAGE.md new file mode 100644 index 0000000..1f728e2 --- /dev/null +++ b/yuy-chat-complete/USAGE.md @@ -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 diff --git a/yuy-chat-complete/src/app.rs b/yuy-chat-complete/src/app.rs new file mode 100644 index 0000000..e4ee688 --- /dev/null +++ b/yuy-chat-complete/src/app.rs @@ -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, + pub selected_model_idx: usize, + pub current_model: Option, + pub runtime: Option>>, + + // Chat + pub conversation: Conversation, + pub input: String, + pub scroll_offset: usize, + pub is_streaming: bool, + + // Conversations history + pub saved_conversations: Vec, + pub selected_conversation_idx: usize, + + // Settings + pub selected_setting_idx: usize, + + // Preset + pub current_preset: Preset, +} + +impl App { + pub async fn new() -> Result { + 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> { + 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 + } +} diff --git a/yuy-chat-complete/src/config.rs b/yuy-chat-complete/src/config.rs new file mode 100644 index 0000000..a121669 --- /dev/null +++ b/yuy-chat-complete/src/config.rs @@ -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, + 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 { + 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 { + 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 { + 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) + } +} diff --git a/yuy-chat-complete/src/conversation.rs b/yuy-chat-complete/src/conversation.rs new file mode 100644 index 0000000..1af7328 --- /dev/null +++ b/yuy-chat-complete/src/conversation.rs @@ -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, +} + +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, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +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 { + 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 { + 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> { + 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::(); + if first_msg.content.len() > 50 { + format!("{}...", preview) + } else { + preview + } + } else { + "Empty conversation".to_string() + } + } +} diff --git a/yuy-chat-complete/src/main.rs b/yuy-chat-complete/src/main.rs new file mode 100644 index 0000000..e6f3dce --- /dev/null +++ b/yuy-chat-complete/src/main.rs @@ -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( + terminal: &mut Terminal, + 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); + } + } + } +} diff --git a/yuy-chat-complete/src/models/hf_api.rs b/yuy-chat-complete/src/models/hf_api.rs new file mode 100644 index 0000000..6777f7a --- /dev/null +++ b/yuy-chat-complete/src/models/hf_api.rs @@ -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 { + 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 = response.json().await?; + + if let Some(first) = hf_response.first() { + Ok(first.generated_text.clone()) + } else { + Ok(String::new()) + } + } +} diff --git a/yuy-chat-complete/src/models/mod.rs b/yuy-chat-complete/src/models/mod.rs new file mode 100644 index 0000000..8b791a4 --- /dev/null +++ b/yuy-chat-complete/src/models/mod.rs @@ -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; diff --git a/yuy-chat-complete/src/models/runtime.rs b/yuy-chat-complete/src/models/runtime.rs new file mode 100644 index 0000000..8fbae15 --- /dev/null +++ b/yuy-chat-complete/src/models/runtime.rs @@ -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, + response_rx: Option>, +} + +impl ModelRuntime { + pub async fn new(model: Model, preset: Preset) -> Result { + 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 { + 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 { + 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 { + 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> { + 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(); + } + } +} diff --git a/yuy-chat-complete/src/models/scanner.rs b/yuy-chat-complete/src/models/scanner.rs new file mode 100644 index 0000000..eea9568 --- /dev/null +++ b/yuy-chat-complete/src/models/scanner.rs @@ -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> { + 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> { + 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> { + // 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) + } +} diff --git a/yuy-chat-complete/src/ui/chat.rs b/yuy-chat-complete/src/ui/chat.rs new file mode 100644 index 0000000..07da3bc --- /dev/null +++ b/yuy-chat-complete/src/ui/chat.rs @@ -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(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(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::(); + + 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(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(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let input_lines: Vec = 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(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); +} diff --git a/yuy-chat-complete/src/ui/conversations.rs b/yuy-chat-complete/src/ui/conversations.rs new file mode 100644 index 0000000..7735001 --- /dev/null +++ b/yuy-chat-complete/src/ui/conversations.rs @@ -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(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 = 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]); +} diff --git a/yuy-chat-complete/src/ui/menu.rs b/yuy-chat-complete/src/ui/menu.rs new file mode 100644 index 0000000..4ce901d --- /dev/null +++ b/yuy-chat-complete/src/ui/menu.rs @@ -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(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]); +} diff --git a/yuy-chat-complete/src/ui/mod.rs b/yuy-chat-complete/src/ui/mod.rs new file mode 100644 index 0000000..734ff91 --- /dev/null +++ b/yuy-chat-complete/src/ui/mod.rs @@ -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(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), + } +} diff --git a/yuy-chat-complete/src/ui/selector.rs b/yuy-chat-complete/src/ui/selector.rs new file mode 100644 index 0000000..d67deb7 --- /dev/null +++ b/yuy-chat-complete/src/ui/selector.rs @@ -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(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 = 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]); +} diff --git a/yuy-chat-complete/src/ui/settings.rs b/yuy-chat-complete/src/ui/settings.rs new file mode 100644 index 0000000..9db1819 --- /dev/null +++ b/yuy-chat-complete/src/ui/settings.rs @@ -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(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 = 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) +}