Initial commit: Cosmo voice assistant
Полностью локальный голосовой ассистент на Python. Стек: - Wake word: openWakeWord (onnxruntime) - STT: RealtimeSTT + faster-whisper + Silero VAD (CUDA) - LLM-агент: smolagents ToolCallingAgent + Ollama qwen2.5:7b - TTS: Silero V4 (torch.hub) + sounddevice - Shell: Git Bash (Windows) / bash (macOS) Поддерживает Windows и macOS. Агент с памятью и tool calling — находит программы самостоятельно, запоминает пути, выполняет произвольные shell-команды. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
47
.gitignore
vendored
Normal file
47
.gitignore
vendored
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.eggs/
|
||||||
|
|
||||||
|
# Виртуальные окружения
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Логи
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Данные и модели (большие файлы)
|
||||||
|
data/
|
||||||
|
train_wakeword/docker_data/
|
||||||
|
train_wakeword/samples/
|
||||||
|
|
||||||
|
# Память ассистента (персональные данные)
|
||||||
|
data/memory.json
|
||||||
|
|
||||||
|
# Wake word модели (скачиваются/обучаются локально)
|
||||||
|
models/*.onnx
|
||||||
|
models/*.tflite
|
||||||
|
|
||||||
|
# Torch кэш
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
|
realtimesst.log
|
||||||
208
CLAUDE.md
Normal file
208
CLAUDE.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# Cosmo — локальный голосовой ассистент
|
||||||
|
|
||||||
|
Полностью локальный голосовой ассистент на Python. Не использует облачные API, всё работает на твоём железе.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
Микрофон → Wake Word → STT → LLM-агент → TTS → Динамики
|
||||||
|
↓ ↓ ↓
|
||||||
|
openWakeWord Whisper smolagents
|
||||||
|
(hey_jarvis) (cuda) + Ollama
|
||||||
|
↓
|
||||||
|
Инструменты:
|
||||||
|
run_shell, find_program,
|
||||||
|
open_browser, read/write_file,
|
||||||
|
memory_get/set
|
||||||
|
```
|
||||||
|
|
||||||
|
**Поток работы:**
|
||||||
|
1. `wake_word.py` — непрерывно слушает микрофон, детектирует слово-триггер
|
||||||
|
2. `transcriber.py` — записывает команду, транскрибирует через Whisper
|
||||||
|
3. `agent.py` — отправляет текст в Ollama, LLM вызывает инструменты в цикле пока не решит задачу
|
||||||
|
4. `tts.py` — озвучивает ответ через Silero
|
||||||
|
|
||||||
|
## Структура файлов
|
||||||
|
|
||||||
|
```
|
||||||
|
cosmo/
|
||||||
|
├── main.py — точка входа, класс Cosmo
|
||||||
|
├── wake_word.py — детект wake word (openWakeWord + onnxruntime)
|
||||||
|
├── transcriber.py — STT (RealtimeSTT + faster-whisper + Silero VAD)
|
||||||
|
├── agent.py — LLM-агент (smolagents ToolCallingAgent + Ollama)
|
||||||
|
├── tools.py — инструменты агента для Windows (Git Bash)
|
||||||
|
├── tools_mac.py — инструменты агента для macOS (нативный bash)
|
||||||
|
├── memory.py — персистентная память (data/memory.json)
|
||||||
|
└── tts.py — TTS (Silero V4 через torch.hub + sounddevice)
|
||||||
|
|
||||||
|
config/
|
||||||
|
├── config.yaml — настройки для Windows
|
||||||
|
└── config_mac.yaml — настройки для macOS (CPU, int8)
|
||||||
|
|
||||||
|
train_wakeword/
|
||||||
|
├── record_samples.py — запись голосовых примеров
|
||||||
|
├── cosmo_config.yaml — конфиг обучения wake word
|
||||||
|
├── Dockerfile — среда Python 3.11 для обучения
|
||||||
|
├── entrypoint.sh — шаги обучения внутри Docker
|
||||||
|
└── train.sh — главный скрипт обучения
|
||||||
|
|
||||||
|
data/
|
||||||
|
└── memory.json — долгосрочная память агента (создаётся автоматически)
|
||||||
|
|
||||||
|
models/
|
||||||
|
└── *.onnx — кастомные wake word модели (после обучения)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Железо (Windows машина)
|
||||||
|
|
||||||
|
- CPU: Intel i5-13400F (10 ядер / 16 потоков)
|
||||||
|
- RAM: 32 ГБ DDR5 6000
|
||||||
|
- GPU: RTX 4060 8 ГБ VRAM
|
||||||
|
- OS: Windows 11, Python 3.13
|
||||||
|
|
||||||
|
## Стек технологий
|
||||||
|
|
||||||
|
| Компонент | Технология | Версия |
|
||||||
|
|---|---|---|
|
||||||
|
| Wake word | openWakeWord + onnxruntime | 0.6.0 |
|
||||||
|
| STT | RealtimeSTT + faster-whisper | 0.3.104 / 1.1.1 |
|
||||||
|
| VAD | Silero VAD (внутри RealtimeSTT) | — |
|
||||||
|
| LLM | Ollama + qwen2.5:7b | ollama 0.6.1 |
|
||||||
|
| Agent | smolagents ToolCallingAgent | 1.11.0 |
|
||||||
|
| TTS | Silero V4 (torch.hub) + sounddevice | — |
|
||||||
|
| Shell | Git Bash (Windows) / bash (macOS) | — |
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
```bash
|
||||||
|
bash install.sh # первый раз
|
||||||
|
bash run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
```bash
|
||||||
|
bash install_mac.sh # первый раз
|
||||||
|
bash run_mac.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
При первом запуске автоматически скачаются:
|
||||||
|
- Whisper модель (~1.5 ГБ на Windows / ~150 МБ на Mac)
|
||||||
|
- Silero TTS модель (~38 МБ)
|
||||||
|
|
||||||
|
## Активация
|
||||||
|
|
||||||
|
Сейчас: говори **"Hey Jarvis"** — это fallback пока нет кастомной модели.
|
||||||
|
|
||||||
|
После обучения кастомной модели: говори **"Hey Cosmo"**.
|
||||||
|
|
||||||
|
После активации говори команду на русском: *"открой браузер"*, *"найди в гугле погоду"*, *"запусти WebStorm"*.
|
||||||
|
|
||||||
|
## Конфиг (config/config.yaml)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
whisper:
|
||||||
|
model_size: "distil-large-v3" # Windows GPU
|
||||||
|
# model_size: "small" # Mac CPU
|
||||||
|
device: "cuda" # "cpu" для Mac
|
||||||
|
compute_type: "float16" # "int8" для CPU
|
||||||
|
|
||||||
|
ollama:
|
||||||
|
model: "qwen2.5:7b" # модель должна быть скачана: ollama pull qwen2.5:7b
|
||||||
|
max_agent_steps: 10 # макс. шагов агента на одну команду
|
||||||
|
|
||||||
|
tts:
|
||||||
|
silero_speaker: "eugene" # голоса: xenia (ж), baya, aidar, eugene, kseniya
|
||||||
|
```
|
||||||
|
|
||||||
|
## Память агента
|
||||||
|
|
||||||
|
Агент автоматически запоминает информацию в `data/memory.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"facts": {
|
||||||
|
"app.webstorm": "C:/Program Files/JetBrains/WebStorm.../webstorm64.exe",
|
||||||
|
"user.name": "Даниил"
|
||||||
|
},
|
||||||
|
"history": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ключи памяти:**
|
||||||
|
- `app.<название>` — пути к программам
|
||||||
|
- `user.<поле>` — данные о пользователе
|
||||||
|
- `pref.<что>` — предпочтения
|
||||||
|
|
||||||
|
После первого поиска программы агент запомнит её путь и больше не будет искать.
|
||||||
|
|
||||||
|
## Инструменты агента
|
||||||
|
|
||||||
|
| Инструмент | Описание |
|
||||||
|
|---|---|
|
||||||
|
| `run_shell` | Выполнить bash команду |
|
||||||
|
| `find_program` | Найти программу (PATH, Program Files, реестр / Spotlight на Mac) |
|
||||||
|
| `open_browser` | Открыть URL или поиск Google |
|
||||||
|
| `read_file` | Прочитать файл |
|
||||||
|
| `write_file` | Записать файл |
|
||||||
|
| `memory_get` | Получить факт из памяти |
|
||||||
|
| `memory_set` | Сохранить факт в память |
|
||||||
|
| `memory_list` | Показать все факты |
|
||||||
|
|
||||||
|
## Обучение кастомной wake word "Hey Cosmo"
|
||||||
|
|
||||||
|
Требования: Docker Desktop, ~25 ГБ свободного места.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Опционально: запиши свой голос (30 примеров)
|
||||||
|
python train_wakeword/record_samples.py
|
||||||
|
|
||||||
|
# Запусти обучение (~1 час)
|
||||||
|
bash train_wakeword/train.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Датасет (~20 ГБ) скачается в `train_wakeword/docker_data/` и сохранится для повторного использования.
|
||||||
|
|
||||||
|
После обучения `.onnx` модель автоматически появится в `models/` и `wake_word.py` подхватит её.
|
||||||
|
|
||||||
|
## Известные ограничения
|
||||||
|
|
||||||
|
- Wake word только английский ("Hey Jarvis" / "Hey Cosmo") — openWakeWord не поддерживает русский TTS для обучения
|
||||||
|
- На Mac нет CUDA — Whisper работает на CPU, латентность выше (~2-3 сек вместо ~0.5 сек)
|
||||||
|
- smolagents требует модель с поддержкой tool calling — qwen2.5:7b, llama3.2, mistral v0.3+
|
||||||
|
|
||||||
|
## Добавление новых инструментов
|
||||||
|
|
||||||
|
В `cosmo/tools.py` (Windows) или `cosmo/tools_mac.py` (Mac):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@tool
|
||||||
|
def my_tool(param: str) -> str:
|
||||||
|
"""
|
||||||
|
Описание что делает инструмент — LLM читает это.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
param: описание параметра
|
||||||
|
"""
|
||||||
|
# реализация
|
||||||
|
return "результат"
|
||||||
|
```
|
||||||
|
|
||||||
|
Добавь в список `ALL_TOOLS` в конце файла — агент автоматически получит доступ.
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
Логи пишутся в `logs/cosmo.log`. Уровень логирования меняется в конфиге (`logging.level: DEBUG`).
|
||||||
|
|
||||||
|
Для тестирования агента без голоса:
|
||||||
|
```bash
|
||||||
|
python -c "
|
||||||
|
import yaml, sys
|
||||||
|
sys.path.insert(0, '.')
|
||||||
|
from cosmo.memory import Memory
|
||||||
|
from cosmo.agent import Agent
|
||||||
|
config = yaml.safe_load(open('config/config.yaml'))
|
||||||
|
agent = Agent(config, Memory())
|
||||||
|
print(agent.run('открой браузер'))
|
||||||
|
"
|
||||||
|
```
|
||||||
29
config/config.yaml
Normal file
29
config/config.yaml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
assistant:
|
||||||
|
name: Cosmo
|
||||||
|
wake_word: "cosmo"
|
||||||
|
|
||||||
|
audio:
|
||||||
|
sample_rate: 16000
|
||||||
|
silence_duration: 1.0 # секунд тишины = конец команды
|
||||||
|
|
||||||
|
whisper:
|
||||||
|
model_size: "distil-large-v3" # быстрее large-v3, почти такая же точность
|
||||||
|
device: "cuda"
|
||||||
|
compute_type: "float16"
|
||||||
|
language: "ru"
|
||||||
|
|
||||||
|
ollama:
|
||||||
|
base_url: "http://localhost:11434"
|
||||||
|
model: "qwen2.5:7b"
|
||||||
|
temperature: 0.2
|
||||||
|
max_tokens: 1024
|
||||||
|
max_agent_steps: 10
|
||||||
|
|
||||||
|
tts:
|
||||||
|
enabled: true
|
||||||
|
silero_speaker: "eugene" # xenia (женский) или baya, aidar, eugene, kseniya, random
|
||||||
|
sample_rate: 48000
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: "INFO"
|
||||||
|
file: "logs/cosmo.log"
|
||||||
29
config/config_mac.yaml
Normal file
29
config/config_mac.yaml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
assistant:
|
||||||
|
name: Cosmo
|
||||||
|
wake_word: "cosmo"
|
||||||
|
|
||||||
|
audio:
|
||||||
|
sample_rate: 16000
|
||||||
|
silence_duration: 1.0
|
||||||
|
|
||||||
|
whisper:
|
||||||
|
model_size: "small" # На Mac без GPU — small быстрее чем distil-large
|
||||||
|
device: "cpu" # Mac Intel/Apple Silicon — CPU (MPS пока не стабилен в faster-whisper)
|
||||||
|
compute_type: "int8" # int8 быстрее на CPU
|
||||||
|
language: "ru"
|
||||||
|
|
||||||
|
ollama:
|
||||||
|
base_url: "http://localhost:11434"
|
||||||
|
model: "qwen2.5:7b"
|
||||||
|
temperature: 0.2
|
||||||
|
max_tokens: 1024
|
||||||
|
max_agent_steps: 10
|
||||||
|
|
||||||
|
tts:
|
||||||
|
enabled: true
|
||||||
|
silero_speaker: "eugene" # xenia (женский) baya aidar eugene kseniya
|
||||||
|
sample_rate: 48000
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: "INFO"
|
||||||
|
file: "logs/cosmo.log"
|
||||||
0
cosmo/__init__.py
Normal file
0
cosmo/__init__.py
Normal file
89
cosmo/agent.py
Normal file
89
cosmo/agent.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""
|
||||||
|
Агент на базе smolagents + Ollama.
|
||||||
|
Использует ToolCallingAgent — LLM вызывает инструменты через JSON tool calling.
|
||||||
|
Продолжает работу пока задача не решена (до max_steps).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
from loguru import logger
|
||||||
|
from smolagents import ToolCallingAgent, LiteLLMModel
|
||||||
|
|
||||||
|
from cosmo.memory import Memory
|
||||||
|
|
||||||
|
# Выбираем инструменты под текущую платформу
|
||||||
|
if platform.system() == "Darwin" or os.environ.get("COSMO_PLATFORM") == "mac":
|
||||||
|
from cosmo.tools_mac import ALL_TOOLS, set_memory
|
||||||
|
_PLATFORM_NOTE = "macOS. Используй bash, 'open -a AppName' для запуска приложений, mdfind для поиска файлов."
|
||||||
|
else:
|
||||||
|
from cosmo.tools import ALL_TOOLS, set_memory
|
||||||
|
_PLATFORM_NOTE = "Windows. Используй Git Bash, 'start' для запуска приложений."
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = f"""Ты — Cosmo, умный голосовой ассистент. Платформа: {_PLATFORM_NOTE}
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
1. Используй инструменты для выполнения задач — не выдумывай результаты
|
||||||
|
2. Если первая попытка не сработала — пробуй другой подход, не сдавайся
|
||||||
|
3. Перед поиском программы — проверь память (memory_get), может путь уже известен
|
||||||
|
4. Если нашёл путь к программе — сохрани в память (memory_set) чтобы не искать повторно
|
||||||
|
5. Отвечай коротко на русском языке — пользователь слушает голосом, не читает
|
||||||
|
|
||||||
|
Факты из памяти о пользователе и системе:
|
||||||
|
{memory_facts}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Agent:
|
||||||
|
def __init__(self, config: dict, memory: Memory):
|
||||||
|
self.memory = memory
|
||||||
|
self._cfg = config["ollama"]
|
||||||
|
|
||||||
|
# Передаём память в инструменты
|
||||||
|
set_memory(memory)
|
||||||
|
|
||||||
|
model_id = f"ollama/{self._cfg['model']}"
|
||||||
|
logger.info(f"Инициализирую smolagents с моделью {model_id}")
|
||||||
|
|
||||||
|
self._model = LiteLLMModel(
|
||||||
|
model_id=model_id,
|
||||||
|
api_base=self._cfg["base_url"],
|
||||||
|
temperature=self._cfg.get("temperature", 0.2),
|
||||||
|
max_tokens=self._cfg.get("max_tokens", 1024),
|
||||||
|
)
|
||||||
|
|
||||||
|
self._agent = ToolCallingAgent(
|
||||||
|
tools=ALL_TOOLS,
|
||||||
|
model=self._model,
|
||||||
|
max_steps=self._cfg.get("max_agent_steps", 10),
|
||||||
|
verbosity_level=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Агент готов")
|
||||||
|
|
||||||
|
def run(self, user_input: str) -> str:
|
||||||
|
"""
|
||||||
|
Обработать команду пользователя.
|
||||||
|
Возвращает финальный текст ответа для TTS.
|
||||||
|
"""
|
||||||
|
logger.info(f"Агент: '{user_input}'")
|
||||||
|
|
||||||
|
# Сохраняем в историю
|
||||||
|
self.memory.add_message("user", user_input)
|
||||||
|
|
||||||
|
# Формируем промпт с текущей памятью
|
||||||
|
system = SYSTEM_PROMPT.format(memory_facts=self.memory.facts_as_text())
|
||||||
|
|
||||||
|
# smolagents принимает задачу и опциональный системный промпт
|
||||||
|
try:
|
||||||
|
result = self._agent.run(
|
||||||
|
user_input,
|
||||||
|
additional_args={"system_prompt_override": system},
|
||||||
|
)
|
||||||
|
response = str(result).strip() if result else "Готово"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка агента: {e}")
|
||||||
|
response = "Произошла ошибка при выполнении команды"
|
||||||
|
|
||||||
|
self.memory.add_message("assistant", response)
|
||||||
|
logger.info(f"Агент ответил: '{response}'")
|
||||||
|
return response
|
||||||
144
cosmo/main.py
Normal file
144
cosmo/main.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"""
|
||||||
|
Cosmo — локальный голосовой ассистент.
|
||||||
|
Стек: openWakeWord → RealtimeSTT (Whisper + Silero VAD) → smolagents + Ollama → RealtimeTTS (Silero)
|
||||||
|
|
||||||
|
Запуск:
|
||||||
|
bash run.sh
|
||||||
|
python cosmo/main.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import argparse
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
# Указываем pydub где искать ffmpeg (установлен через imageio-ffmpeg)
|
||||||
|
try:
|
||||||
|
import imageio_ffmpeg
|
||||||
|
from pydub import AudioSegment
|
||||||
|
AudioSegment.converter = imageio_ffmpeg.get_ffmpeg_exe()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from cosmo.wake_word import WakeWordDetector
|
||||||
|
from cosmo.transcriber import Transcriber
|
||||||
|
from cosmo.memory import Memory
|
||||||
|
from cosmo.agent import Agent
|
||||||
|
from cosmo.tts import TTS
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: str) -> dict:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(config: dict):
|
||||||
|
log_cfg = config.get("logging", {})
|
||||||
|
level = log_cfg.get("level", "INFO")
|
||||||
|
log_file = log_cfg.get("file", "logs/cosmo.log")
|
||||||
|
os.makedirs(os.path.dirname(log_file), exist_ok=True)
|
||||||
|
logger.remove()
|
||||||
|
logger.add(
|
||||||
|
sys.stderr, level=level, colorize=True,
|
||||||
|
format="<green>{time:HH:mm:ss}</green> | <level>{level: <8}</level> | {message}",
|
||||||
|
)
|
||||||
|
logger.add(log_file, level="DEBUG", rotation="10 MB", retention="7 days", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class Cosmo:
|
||||||
|
def __init__(self, config: dict):
|
||||||
|
self.config = config
|
||||||
|
self.name = config["assistant"]["name"]
|
||||||
|
self._running = False
|
||||||
|
self._command_event = threading.Event()
|
||||||
|
|
||||||
|
logger.info("Инициализирую модули...")
|
||||||
|
|
||||||
|
self.tts = TTS(config)
|
||||||
|
self.transcriber = Transcriber(config)
|
||||||
|
self.memory = Memory()
|
||||||
|
self.agent = Agent(config, self.memory)
|
||||||
|
self.wake_word = WakeWordDetector(config, on_detected_callback=self._on_wake_word)
|
||||||
|
|
||||||
|
def _on_wake_word(self):
|
||||||
|
logger.info(f"=== {self.name} активирован! ===")
|
||||||
|
self._command_event.set()
|
||||||
|
|
||||||
|
def _process_command(self):
|
||||||
|
self.tts.say_async("Слушаю")
|
||||||
|
|
||||||
|
# Partial results — печатаем в лог что слышим в реальном времени
|
||||||
|
def on_partial(text):
|
||||||
|
logger.debug(f"[partial] {text}")
|
||||||
|
|
||||||
|
text = self.transcriber.record_and_transcribe(on_partial=on_partial)
|
||||||
|
|
||||||
|
if not text.strip():
|
||||||
|
self.tts.say_async("Не расслышал, попробуй ещё раз")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Агент обрабатывает команду и возвращает ответ для TTS
|
||||||
|
response = self.agent.run(text)
|
||||||
|
self.tts.say(response)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self._running = True
|
||||||
|
self.wake_word.start()
|
||||||
|
|
||||||
|
logger.info("=" * 50)
|
||||||
|
logger.info(f" {self.name} запущен!")
|
||||||
|
logger.info(f" Скажи '{self.name}' чтобы активировать.")
|
||||||
|
logger.info(f" Ctrl+C для выхода.")
|
||||||
|
logger.info("=" * 50)
|
||||||
|
|
||||||
|
self.tts.say(f"{self.name} запущен")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while self._running:
|
||||||
|
triggered = self._command_event.wait(timeout=0.5)
|
||||||
|
if triggered and self._running:
|
||||||
|
self._command_event.clear()
|
||||||
|
try:
|
||||||
|
self._process_command()
|
||||||
|
finally:
|
||||||
|
self.wake_word.resume()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Ctrl+C — завершаю...")
|
||||||
|
finally:
|
||||||
|
self.shutdown()
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
logger.info("Завершаю работу...")
|
||||||
|
self._running = False
|
||||||
|
self.wake_word.stop()
|
||||||
|
self.transcriber.shutdown()
|
||||||
|
logger.info(f"{self.name} остановлен")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Cosmo — голосовой ассистент")
|
||||||
|
parser.add_argument("--config", default="config/config.yaml")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
config_path = args.config
|
||||||
|
if not os.path.isabs(config_path):
|
||||||
|
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
config_path = os.path.join(project_root, config_path)
|
||||||
|
|
||||||
|
if not os.path.exists(config_path):
|
||||||
|
print(f"Конфиг не найден: {config_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
config = load_config(config_path)
|
||||||
|
setup_logging(config)
|
||||||
|
Cosmo(config).run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
136
cosmo/memory.py
Normal file
136
cosmo/memory.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""
|
||||||
|
Персистентная память ассистента.
|
||||||
|
Хранится в data/memory.json — LLM читает и пишет её через инструменты.
|
||||||
|
|
||||||
|
Структура:
|
||||||
|
{
|
||||||
|
"facts": {
|
||||||
|
"user.name": "Даниил",
|
||||||
|
"app.webstorm": "C:/Program Files/JetBrains/WebStorm.../webstorm64.exe",
|
||||||
|
"user.browser": "chrome",
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"history": [
|
||||||
|
{"role": "user", "content": "..."},
|
||||||
|
{"role": "assistant", "content": "..."}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
MEMORY_PATH = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"data", "memory.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Memory:
|
||||||
|
def __init__(self, path: str = MEMORY_PATH, history_limit: int = 20):
|
||||||
|
self.path = path
|
||||||
|
self.history_limit = history_limit
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._data = {"facts": {}, "history": []}
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Персистентность
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _load(self):
|
||||||
|
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
||||||
|
if os.path.exists(self.path):
|
||||||
|
try:
|
||||||
|
with open(self.path, "r", encoding="utf-8") as f:
|
||||||
|
self._data = json.load(f)
|
||||||
|
logger.info(
|
||||||
|
f"Память загружена: {len(self._data.get('facts', {}))} фактов, "
|
||||||
|
f"{len(self._data.get('history', []))} сообщений в истории"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Не удалось загрузить память: {e}. Начинаю с чистой.")
|
||||||
|
self._data = {"facts": {}, "history": []}
|
||||||
|
else:
|
||||||
|
logger.info("Файл памяти не найден — создаю новый")
|
||||||
|
|
||||||
|
def _save(self):
|
||||||
|
try:
|
||||||
|
with open(self.path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(self._data, f, ensure_ascii=False, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Не удалось сохранить память: {e}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Факты (key-value долгосрочная память)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get(self, key: str) -> str | None:
|
||||||
|
"""Получить факт по ключу. Возвращает None если не найден."""
|
||||||
|
with self._lock:
|
||||||
|
return self._data["facts"].get(key)
|
||||||
|
|
||||||
|
def set(self, key: str, value: str):
|
||||||
|
"""Сохранить факт. Перезаписывает если уже существует."""
|
||||||
|
with self._lock:
|
||||||
|
self._data["facts"][key] = value
|
||||||
|
self._save()
|
||||||
|
logger.debug(f"Память: сохранено [{key}] = {value!r}")
|
||||||
|
|
||||||
|
def delete(self, key: str) -> bool:
|
||||||
|
"""Удалить факт. Возвращает True если был."""
|
||||||
|
with self._lock:
|
||||||
|
existed = key in self._data["facts"]
|
||||||
|
if existed:
|
||||||
|
del self._data["facts"][key]
|
||||||
|
self._save()
|
||||||
|
return existed
|
||||||
|
|
||||||
|
def list_facts(self, prefix: str = "") -> dict:
|
||||||
|
"""Вернуть все факты, опционально отфильтрованные по префиксу."""
|
||||||
|
with self._lock:
|
||||||
|
facts = self._data["facts"]
|
||||||
|
if prefix:
|
||||||
|
return {k: v for k, v in facts.items() if k.startswith(prefix)}
|
||||||
|
return dict(facts)
|
||||||
|
|
||||||
|
def facts_as_text(self) -> str:
|
||||||
|
"""Все факты в виде читаемого текста для системного промпта."""
|
||||||
|
facts = self.list_facts()
|
||||||
|
if not facts:
|
||||||
|
return "Память пуста."
|
||||||
|
lines = [f" {k}: {v}" for k, v in sorted(facts.items())]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# История разговора (краткосрочная, последние N сообщений)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def add_message(self, role: str, content: str):
|
||||||
|
"""Добавить сообщение в историю (role: user/assistant/tool)."""
|
||||||
|
with self._lock:
|
||||||
|
self._data["history"].append({
|
||||||
|
"role": role,
|
||||||
|
"content": content,
|
||||||
|
"ts": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
})
|
||||||
|
# Ограничиваем длину истории
|
||||||
|
if len(self._data["history"]) > self.history_limit:
|
||||||
|
self._data["history"] = self._data["history"][-self.history_limit:]
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def get_history(self) -> list[dict]:
|
||||||
|
"""Вернуть историю в формате для LLM API (без поля ts)."""
|
||||||
|
with self._lock:
|
||||||
|
return [
|
||||||
|
{"role": m["role"], "content": m["content"]}
|
||||||
|
for m in self._data["history"]
|
||||||
|
]
|
||||||
|
|
||||||
|
def clear_history(self):
|
||||||
|
with self._lock:
|
||||||
|
self._data["history"] = []
|
||||||
|
self._save()
|
||||||
232
cosmo/tools.py
Normal file
232
cosmo/tools.py
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
"""
|
||||||
|
Инструменты агента для smolagents.
|
||||||
|
Каждый инструмент — функция с декоратором @tool.
|
||||||
|
smolagents автоматически генерирует схему из docstring и type hints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import webbrowser
|
||||||
|
import urllib.parse
|
||||||
|
from loguru import logger
|
||||||
|
from smolagents import tool
|
||||||
|
|
||||||
|
from cosmo.memory import Memory
|
||||||
|
|
||||||
|
# Глобальная ссылка на память — устанавливается из agent.py
|
||||||
|
_memory: Memory | None = None
|
||||||
|
|
||||||
|
def set_memory(mem: Memory):
|
||||||
|
global _memory
|
||||||
|
_memory = mem
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Поиск Git Bash
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
_GIT_BASH_CANDIDATES = [
|
||||||
|
"C:/Program Files/Git/bin/bash.exe",
|
||||||
|
"C:/Program Files (x86)/Git/bin/bash.exe",
|
||||||
|
os.path.expandvars("%LOCALAPPDATA%/Programs/Git/bin/bash.exe"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _find_git_bash() -> str:
|
||||||
|
for path in _GIT_BASH_CANDIDATES:
|
||||||
|
if os.path.exists(path):
|
||||||
|
return path
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["where", "bash"], capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
first = result.stdout.strip().splitlines()[0]
|
||||||
|
if "git" in first.lower():
|
||||||
|
return first
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "bash"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Инструменты
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def run_shell(command: str) -> str:
|
||||||
|
"""
|
||||||
|
Выполнить команду в Git Bash и получить вывод.
|
||||||
|
Используй bash-синтаксис. Для запуска Windows-программ: start '' '/c/path/app.exe'
|
||||||
|
или cmd //c start. Для поиска программ: which, find, cmd //c where.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: bash команда для выполнения
|
||||||
|
"""
|
||||||
|
bash = _find_git_bash()
|
||||||
|
logger.info(f"[run_shell] {command}")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[bash, "-c", command],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=20,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
output = result.stdout.strip()
|
||||||
|
stderr = result.stderr.strip()
|
||||||
|
if result.returncode != 0 and stderr:
|
||||||
|
return f"[returncode={result.returncode}]\nstdout: {output}\nstderr: {stderr}"
|
||||||
|
return output if output else f"[выполнено, returncode={result.returncode}]"
|
||||||
|
except FileNotFoundError:
|
||||||
|
return "[ошибка: Git Bash не найден]"
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return "[таймаут 20с]"
|
||||||
|
except Exception as e:
|
||||||
|
return f"[ошибка: {e}]"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def find_program(name: str) -> str:
|
||||||
|
"""
|
||||||
|
Найти программу или файл на Windows по имени.
|
||||||
|
Ищет в PATH, Program Files, AppData и реестре.
|
||||||
|
Используй когда не знаешь точный путь к программе.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: имя программы без расширения, например 'webstorm' или 'chrome'
|
||||||
|
"""
|
||||||
|
stem = name.lower().strip().replace(".exe", "")
|
||||||
|
logger.info(f"[find_program] {stem}")
|
||||||
|
|
||||||
|
steps = [
|
||||||
|
f"which {stem} 2>/dev/null",
|
||||||
|
f"cmd //c where {stem} 2>/dev/null",
|
||||||
|
f"find '/c/Program Files' -iname '{stem}*.exe' 2>/dev/null | head -3",
|
||||||
|
f"find '/c/Program Files (x86)' -iname '{stem}*.exe' 2>/dev/null | head -3",
|
||||||
|
f"find \"$LOCALAPPDATA\" -iname '{stem}*.exe' 2>/dev/null | head -3",
|
||||||
|
f"cmd //c 'reg query \"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\{stem}.exe\" /ve 2>nul'",
|
||||||
|
]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for cmd in steps:
|
||||||
|
out = run_shell(cmd)
|
||||||
|
if out and not out.startswith("[") and len(out) > 3:
|
||||||
|
results.append(out.strip())
|
||||||
|
|
||||||
|
if results:
|
||||||
|
return "Найдено:\n" + "\n".join(results)
|
||||||
|
return f"Программа '{name}' не найдена. Попробуй другое имя."
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def open_browser(url: str, search: bool = False) -> str:
|
||||||
|
"""
|
||||||
|
Открыть URL в браузере или выполнить поиск в Google.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: полный URL (https://...) или поисковый запрос если search=True
|
||||||
|
search: если True — выполнить поиск Google по тексту в url
|
||||||
|
"""
|
||||||
|
if search:
|
||||||
|
url = "https://www.google.com/search?q=" + urllib.parse.quote(url)
|
||||||
|
elif not url.startswith(("http://", "https://")):
|
||||||
|
url = "https://" + url
|
||||||
|
webbrowser.open(url)
|
||||||
|
logger.info(f"[open_browser] {url}")
|
||||||
|
return f"Открыт браузер: {url}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def read_file(path: str) -> str:
|
||||||
|
"""
|
||||||
|
Прочитать содержимое текстового файла.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: абсолютный путь к файлу
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
return f.read(8000)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return f"Файл не найден: {path}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Ошибка чтения: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def write_file(path: str, content: str) -> str:
|
||||||
|
"""
|
||||||
|
Записать текст в файл (создаёт или перезаписывает).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: абсолютный путь к файлу
|
||||||
|
content: содержимое файла
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
dir_path = os.path.dirname(path)
|
||||||
|
if dir_path:
|
||||||
|
os.makedirs(dir_path, exist_ok=True)
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
return f"Файл записан: {path}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Ошибка записи: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def memory_set(key: str, value: str) -> str:
|
||||||
|
"""
|
||||||
|
Сохранить факт в долгосрочную память ассистента.
|
||||||
|
Используй для запоминания путей программ, предпочтений пользователя, часто используемых команд.
|
||||||
|
Ключи: 'app.название', 'user.имя', 'pref.что-то'.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: ключ факта, например 'app.webstorm' или 'user.name'
|
||||||
|
value: значение для сохранения
|
||||||
|
"""
|
||||||
|
if _memory is None:
|
||||||
|
return "Память не инициализирована"
|
||||||
|
_memory.set(key, value)
|
||||||
|
return f"Запомнено: {key} = {value!r}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def memory_get(key: str) -> str:
|
||||||
|
"""
|
||||||
|
Получить сохранённый факт из долгосрочной памяти по ключу.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: ключ факта, например 'app.webstorm'
|
||||||
|
"""
|
||||||
|
if _memory is None:
|
||||||
|
return "Память не инициализирована"
|
||||||
|
value = _memory.get(key)
|
||||||
|
return f"{key} = {value!r}" if value else f"Факт '{key}' не найден"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def memory_list(prefix: str = "") -> str:
|
||||||
|
"""
|
||||||
|
Показать все факты из памяти, опционально по префиксу.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prefix: префикс для фильтрации, например 'app.' чтобы видеть пути программ
|
||||||
|
"""
|
||||||
|
if _memory is None:
|
||||||
|
return "Память не инициализирована"
|
||||||
|
facts = _memory.list_facts(prefix)
|
||||||
|
if not facts:
|
||||||
|
return "Память пуста" if not prefix else f"Нет фактов с префиксом '{prefix}'"
|
||||||
|
return "\n".join(f"{k}: {v}" for k, v in sorted(facts.items()))
|
||||||
|
|
||||||
|
|
||||||
|
# Список всех инструментов для передачи в агент
|
||||||
|
ALL_TOOLS = [
|
||||||
|
run_shell,
|
||||||
|
find_program,
|
||||||
|
open_browser,
|
||||||
|
read_file,
|
||||||
|
write_file,
|
||||||
|
memory_set,
|
||||||
|
memory_get,
|
||||||
|
memory_list,
|
||||||
|
]
|
||||||
206
cosmo/tools_mac.py
Normal file
206
cosmo/tools_mac.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
"""
|
||||||
|
Инструменты агента для smolagents — macOS версия.
|
||||||
|
Отличия от Windows: нативный bash, поиск через mdfind/Spotlight,
|
||||||
|
запуск приложений через 'open -a', нет реестра.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import webbrowser
|
||||||
|
import urllib.parse
|
||||||
|
from loguru import logger
|
||||||
|
from smolagents import tool
|
||||||
|
|
||||||
|
from cosmo.memory import Memory
|
||||||
|
|
||||||
|
_memory: Memory | None = None
|
||||||
|
|
||||||
|
def set_memory(mem: Memory):
|
||||||
|
global _memory
|
||||||
|
_memory = mem
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Инструменты
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def run_shell(command: str) -> str:
|
||||||
|
"""
|
||||||
|
Выполнить команду в bash и получить вывод.
|
||||||
|
На macOS доступны все стандартные unix-команды.
|
||||||
|
Для запуска приложений используй: open -a "AppName" или open /path/to/app.
|
||||||
|
Для поиска файлов: mdfind, find, which.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: bash команда для выполнения
|
||||||
|
"""
|
||||||
|
logger.info(f"[run_shell] {command}")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["bash", "-c", command],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=20,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
output = result.stdout.strip()
|
||||||
|
stderr = result.stderr.strip()
|
||||||
|
if result.returncode != 0 and stderr:
|
||||||
|
return f"[returncode={result.returncode}]\nstdout: {output}\nstderr: {stderr}"
|
||||||
|
return output if output else f"[выполнено, returncode={result.returncode}]"
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return "[таймаут 20с]"
|
||||||
|
except Exception as e:
|
||||||
|
return f"[ошибка: {e}]"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def find_program(name: str) -> str:
|
||||||
|
"""
|
||||||
|
Найти программу или приложение на macOS по имени.
|
||||||
|
Ищет в PATH, /Applications, ~/Applications и через Spotlight (mdfind).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: имя программы, например 'webstorm', 'chrome', 'cursor'
|
||||||
|
"""
|
||||||
|
stem = name.strip()
|
||||||
|
logger.info(f"[find_program] {stem}")
|
||||||
|
|
||||||
|
steps = [
|
||||||
|
# which — ищет в PATH
|
||||||
|
f"which {stem} 2>/dev/null",
|
||||||
|
# Spotlight — самый надёжный способ найти .app
|
||||||
|
f"mdfind 'kMDItemKind == \"Application\"' | grep -i '{stem}' | head -3",
|
||||||
|
# /Applications напрямую
|
||||||
|
f"find /Applications -maxdepth 2 -iname '*{stem}*.app' 2>/dev/null | head -3",
|
||||||
|
# ~/Applications
|
||||||
|
f"find ~/Applications -maxdepth 2 -iname '*{stem}*.app' 2>/dev/null | head -3",
|
||||||
|
# Homebrew bin
|
||||||
|
f"find /opt/homebrew/bin /usr/local/bin -iname '*{stem}*' 2>/dev/null | head -3",
|
||||||
|
]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for cmd in steps:
|
||||||
|
out = run_shell(cmd)
|
||||||
|
if out and not out.startswith("[") and len(out) > 3:
|
||||||
|
results.append(out.strip())
|
||||||
|
|
||||||
|
if results:
|
||||||
|
return "Найдено:\n" + "\n".join(results)
|
||||||
|
return f"Программа '{name}' не найдена."
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def open_browser(url: str, search: bool = False) -> str:
|
||||||
|
"""
|
||||||
|
Открыть URL в браузере или выполнить поиск в Google.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: полный URL (https://...) или поисковый запрос если search=True
|
||||||
|
search: если True — выполнить поиск Google по тексту в url
|
||||||
|
"""
|
||||||
|
if search:
|
||||||
|
url = "https://www.google.com/search?q=" + urllib.parse.quote(url)
|
||||||
|
elif not url.startswith(("http://", "https://")):
|
||||||
|
url = "https://" + url
|
||||||
|
webbrowser.open(url)
|
||||||
|
logger.info(f"[open_browser] {url}")
|
||||||
|
return f"Открыт браузер: {url}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def read_file(path: str) -> str:
|
||||||
|
"""
|
||||||
|
Прочитать содержимое текстового файла.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: абсолютный путь к файлу
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
return f.read(8000)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return f"Файл не найден: {path}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Ошибка чтения: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def write_file(path: str, content: str) -> str:
|
||||||
|
"""
|
||||||
|
Записать текст в файл (создаёт или перезаписывает).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: абсолютный путь к файлу
|
||||||
|
content: содержимое файла
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
dir_path = os.path.dirname(path)
|
||||||
|
if dir_path:
|
||||||
|
os.makedirs(dir_path, exist_ok=True)
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
return f"Файл записан: {path}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Ошибка записи: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def memory_set(key: str, value: str) -> str:
|
||||||
|
"""
|
||||||
|
Сохранить факт в долгосрочную память ассистента.
|
||||||
|
Используй для запоминания путей программ, предпочтений пользователя.
|
||||||
|
Ключи: 'app.название', 'user.имя', 'pref.что-то'.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: ключ факта, например 'app.webstorm'
|
||||||
|
value: значение для сохранения
|
||||||
|
"""
|
||||||
|
if _memory is None:
|
||||||
|
return "Память не инициализирована"
|
||||||
|
_memory.set(key, value)
|
||||||
|
return f"Запомнено: {key} = {value!r}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def memory_get(key: str) -> str:
|
||||||
|
"""
|
||||||
|
Получить сохранённый факт из долгосрочной памяти по ключу.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: ключ факта, например 'app.webstorm'
|
||||||
|
"""
|
||||||
|
if _memory is None:
|
||||||
|
return "Память не инициализирована"
|
||||||
|
value = _memory.get(key)
|
||||||
|
return f"{key} = {value!r}" if value else f"Факт '{key}' не найден"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def memory_list(prefix: str = "") -> str:
|
||||||
|
"""
|
||||||
|
Показать все факты из памяти, опционально по префиксу.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prefix: префикс для фильтрации, например 'app.'
|
||||||
|
"""
|
||||||
|
if _memory is None:
|
||||||
|
return "Память не инициализирована"
|
||||||
|
facts = _memory.list_facts(prefix)
|
||||||
|
if not facts:
|
||||||
|
return "Память пуста" if not prefix else f"Нет фактов с префиксом '{prefix}'"
|
||||||
|
return "\n".join(f"{k}: {v}" for k, v in sorted(facts.items()))
|
||||||
|
|
||||||
|
|
||||||
|
ALL_TOOLS = [
|
||||||
|
run_shell,
|
||||||
|
find_program,
|
||||||
|
open_browser,
|
||||||
|
read_file,
|
||||||
|
write_file,
|
||||||
|
memory_set,
|
||||||
|
memory_get,
|
||||||
|
memory_list,
|
||||||
|
]
|
||||||
87
cosmo/transcriber.py
Normal file
87
cosmo/transcriber.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""
|
||||||
|
STT модуль на базе RealtimeSTT.
|
||||||
|
Использует faster-whisper + Silero VAD под капотом.
|
||||||
|
Поддерживает стриминг — partial transcriptions во время речи.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from RealtimeSTT import AudioToTextRecorder
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
class Transcriber:
|
||||||
|
def __init__(self, config: dict):
|
||||||
|
whisper_cfg = config["whisper"]
|
||||||
|
audio_cfg = config["audio"]
|
||||||
|
|
||||||
|
self._recorder: AudioToTextRecorder | None = None
|
||||||
|
self._config = {
|
||||||
|
"model": whisper_cfg["model_size"],
|
||||||
|
"language": whisper_cfg["language"],
|
||||||
|
"device": whisper_cfg["device"],
|
||||||
|
"compute_type": whisper_cfg["compute_type"],
|
||||||
|
# Silero VAD параметры
|
||||||
|
"silero_sensitivity": 0.4,
|
||||||
|
"webrtc_sensitivity": 3,
|
||||||
|
"post_speech_silence_duration": audio_cfg["silence_duration"],
|
||||||
|
"min_length_of_recording": 0.3,
|
||||||
|
"min_gap_between_recordings": 0.01,
|
||||||
|
# Отключаем wake word в RealtimeSTT — используем свой
|
||||||
|
"wakeword_backend": "none",
|
||||||
|
# Не запускать в режиме непрерывного прослушивания
|
||||||
|
"use_microphone": True,
|
||||||
|
"spinner": False,
|
||||||
|
"level": 0, # минимальный лог уровень внутри RealtimeSTT
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Инициализирую RealtimeSTT: модель={whisper_cfg['model_size']}, "
|
||||||
|
f"device={whisper_cfg['device']}, compute={whisper_cfg['compute_type']}"
|
||||||
|
)
|
||||||
|
self._init_recorder()
|
||||||
|
|
||||||
|
def _init_recorder(self):
|
||||||
|
try:
|
||||||
|
self._recorder = AudioToTextRecorder(**self._config)
|
||||||
|
logger.info("RealtimeSTT готов")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка инициализации RealtimeSTT: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def record_and_transcribe(self, on_partial: callable = None) -> str:
|
||||||
|
"""
|
||||||
|
Записывает команду и транскрибирует.
|
||||||
|
on_partial(text) — опциональный колбэк для частичных результатов.
|
||||||
|
Возвращает финальный текст.
|
||||||
|
"""
|
||||||
|
if self._recorder is None:
|
||||||
|
self._init_recorder()
|
||||||
|
|
||||||
|
result_holder = []
|
||||||
|
done_event = threading.Event()
|
||||||
|
|
||||||
|
def on_text(text: str):
|
||||||
|
result_holder.append(text)
|
||||||
|
done_event.set()
|
||||||
|
|
||||||
|
# Partial results — показываем что слышим в реальном времени
|
||||||
|
if on_partial:
|
||||||
|
self._recorder.on_realtime_transcription_update = on_partial
|
||||||
|
|
||||||
|
logger.info("Слушаю команду...")
|
||||||
|
self._recorder.text(on_text)
|
||||||
|
done_event.wait(timeout=12.0)
|
||||||
|
|
||||||
|
text = result_holder[0].strip() if result_holder else ""
|
||||||
|
if text:
|
||||||
|
logger.info(f"Транскрипция: '{text}'")
|
||||||
|
else:
|
||||||
|
logger.info("Команда не распознана (тишина или таймаут)")
|
||||||
|
return text
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
if self._recorder:
|
||||||
|
try:
|
||||||
|
self._recorder.shutdown()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
79
cosmo/tts.py
Normal file
79
cosmo/tts.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""
|
||||||
|
TTS модуль на базе Silero V4 (torch.hub) + sounddevice.
|
||||||
|
Silero — лучший русскоязычный офлайн TTS.
|
||||||
|
Модель скачивается автоматически при первом запуске (~50 MB).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import numpy as np
|
||||||
|
import sounddevice as sd
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
TORCH_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
TORCH_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
|
class TTS:
|
||||||
|
def __init__(self, config: dict):
|
||||||
|
tts_cfg = config.get("tts", {})
|
||||||
|
self.enabled = tts_cfg.get("enabled", True)
|
||||||
|
self.speaker = tts_cfg.get("silero_speaker", "xenia")
|
||||||
|
self.sample_rate = tts_cfg.get("sample_rate", 48000)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._model = None
|
||||||
|
|
||||||
|
if not self.enabled:
|
||||||
|
return
|
||||||
|
if not TORCH_AVAILABLE:
|
||||||
|
logger.warning("torch не установлен — TTS отключён")
|
||||||
|
self.enabled = False
|
||||||
|
return
|
||||||
|
|
||||||
|
self._load_model()
|
||||||
|
|
||||||
|
def _load_model(self):
|
||||||
|
try:
|
||||||
|
logger.info(f"Загружаю Silero TTS (голос: {self.speaker}, {self.sample_rate} Hz)...")
|
||||||
|
# torch.hub кэширует модель в ~/.cache/torch/hub
|
||||||
|
model, _ = torch.hub.load(
|
||||||
|
repo_or_dir="snakers4/silero-models",
|
||||||
|
model="silero_tts",
|
||||||
|
language="ru",
|
||||||
|
speaker="v4_ru",
|
||||||
|
trust_repo=True,
|
||||||
|
)
|
||||||
|
self._model = model
|
||||||
|
logger.info("Silero TTS готов")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка загрузки Silero TTS: {e}")
|
||||||
|
logger.warning("TTS отключён")
|
||||||
|
self.enabled = False
|
||||||
|
|
||||||
|
def say(self, text: str):
|
||||||
|
"""Произнести текст синхронно."""
|
||||||
|
if not self.enabled or self._model is None:
|
||||||
|
logger.info(f"[TTS]: {text}")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug(f"TTS: '{text}'")
|
||||||
|
with self._lock:
|
||||||
|
try:
|
||||||
|
with torch.no_grad():
|
||||||
|
audio = self._model.apply_tts(
|
||||||
|
text=text,
|
||||||
|
speaker=self.speaker,
|
||||||
|
sample_rate=self.sample_rate,
|
||||||
|
)
|
||||||
|
audio_np = audio.numpy() if hasattr(audio, "numpy") else np.array(audio)
|
||||||
|
sd.play(audio_np, samplerate=self.sample_rate)
|
||||||
|
sd.wait()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка TTS: {e}")
|
||||||
|
|
||||||
|
def say_async(self, text: str):
|
||||||
|
"""Произнести текст асинхронно."""
|
||||||
|
t = threading.Thread(target=self.say, args=(text,), daemon=True)
|
||||||
|
t.start()
|
||||||
139
cosmo/wake_word.py
Normal file
139
cosmo/wake_word.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""
|
||||||
|
Wake word detector для Cosmo.
|
||||||
|
Слушает микрофон непрерывно, детектирует слово "cosmo" через openwakeword.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import glob
|
||||||
|
import threading
|
||||||
|
import queue
|
||||||
|
import numpy as np
|
||||||
|
import sounddevice as sd
|
||||||
|
from openwakeword.model import Model
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
class WakeWordDetector:
|
||||||
|
def __init__(self, config: dict, on_detected_callback):
|
||||||
|
"""
|
||||||
|
config: секция audio + assistant из config.yaml
|
||||||
|
on_detected_callback: вызывается без аргументов при детекте wake word
|
||||||
|
"""
|
||||||
|
self.sample_rate = config["audio"].get("sample_rate", 16000)
|
||||||
|
self.chunk_duration = config["audio"].get("chunk_duration", 0.08)
|
||||||
|
self.chunk_size = int(self.sample_rate * self.chunk_duration)
|
||||||
|
self.wake_word = config["assistant"]["wake_word"]
|
||||||
|
self.on_detected = on_detected_callback
|
||||||
|
|
||||||
|
self._audio_queue = queue.Queue()
|
||||||
|
self._running = False
|
||||||
|
self._paused = False
|
||||||
|
self._thread = None
|
||||||
|
|
||||||
|
# Порог уверенности для срабатывания (0.0 – 1.0)
|
||||||
|
self.threshold = 0.5
|
||||||
|
|
||||||
|
logger.info("Загружаю wake word модель openwakeword...")
|
||||||
|
|
||||||
|
# Ищем кастомную модель в папке models/
|
||||||
|
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
custom_models = glob.glob(os.path.join(project_root, "models", "*.onnx"))
|
||||||
|
|
||||||
|
if custom_models:
|
||||||
|
model_path = custom_models[0]
|
||||||
|
model_name = os.path.basename(model_path)
|
||||||
|
try:
|
||||||
|
self.model = Model(
|
||||||
|
wakeword_models=[model_path],
|
||||||
|
inference_framework="onnx",
|
||||||
|
)
|
||||||
|
logger.info(f"Кастомная wake word модель загружена: {model_name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Не удалось загрузить кастомную модель {model_name}: {e}")
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
# Fallback на встроенную hey_jarvis
|
||||||
|
try:
|
||||||
|
self.model = Model(
|
||||||
|
wakeword_models=["hey_jarvis"],
|
||||||
|
inference_framework="onnx",
|
||||||
|
)
|
||||||
|
logger.info("Wake word модель 'hey_jarvis' загружена (onnx)")
|
||||||
|
logger.warning(
|
||||||
|
"Кастомная модель не найдена в папке models/. "
|
||||||
|
"Активация по слову 'Hey Jarvis'. "
|
||||||
|
"Запусти train_wakeword/train.sh чтобы обучить модель на 'Hey Cosmo'."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Не удалось загрузить wake word модель: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Публичный интерфейс
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Запустить детектор в фоновом потоке."""
|
||||||
|
self._running = True
|
||||||
|
self._thread = threading.Thread(target=self._run, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
logger.info(f"Wake word детектор запущен. Жду слово '{self.wake_word}'...")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._running = False
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=2)
|
||||||
|
|
||||||
|
def pause(self):
|
||||||
|
"""Приостановить детект (пока идёт запись команды)."""
|
||||||
|
self._paused = True
|
||||||
|
|
||||||
|
def resume(self):
|
||||||
|
"""Возобновить детект после записи команды."""
|
||||||
|
# Очищаем очередь, чтобы не срабатывать на эхо
|
||||||
|
while not self._audio_queue.empty():
|
||||||
|
try:
|
||||||
|
self._audio_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
self._paused = False
|
||||||
|
logger.debug("Wake word детектор возобновлён")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Внутренняя логика
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _audio_callback(self, indata, frames, time_info, status):
|
||||||
|
if status:
|
||||||
|
logger.debug(f"sounddevice статус: {status}")
|
||||||
|
if not self._paused:
|
||||||
|
# Копируем данные, т.к. буфер перезаписывается
|
||||||
|
self._audio_queue.put(indata[:, 0].copy())
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
with sd.InputStream(
|
||||||
|
samplerate=self.sample_rate,
|
||||||
|
channels=1,
|
||||||
|
dtype="float32",
|
||||||
|
blocksize=self.chunk_size,
|
||||||
|
callback=self._audio_callback,
|
||||||
|
):
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
chunk = self._audio_queue.get(timeout=0.5)
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# openwakeword ожидает int16 PCM
|
||||||
|
chunk_int16 = (chunk * 32767).astype(np.int16)
|
||||||
|
prediction = self.model.predict(chunk_int16)
|
||||||
|
|
||||||
|
for model_name, score in prediction.items():
|
||||||
|
if score >= self.threshold:
|
||||||
|
logger.info(
|
||||||
|
f"Wake word задетектирован! Модель={model_name}, "
|
||||||
|
f"уверенность={score:.2f}"
|
||||||
|
)
|
||||||
|
self.pause()
|
||||||
|
self.on_detected()
|
||||||
|
break
|
||||||
35
install.sh
Normal file
35
install.sh
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "============================================"
|
||||||
|
echo " Установка зависимостей Cosmo"
|
||||||
|
echo "============================================"
|
||||||
|
|
||||||
|
# Проверяем наличие Python
|
||||||
|
if ! command -v python &>/dev/null; then
|
||||||
|
echo "ОШИБКА: Python не найден. Установи Python 3.10+"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[1/4] Обновляю pip..."
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
|
||||||
|
echo "[2/4] Устанавливаю основные зависимости..."
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
echo "[3/4] Устанавливаю faster-whisper с поддержкой CUDA..."
|
||||||
|
pip install faster-whisper
|
||||||
|
|
||||||
|
echo "[4/4] Устанавливаю openwakeword..."
|
||||||
|
pip install openwakeword
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "============================================"
|
||||||
|
echo " Установка завершена!"
|
||||||
|
echo ""
|
||||||
|
echo " Следующие шаги:"
|
||||||
|
echo " 1. Запусти LM Studio и загрузи модель"
|
||||||
|
echo " 2. Включи Local Server в LM Studio (порт 1234)"
|
||||||
|
echo " 3. Запусти: bash run.sh"
|
||||||
|
echo "============================================"
|
||||||
|
read -p "Нажми Enter для выхода..."
|
||||||
49
install_mac.sh
Normal file
49
install_mac.sh
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "============================================"
|
||||||
|
echo " Установка Cosmo на macOS"
|
||||||
|
echo "============================================"
|
||||||
|
|
||||||
|
# --- Python ---
|
||||||
|
if ! command -v python3 &>/dev/null; then
|
||||||
|
echo "ОШИБКА: Python3 не найден."
|
||||||
|
echo "Установи через Homebrew: brew install python@3.11"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PYTHON_VERSION=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
|
||||||
|
echo "Python: $PYTHON_VERSION"
|
||||||
|
|
||||||
|
# --- Homebrew зависимости ---
|
||||||
|
if command -v brew &>/dev/null; then
|
||||||
|
echo "[1/5] Устанавливаю системные зависимости через Homebrew..."
|
||||||
|
brew install portaudio ffmpeg 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo "Homebrew не найден — пропускаю системные зависимости."
|
||||||
|
echo "Если будут ошибки с аудио — установи: /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[2/5] Обновляю pip..."
|
||||||
|
python3 -m pip install --upgrade pip
|
||||||
|
|
||||||
|
echo "[3/5] Устанавливаю зависимости..."
|
||||||
|
python3 -m pip install -r requirements.txt
|
||||||
|
|
||||||
|
echo "[4/5] Устанавливаю faster-whisper..."
|
||||||
|
# На Mac (Apple Silicon) используем CPU compute type
|
||||||
|
python3 -m pip install faster-whisper
|
||||||
|
|
||||||
|
echo "[5/5] Устанавливаю openwakeword..."
|
||||||
|
python3 -m pip install openwakeword
|
||||||
|
python3 -c "import openwakeword; openwakeword.utils.download_models()" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "============================================"
|
||||||
|
echo " Установка завершена!"
|
||||||
|
echo ""
|
||||||
|
echo " Следующие шаги:"
|
||||||
|
echo " 1. Установи и запусти Ollama: https://ollama.com"
|
||||||
|
echo " 2. Скачай модель: ollama pull qwen2.5:7b"
|
||||||
|
echo " 3. Запусти Cosmo: bash run_mac.sh"
|
||||||
|
echo "============================================"
|
||||||
21
requirements.txt
Normal file
21
requirements.txt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Wake word
|
||||||
|
openwakeword==0.6.0
|
||||||
|
|
||||||
|
# STT — стриминг с Silero VAD
|
||||||
|
RealtimeSTT==0.3.104
|
||||||
|
|
||||||
|
# TTS — Silero V4 для русского языка
|
||||||
|
RealtimeTTS==0.6.1
|
||||||
|
torch>=2.0.0 # нужен для Silero (CPU inference)
|
||||||
|
|
||||||
|
# Agent framework
|
||||||
|
smolagents==1.11.0
|
||||||
|
ollama==0.4.4 # официальный Python клиент Ollama
|
||||||
|
|
||||||
|
# Память и конфиг
|
||||||
|
pyyaml==6.0.2
|
||||||
|
loguru==0.7.2
|
||||||
|
|
||||||
|
# Инструменты агента
|
||||||
|
psutil==6.0.0
|
||||||
|
pyautogui==0.9.54
|
||||||
5
run.sh
Normal file
5
run.sh
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
python cosmo/main.py "$@"
|
||||||
14
run_mac.sh
Normal file
14
run_mac.sh
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# Проверяем что Ollama запущен
|
||||||
|
if ! curl -s http://localhost:11434 &>/dev/null; then
|
||||||
|
echo "Ollama не запущен. Запускаю..."
|
||||||
|
ollama serve &>/dev/null &
|
||||||
|
sleep 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Запускаем с Mac-конфигом
|
||||||
|
COSMO_PLATFORM=mac python3 cosmo/main.py --config config/config_mac.yaml "$@"
|
||||||
92
train_wakeword/Dockerfile
Normal file
92
train_wakeword/Dockerfile
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Dockerfile для обучения wake word модели openWakeWord
|
||||||
|
# Python 3.11 + torch 2.5 (последний совместимый с py3.11) + рабочие зависимости 2026
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Системные зависимости (включая build-essential для webrtcvad)
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
git wget curl ffmpeg libsndfile1 \
|
||||||
|
build-essential python3-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Клонируем openWakeWord и piper-sample-generator
|
||||||
|
RUN git clone https://github.com/dscripka/openWakeWord /openWakeWord
|
||||||
|
RUN git clone https://github.com/rhasspy/piper-sample-generator /piper-sample-generator
|
||||||
|
|
||||||
|
# Torch 2.5.0 — последний для Python 3.11, CPU версия (обучение не требует GPU)
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
torch==2.5.0 \
|
||||||
|
torchaudio==2.5.0 \
|
||||||
|
--index-url https://download.pytorch.org/whl/cpu
|
||||||
|
|
||||||
|
# Зависимости обучения с совместимыми версиями
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
mutagen==1.47.0 \
|
||||||
|
torchinfo==1.8.0 \
|
||||||
|
torchmetrics==1.2.0 \
|
||||||
|
speechbrain==1.0.3 \
|
||||||
|
audiomentations==0.43.1 \
|
||||||
|
torch-audiomentations==0.12.0 \
|
||||||
|
pronouncing==0.2.0 \
|
||||||
|
"datasets==2.20.0" \
|
||||||
|
"pyarrow==14.0.2" \
|
||||||
|
"fsspec==2023.12.2" \
|
||||||
|
acoustics==0.2.6 \
|
||||||
|
webrtcvad \
|
||||||
|
onnx \
|
||||||
|
onnxruntime \
|
||||||
|
onnx2tf \
|
||||||
|
pyyaml scipy scikit-learn tqdm
|
||||||
|
|
||||||
|
# TFLite конвертация через onnx2tf (замена мёртвого onnx_tf)
|
||||||
|
# Патчим train.py чтобы использовал onnx2tf вместо onnx_tf
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
tensorflow-cpu==2.21.0 \
|
||||||
|
tensorflow_probability==0.24.0
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir -e /openWakeWord
|
||||||
|
|
||||||
|
# Патч: заменяем onnx_tf на onnx2tf в train.py
|
||||||
|
RUN python - <<'EOF'
|
||||||
|
import re, pathlib
|
||||||
|
train_py = pathlib.Path("/openWakeWord/openwakeword/train.py")
|
||||||
|
text = train_py.read_text()
|
||||||
|
# Заменяем импорт onnx_tf
|
||||||
|
text = text.replace(
|
||||||
|
"import onnx_tf",
|
||||||
|
"import onnx2tf as onnx_tf_compat"
|
||||||
|
)
|
||||||
|
text = text.replace(
|
||||||
|
"from onnx_tf.backend import prepare",
|
||||||
|
"# onnx_tf replaced by onnx2tf"
|
||||||
|
)
|
||||||
|
# Заменяем вызов convert_onnx_to_tflite если он есть
|
||||||
|
text = re.sub(
|
||||||
|
r"onnx_tf\.backend\.prepare\(.*?\)",
|
||||||
|
"None # onnx2tf handles tflite conversion differently",
|
||||||
|
text, flags=re.DOTALL
|
||||||
|
)
|
||||||
|
train_py.write_text(text)
|
||||||
|
print("train.py patched OK")
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Устанавливаем piper-sample-generator
|
||||||
|
RUN pip install --no-cache-dir -e /piper-sample-generator 2>/dev/null || \
|
||||||
|
pip install --no-cache-dir piper-tts
|
||||||
|
|
||||||
|
# Скачиваем TTS модель LibriTTS-R medium (~66 MB) для генерации примеров
|
||||||
|
RUN mkdir -p /piper-sample-generator/models && \
|
||||||
|
wget -q --show-progress \
|
||||||
|
-O /piper-sample-generator/models/en_US-libritts_r-medium.onnx \
|
||||||
|
"https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/libritts_r/medium/en_US-libritts_r-medium.onnx" && \
|
||||||
|
wget -q \
|
||||||
|
-O /piper-sample-generator/models/en_US-libritts_r-medium.onnx.json \
|
||||||
|
"https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/libritts_r/medium/en_US-libritts_r-medium.onnx.json"
|
||||||
|
|
||||||
|
RUN mkdir -p /data /output /samples
|
||||||
|
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
53
train_wakeword/cosmo_config.yaml
Normal file
53
train_wakeword/cosmo_config.yaml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Конфиг для обучения wake word модели "Hey Cosmo"
|
||||||
|
# Документация: https://github.com/dscripka/openWakeWord
|
||||||
|
|
||||||
|
model_name: "hey_cosmo"
|
||||||
|
output_dir: "/output"
|
||||||
|
|
||||||
|
# Целевая фраза — "hey cosmo" работает лучше чем просто "cosmo"
|
||||||
|
target_phrase:
|
||||||
|
- "hey cosmo"
|
||||||
|
- "cosmo"
|
||||||
|
|
||||||
|
# Похожие слова для улучшения устойчивости к ложным срабатываниям
|
||||||
|
custom_negative_phrases:
|
||||||
|
- "hey cosmos"
|
||||||
|
- "hey cosmic"
|
||||||
|
- "hey cosplay"
|
||||||
|
- "hey presto"
|
||||||
|
- "hey como"
|
||||||
|
- "hey koz"
|
||||||
|
- "cozmo"
|
||||||
|
|
||||||
|
# Количество синтетических примеров
|
||||||
|
n_samples: 10000
|
||||||
|
n_samples_val: 1000
|
||||||
|
tts_batch_size: 25
|
||||||
|
|
||||||
|
# Аугментация
|
||||||
|
augmentation_batch_size: 16
|
||||||
|
augmentation_rounds: 2
|
||||||
|
|
||||||
|
# Пути внутри Docker контейнера
|
||||||
|
piper_sample_generator_path: "/piper-sample-generator"
|
||||||
|
false_positive_validation_data_path: "/data/validation_set_features.npy"
|
||||||
|
|
||||||
|
feature_data_files:
|
||||||
|
"ACAV100M_sample": "/data/openwakeword_features_ACAV100M_2000_hrs_16bit.npy"
|
||||||
|
|
||||||
|
batch_n_per_class:
|
||||||
|
"ACAV100M_sample": 1024
|
||||||
|
"adversarial_negative": 50
|
||||||
|
"positive": 50
|
||||||
|
|
||||||
|
# Архитектура модели
|
||||||
|
model_type: "dnn"
|
||||||
|
layer_size: 32
|
||||||
|
steps: 50000
|
||||||
|
|
||||||
|
# Цели качества
|
||||||
|
max_negative_weight: 1500
|
||||||
|
target_false_positives_per_hour: 0.5
|
||||||
|
target_accuracy: 0.7
|
||||||
|
target_recall: 0.5
|
||||||
|
lr: 0.0001
|
||||||
42
train_wakeword/entrypoint.sh
Normal file
42
train_wakeword/entrypoint.sh
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "============================================"
|
||||||
|
echo " Обучение wake word модели 'Hey Cosmo'"
|
||||||
|
echo "============================================"
|
||||||
|
|
||||||
|
CONFIG="/app/cosmo_config.yaml"
|
||||||
|
OWW="/openWakeWord/openwakeword/train.py"
|
||||||
|
|
||||||
|
# Шаг 1: Генерация синтетических аудио примеров через TTS
|
||||||
|
echo ""
|
||||||
|
echo "[1/3] Генерирую синтетические примеры через TTS..."
|
||||||
|
python "$OWW" --training_config "$CONFIG" --generate_clips
|
||||||
|
echo " Готово."
|
||||||
|
|
||||||
|
# Шаг 2: Аугментация (шумы, реверберация, разные условия)
|
||||||
|
echo ""
|
||||||
|
echo "[2/3] Аугментирую примеры (шумы, реверберация)..."
|
||||||
|
python "$OWW" --training_config "$CONFIG" --augment_clips
|
||||||
|
echo " Готово."
|
||||||
|
|
||||||
|
# Шаг 3: Обучение модели
|
||||||
|
echo ""
|
||||||
|
echo "[3/3] Обучаю модель..."
|
||||||
|
python "$OWW" --training_config "$CONFIG" --train_model
|
||||||
|
echo " Готово."
|
||||||
|
|
||||||
|
# Копируем результат
|
||||||
|
echo ""
|
||||||
|
echo "============================================"
|
||||||
|
if ls /output/hey_cosmo*.onnx 1>/dev/null 2>&1; then
|
||||||
|
echo " Модель обучена успешно!"
|
||||||
|
echo " Файлы в папке models/:"
|
||||||
|
ls -lh /output/*.onnx 2>/dev/null || true
|
||||||
|
ls -lh /output/*.tflite 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo " ОШИБКА: .onnx файл не найден в /output"
|
||||||
|
echo " Проверь логи выше."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "============================================"
|
||||||
114
train_wakeword/record_samples.py
Normal file
114
train_wakeword/record_samples.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""
|
||||||
|
Запись голосовых примеров для обучения wake word модели.
|
||||||
|
Запускай: python train_wakeword/record_samples.py
|
||||||
|
|
||||||
|
Скрипт записывает N примеров слова "Hey Cosmo" с паузами между ними.
|
||||||
|
Файлы сохраняются в train_wakeword/samples/positive/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import wave
|
||||||
|
import struct
|
||||||
|
import threading
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pyaudio
|
||||||
|
except ImportError:
|
||||||
|
print("Установи pyaudio: pip install pyaudio")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# --- Настройки ---
|
||||||
|
WAKE_WORD = "Hey Cosmo"
|
||||||
|
N_SAMPLES = 30 # сколько примеров записать
|
||||||
|
RECORD_SECS = 2.0 # длина одной записи (сек)
|
||||||
|
PAUSE_SECS = 2.0 # пауза между записями (сек)
|
||||||
|
SAMPLE_RATE = 16000
|
||||||
|
CHANNELS = 1
|
||||||
|
CHUNK = 512
|
||||||
|
OUTPUT_DIR = os.path.join(os.path.dirname(__file__), "samples", "positive")
|
||||||
|
|
||||||
|
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
def record_clip(pa: pyaudio.PyAudio, filename: str, duration: float):
|
||||||
|
stream = pa.open(
|
||||||
|
format=pyaudio.paInt16,
|
||||||
|
channels=CHANNELS,
|
||||||
|
rate=SAMPLE_RATE,
|
||||||
|
input=True,
|
||||||
|
frames_per_buffer=CHUNK,
|
||||||
|
)
|
||||||
|
frames = []
|
||||||
|
n_chunks = int(SAMPLE_RATE / CHUNK * duration)
|
||||||
|
for _ in range(n_chunks):
|
||||||
|
frames.append(stream.read(CHUNK, exception_on_overflow=False))
|
||||||
|
stream.stop_stream()
|
||||||
|
stream.close()
|
||||||
|
|
||||||
|
with wave.open(filename, "wb") as wf:
|
||||||
|
wf.setnchannels(CHANNELS)
|
||||||
|
wf.setsampwidth(pa.get_sample_size(pyaudio.paInt16))
|
||||||
|
wf.setframerate(SAMPLE_RATE)
|
||||||
|
wf.writeframes(b"".join(frames))
|
||||||
|
|
||||||
|
def countdown(seconds: int):
|
||||||
|
for i in range(seconds, 0, -1):
|
||||||
|
print(f"\r {i}...", end="", flush=True)
|
||||||
|
time.sleep(1)
|
||||||
|
print("\r Говори! ", end="", flush=True)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Считаем уже записанные файлы
|
||||||
|
existing = [f for f in os.listdir(OUTPUT_DIR) if f.endswith(".wav")]
|
||||||
|
start_idx = len(existing)
|
||||||
|
|
||||||
|
if start_idx >= N_SAMPLES:
|
||||||
|
print(f"Уже записано {start_idx} примеров. Для перезаписи удали папку {OUTPUT_DIR}")
|
||||||
|
return
|
||||||
|
|
||||||
|
pa = pyaudio.PyAudio()
|
||||||
|
|
||||||
|
print("=" * 50)
|
||||||
|
print(f" Запись примеров wake word: \"{WAKE_WORD}\"")
|
||||||
|
print(f" Нужно записать: {N_SAMPLES} примеров")
|
||||||
|
print(f" Уже есть: {start_idx}")
|
||||||
|
print(f" Длина каждой записи: {RECORD_SECS} сек")
|
||||||
|
print("=" * 50)
|
||||||
|
print()
|
||||||
|
print("Инструкция:")
|
||||||
|
print(" - Говори чётко и естественно")
|
||||||
|
|
||||||
|
print(" - Меняй интонацию, темп, громкость")
|
||||||
|
print(" - Можно говорить чуть тише / громче / быстрее")
|
||||||
|
print(" - Представь что реально обращаешься к ассистенту")
|
||||||
|
print()
|
||||||
|
input(" Нажми Enter когда готов начать...")
|
||||||
|
print()
|
||||||
|
|
||||||
|
for i in range(start_idx, N_SAMPLES):
|
||||||
|
num = i + 1
|
||||||
|
filename = os.path.join(OUTPUT_DIR, f"hey_cosmo_{num:03d}.wav")
|
||||||
|
|
||||||
|
print(f"[{num:2d}/{N_SAMPLES}] Приготовься... ", end="", flush=True)
|
||||||
|
countdown(2)
|
||||||
|
|
||||||
|
record_clip(pa, filename, RECORD_SECS)
|
||||||
|
print(f" ✓ записано")
|
||||||
|
|
||||||
|
if num < N_SAMPLES:
|
||||||
|
time.sleep(PAUSE_SECS)
|
||||||
|
|
||||||
|
pa.terminate()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 50)
|
||||||
|
print(f" Готово! Записано {N_SAMPLES} примеров.")
|
||||||
|
print(f" Папка: {OUTPUT_DIR}")
|
||||||
|
print()
|
||||||
|
print(" Следующий шаг:")
|
||||||
|
print(" bash train_wakeword/train.sh")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
119
train_wakeword/train.sh
Normal file
119
train_wakeword/train.sh
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
MODELS_DIR="$PROJECT_DIR/models"
|
||||||
|
SAMPLES_DIR="$SCRIPT_DIR/samples"
|
||||||
|
DATA_DIR="$SCRIPT_DIR/docker_data"
|
||||||
|
|
||||||
|
echo "============================================"
|
||||||
|
echo " Cosmo Wake Word — обучение модели"
|
||||||
|
echo "============================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Проверяем Docker
|
||||||
|
if ! command -v docker &>/dev/null; then
|
||||||
|
echo "ОШИБКА: Docker не найден."
|
||||||
|
echo "Установи Docker Desktop: https://www.docker.com/products/docker-desktop/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker info &>/dev/null; then
|
||||||
|
echo "ОШИБКА: Docker не запущен. Запусти Docker Desktop и попробуй снова."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Проверяем что есть записанные примеры
|
||||||
|
POSITIVE_DIR="$SAMPLES_DIR/positive"
|
||||||
|
if [ ! -d "$POSITIVE_DIR" ] || [ -z "$(ls "$POSITIVE_DIR"/*.wav 2>/dev/null)" ]; then
|
||||||
|
echo "Записанные примеры не найдены."
|
||||||
|
echo "Сначала запусти запись голоса:"
|
||||||
|
echo " python train_wakeword/record_samples.py"
|
||||||
|
echo ""
|
||||||
|
read -p "Продолжить без записанных примеров? (используются только TTS) [y/N]: " yn
|
||||||
|
case "$yn" in
|
||||||
|
[Yy]*) echo "Продолжаем с только TTS примерами..." ;;
|
||||||
|
*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$MODELS_DIR" "$DATA_DIR"
|
||||||
|
|
||||||
|
# Собираем Docker образ
|
||||||
|
echo "[1/4] Собираю Docker образ (первый раз ~5-10 мин)..."
|
||||||
|
docker build -t cosmo-wakeword-trainer "$SCRIPT_DIR" --quiet
|
||||||
|
echo " Образ готов."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Скачиваем датасет негативных примеров если нет
|
||||||
|
NEGATIVE_FEATURES="$DATA_DIR/openwakeword_features_ACAV100M_2000_hrs_16bit.npy"
|
||||||
|
VALIDATION_FEATURES="$DATA_DIR/validation_set_features.npy"
|
||||||
|
|
||||||
|
if [ ! -f "$NEGATIVE_FEATURES" ]; then
|
||||||
|
echo "[2/4] Скачиваю негативный датасет (~20 GB, один раз)..."
|
||||||
|
echo " Это займёт время в зависимости от скорости интернета."
|
||||||
|
docker run --rm \
|
||||||
|
-v "$DATA_DIR:/data" \
|
||||||
|
cosmo-wakeword-trainer \
|
||||||
|
python -c "
|
||||||
|
from datasets import load_dataset
|
||||||
|
import numpy as np, os
|
||||||
|
print('Скачиваю ACAV100M features...')
|
||||||
|
ds = load_dataset('davidscripka/openwakeword_features', 'ACAV100M_2000_hrs_16bit', split='train')
|
||||||
|
arr = np.array(ds['features'])
|
||||||
|
np.save('/data/openwakeword_features_ACAV100M_2000_hrs_16bit.npy', arr)
|
||||||
|
print('Скачиваю validation features...')
|
||||||
|
ds_val = load_dataset('davidscripka/openwakeword_features', 'validation_set', split='train')
|
||||||
|
arr_val = np.array(ds_val['features'])
|
||||||
|
np.save('/data/validation_set_features.npy', arr_val)
|
||||||
|
print('Датасет скачан.')
|
||||||
|
"
|
||||||
|
echo " Датасет готов."
|
||||||
|
else
|
||||||
|
echo "[2/4] Негативный датасет уже скачан. Пропускаю."
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Запускаем обучение
|
||||||
|
echo "[3/4] Запускаю обучение в Docker..."
|
||||||
|
echo " Это займёт ~30-60 минут."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
SAMPLES_MOUNT=""
|
||||||
|
if [ -d "$POSITIVE_DIR" ] && [ -n "$(ls "$POSITIVE_DIR"/*.wav 2>/dev/null)" ]; then
|
||||||
|
SAMPLES_MOUNT="-v $POSITIVE_DIR:/samples/positive"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-v "$SCRIPT_DIR/cosmo_config.yaml:/app/cosmo_config.yaml" \
|
||||||
|
-v "$DATA_DIR:/data" \
|
||||||
|
-v "$MODELS_DIR:/output" \
|
||||||
|
$SAMPLES_MOUNT \
|
||||||
|
cosmo-wakeword-trainer
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[4/4] Копирую модель в проект..."
|
||||||
|
|
||||||
|
# Ищем готовую модель
|
||||||
|
ONNX_FILE=$(ls "$MODELS_DIR"/*.onnx 2>/dev/null | head -1)
|
||||||
|
|
||||||
|
if [ -n "$ONNX_FILE" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "============================================"
|
||||||
|
echo " Готово! Модель сохранена:"
|
||||||
|
echo " $ONNX_FILE"
|
||||||
|
echo ""
|
||||||
|
echo " Обновляю wake_word детектор..."
|
||||||
|
# Обновляем путь в конфиге
|
||||||
|
MODEL_FILENAME=$(basename "$ONNX_FILE")
|
||||||
|
sed -i "s|wakeword_models=\[\"hey_jarvis\"\]|wakeword_models=[\"models/$MODEL_FILENAME\"]|g" \
|
||||||
|
"$PROJECT_DIR/cosmo/wake_word.py" 2>/dev/null || true
|
||||||
|
echo " Теперь запускай: bash run.sh"
|
||||||
|
echo " и говори 'Hey Cosmo' для активации!"
|
||||||
|
echo "============================================"
|
||||||
|
else
|
||||||
|
echo "ОШИБКА: .onnx файл не найден в $MODELS_DIR"
|
||||||
|
echo "Проверь логи Docker выше."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user