commit 464324aedfa12d6418059d5e4a869af0c307c9af Author: Hermes Agent Date: Fri Sep 11 11:56:41 2026 +0200 Initial ai typewriter implementation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a67fc6b --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.egg-info/ +.venv/ +build/ +dist/ +*.spec +.pytest_cache/ + +# Local secrets/config +config.json + +# Binary releases +*.exe +*.msi +*.zip diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a89812 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# ai-typewriter + +Application Python qui capture le texte sélectionné avec un raccourci global, l'envoie à un modèle IA, puis remplace chaque pression de touche suivante par le caractère suivant de la réponse. + +## Fonctionnement + +1. Raccourci global par défaut : `Ctrl+Alt+A`. +2. L'application simule `Ctrl+C` et lit le presse-papier. +3. Le texte capturé est envoyé à Ollama ou Gemini avec le system prompt strict : + + > Réponds directement et de manière ultra-concise. Aucune phrase d'introduction, aucune salutation, aucun formatage superflu. Uniquement la réponse brute. + +4. Quand la réponse arrive, le mode dactylographie s'active : chaque touche physique est interceptée et remplacée par le prochain caractère de la réponse. +5. Le hook clavier est libéré automatiquement après le dernier caractère. + +## Installation depuis les sources + +```bash +python -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +cp config.json.template config.json +python main.py +``` + +Sous Linux, le paquet `keyboard` nécessite souvent les droits root ou l'accès aux périphériques `/dev/input`. Sous Windows, lancez l'exécutable dans une session utilisateur normale. + +## Configuration + +Copiez `config.json.template` vers `config.json` puis adaptez : + +```json +{ + "provider": "ollama", + "model": "llama3.1", + "api_key": "", + "server_url": "http://localhost:11434", + "hotkey": "ctrl+alt+a" +} +``` + +### Ollama + +```json +{ + "provider": "ollama", + "model": "llama3.1", + "server_url": "http://localhost:11434" +} +``` + +### Gemini + +```json +{ + "provider": "gemini", + "model": "gemini-1.5-flash", + "api_key": "VOTRE_CLE", + "server_url": "https://generativelanguage.googleapis.com" +} +``` + +## Compilation + +```bash +pyinstaller --onefile --paths src --name ai-typewriter.exe main.py +``` + +L'exécutable est généré dans `dist/`. Le binaire n'est pas versionné Git. + +## Test rapide sans hook clavier + +```bash +python main.py --config config.json --ask "Résume: bonjour tout le monde" +``` diff --git a/config.json.template b/config.json.template new file mode 100644 index 0000000..963d118 --- /dev/null +++ b/config.json.template @@ -0,0 +1,12 @@ +{ + "provider": "ollama", + "model": "llama3.1", + "api_key": "", + "server_url": "http://localhost:11434", + "hotkey": "ctrl+alt+a", + "request_timeout_seconds": 120, + "copy_wait_seconds": 1, + "type_delay_seconds": 0, + "restore_clipboard": true, + "system_prompt": "Réponds directement et de manière ultra-concise. Aucune phrase d'introduction, aucune salutation, aucun formatage superflu. Uniquement la réponse brute." +} diff --git a/main.py b/main.py new file mode 100644 index 0000000..250122c --- /dev/null +++ b/main.py @@ -0,0 +1,4 @@ +from ai_typewriter.app import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a7af9c2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=70"] +build-backend = "setuptools.build_meta" + +[project] +name = "ai-typewriter" +version = "0.1.0" +description = "Global hotkey AI key-stepper" +requires-python = ">=3.10" +dependencies = [ + "keyboard==0.13.5", + "pyperclip==1.9.0", + "requests==2.32.5", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +pythonpath = ["src"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5b4bb54 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +keyboard==0.13.5 +pyperclip==1.9.0 +requests==2.32.5 +pyinstaller==6.16.0 +pytest==8.4.2 diff --git a/src/ai_typewriter/__init__.py b/src/ai_typewriter/__init__.py new file mode 100644 index 0000000..da49259 --- /dev/null +++ b/src/ai_typewriter/__init__.py @@ -0,0 +1,3 @@ +"""AI Typewriter package.""" + +__version__ = "0.1.0" diff --git a/src/ai_typewriter/ai_client.py b/src/ai_typewriter/ai_client.py new file mode 100644 index 0000000..2783fd2 --- /dev/null +++ b/src/ai_typewriter/ai_client.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import requests + +from .config import AppConfig + + +class AIClientError(RuntimeError): + """Raised when the configured AI backend cannot return text.""" + + +def ask_ai(prompt: str, config: AppConfig) -> str: + if not prompt.strip(): + raise AIClientError("Le texte capturé est vide.") + + if config.provider == "ollama": + return _ask_ollama(prompt, config) + if config.provider == "gemini": + return _ask_gemini(prompt, config) + raise AIClientError(f"Provider non supporté: {config.provider}") + + +def _ask_ollama(prompt: str, config: AppConfig) -> str: + url = f"{config.server_url}/api/chat" + payload = { + "model": config.model, + "stream": False, + "messages": [ + {"role": "system", "content": config.system_prompt}, + {"role": "user", "content": prompt}, + ], + } + try: + response = requests.post(url, json=payload, timeout=config.request_timeout_seconds) + response.raise_for_status() + data = response.json() + except requests.RequestException as exc: + raise AIClientError(f"Erreur Ollama: {exc}") from exc + except ValueError as exc: + raise AIClientError("Réponse Ollama invalide: JSON illisible") from exc + + content = data.get("message", {}).get("content") + if not isinstance(content, str) or not content.strip(): + raise AIClientError("Réponse Ollama vide ou inattendue") + return content.strip() + + +def _ask_gemini(prompt: str, config: AppConfig) -> str: + if not config.api_key: + raise AIClientError("api_key est obligatoire pour provider='gemini'.") + + base = config.server_url or "https://generativelanguage.googleapis.com" + url = f"{base}/v1beta/models/{config.model}:generateContent" + payload = { + "systemInstruction": {"parts": [{"text": config.system_prompt}]}, + "contents": [{"role": "user", "parts": [{"text": prompt}]}], + "generationConfig": {"temperature": 0.2}, + } + try: + response = requests.post( + url, + params={"key": config.api_key}, + json=payload, + timeout=config.request_timeout_seconds, + ) + response.raise_for_status() + data = response.json() + except requests.RequestException as exc: + raise AIClientError(f"Erreur Gemini: {exc}") from exc + except ValueError as exc: + raise AIClientError("Réponse Gemini invalide: JSON illisible") from exc + + try: + text = data["candidates"][0]["content"]["parts"][0]["text"] + except (KeyError, IndexError, TypeError) as exc: + raise AIClientError("Réponse Gemini vide ou inattendue") from exc + if not isinstance(text, str) or not text.strip(): + raise AIClientError("Réponse Gemini vide") + return text.strip() diff --git a/src/ai_typewriter/app.py b/src/ai_typewriter/app.py new file mode 100644 index 0000000..d40c741 --- /dev/null +++ b/src/ai_typewriter/app.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import argparse +import logging +import sys +from threading import Lock + +import keyboard + +from .ai_client import AIClientError, ask_ai +from .clipboard_capture import capture_selection +from .config import AppConfig, load_config +from .key_stepper import KeyStepper + +LOG = logging.getLogger("ai_typewriter") + + +class AITypewriterApp: + def __init__(self, config: AppConfig) -> None: + self.config = config + self._busy = Lock() + self._stepper: KeyStepper | None = None + + def run(self) -> None: + keyboard.add_hotkey(self.config.hotkey, self._handle_hotkey, suppress=False) + LOG.info("Prêt. Raccourci: %s. Quitter: Ctrl+C dans ce terminal.", self.config.hotkey) + keyboard.wait() + + def _handle_hotkey(self) -> None: + if not self._busy.acquire(blocking=False): + LOG.warning("Requête déjà en cours, raccourci ignoré.") + return + try: + self._capture_ask_and_step() + finally: + self._busy.release() + + def _capture_ask_and_step(self) -> None: + try: + selected = capture_selection(self.config.copy_wait_seconds, self.config.restore_clipboard) + LOG.info("Texte capturé: %d caractères.", len(selected)) + answer = ask_ai(selected, self.config) + LOG.info("Réponse reçue: %d caractères. Mode dactylographie actif.", len(answer)) + if self._stepper is not None: + self._stepper.stop() + self._stepper = KeyStepper(answer, delay=self.config.type_delay_seconds) + self._stepper.start() + except (AIClientError, FileNotFoundError, ValueError) as exc: + LOG.error("%s", exc) + except Exception: + LOG.exception("Erreur inattendue") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="AI Typewriter") + parser.add_argument("--config", default="config.json", help="Chemin du fichier config.json") + parser.add_argument("--debug", action="store_true", help="Logs détaillés") + parser.add_argument("--ask", help="Mode test: envoie ce texte à l'IA et imprime la réponse, sans hook clavier") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + logging.basicConfig( + level=logging.DEBUG if args.debug else logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + ) + try: + config = load_config(args.config) + except Exception as exc: + LOG.error("%s", exc) + return 2 + + if args.ask is not None: + try: + print(ask_ai(args.ask, config)) + return 0 + except AIClientError as exc: + LOG.error("%s", exc) + return 1 + + AITypewriterApp(config).run() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/src/ai_typewriter/clipboard_capture.py b/src/ai_typewriter/clipboard_capture.py new file mode 100644 index 0000000..b12ea37 --- /dev/null +++ b/src/ai_typewriter/clipboard_capture.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import time + +import keyboard +import pyperclip + + +def capture_selection(copy_wait_seconds: float = 1.0, restore_clipboard: bool = True) -> str: + previous = pyperclip.paste() + keyboard.send("ctrl+c") + + deadline = time.monotonic() + max(copy_wait_seconds, 0.05) + captured = previous + while time.monotonic() < deadline: + current = pyperclip.paste() + if current != previous: + captured = current + break + time.sleep(0.03) + else: + captured = pyperclip.paste() + + if restore_clipboard: + try: + pyperclip.copy(previous) + except pyperclip.PyperclipException: + pass + return captured or "" diff --git a/src/ai_typewriter/config.py b/src/ai_typewriter/config.py new file mode 100644 index 0000000..cef092b --- /dev/null +++ b/src/ai_typewriter/config.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +Provider = Literal["ollama", "gemini"] + + +@dataclass(frozen=True) +class AppConfig: + provider: Provider = "ollama" + model: str = "llama3.1" + api_key: str = "" + server_url: str = "http://localhost:11434" + hotkey: str = "ctrl+alt+a" + request_timeout_seconds: float = 120.0 + copy_wait_seconds: float = 1.0 + type_delay_seconds: float = 0.0 + restore_clipboard: bool = True + system_prompt: str = ( + "Réponds directement et de manière ultra-concise. " + "Aucune phrase d'introduction, aucune salutation, aucun formatage superflu. " + "Uniquement la réponse brute." + ) + + +def _coerce_provider(value: Any) -> Provider: + provider = str(value or "ollama").lower().strip() + if provider not in {"ollama", "gemini"}: + raise ValueError("config.provider doit être 'ollama' ou 'gemini'") + return provider # type: ignore[return-value] + + +def load_config(path: str | Path = "config.json") -> AppConfig: + cfg_path = Path(path) + if not cfg_path.exists(): + raise FileNotFoundError( + f"Configuration introuvable: {cfg_path}. Copiez config.json.template vers config.json." + ) + data = json.loads(cfg_path.read_text(encoding="utf-8")) + return AppConfig( + provider=_coerce_provider(data.get("provider", "ollama")), + model=str(data.get("model", "llama3.1")), + api_key=str(data.get("api_key", "")), + server_url=str(data.get("server_url", "http://localhost:11434")).rstrip("/"), + hotkey=str(data.get("hotkey", "ctrl+alt+a")).lower(), + request_timeout_seconds=float(data.get("request_timeout_seconds", 120)), + copy_wait_seconds=float(data.get("copy_wait_seconds", 1)), + type_delay_seconds=float(data.get("type_delay_seconds", 0)), + restore_clipboard=bool(data.get("restore_clipboard", True)), + system_prompt=str(data.get("system_prompt", AppConfig.system_prompt)), + ) diff --git a/src/ai_typewriter/key_stepper.py b/src/ai_typewriter/key_stepper.py new file mode 100644 index 0000000..f35db1e --- /dev/null +++ b/src/ai_typewriter/key_stepper.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from threading import Lock +from typing import Optional + +import keyboard + + +@dataclass +class KeyStepper: + text: str + delay: float = 0.0 + _index: int = 0 + _hook: Optional[object] = None + _lock: Lock = field(default_factory=Lock) + _injecting: bool = False + + def start(self) -> None: + if not self.text: + return + if self._hook is not None: + return + # suppress=True blocks the physical key while the callback types the next AI character. + self._hook = keyboard.hook(self._on_event, suppress=True) + + def stop(self) -> None: + hook = self._hook + self._hook = None + if hook is not None: + keyboard.unhook(hook) + + def _on_event(self, event: keyboard.KeyboardEvent) -> None: + if self._injecting or event.event_type != keyboard.KEY_DOWN: + return + + with self._lock: + if self._index >= len(self.text): + self.stop() + return + char = self.text[self._index] + self._index += 1 + + self._injecting = True + try: + self._type_char(char) + finally: + self._injecting = False + + if self._index >= len(self.text): + self.stop() + + def _type_char(self, char: str) -> None: + if char == "\n": + keyboard.send("enter") + elif char == " ": + keyboard.send("tab") + else: + keyboard.write(char, delay=self.delay, exact=True) diff --git a/tests/test_ai_client.py b/tests/test_ai_client.py new file mode 100644 index 0000000..dc8cbf7 --- /dev/null +++ b/tests/test_ai_client.py @@ -0,0 +1,44 @@ +from ai_typewriter.ai_client import ask_ai +from ai_typewriter.config import AppConfig + + +class FakeResponse: + def __init__(self, payload): + self.payload = payload + def raise_for_status(self): + return None + def json(self): + return self.payload + + +def test_ollama_payload_contains_strict_system_prompt(monkeypatch): + seen = {} + def fake_post(url, json, timeout, **kwargs): + seen["url"] = url + seen["json"] = json + return FakeResponse({"message": {"content": "ok"}}) + monkeypatch.setattr("ai_typewriter.ai_client.requests.post", fake_post) + + result = ask_ai("texte", AppConfig(provider="ollama", model="m")) + + assert result == "ok" + assert seen["url"].endswith("/api/chat") + assert seen["json"]["messages"][0]["role"] == "system" + assert "Uniquement la réponse brute" in seen["json"]["messages"][0]["content"] + + +def test_gemini_payload(monkeypatch): + seen = {} + def fake_post(url, params, json, timeout): + seen["url"] = url + seen["params"] = params + seen["json"] = json + return FakeResponse({"candidates": [{"content": {"parts": [{"text": "brut"}]}}]}) + monkeypatch.setattr("ai_typewriter.ai_client.requests.post", fake_post) + + result = ask_ai("texte", AppConfig(provider="gemini", model="gemini-1.5-flash", api_key="k")) + + assert result == "brut" + assert seen["params"] == {"key": "k"} + assert seen["url"].endswith("/v1beta/models/gemini-1.5-flash:generateContent") + assert "systemInstruction" in seen["json"] diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..78c58c9 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,24 @@ +import json + +import pytest + +from ai_typewriter.config import load_config + + +def test_load_default_config(tmp_path): + path = tmp_path / "config.json" + path.write_text(json.dumps({"provider": "ollama"}), encoding="utf-8") + + cfg = load_config(path) + + assert cfg.provider == "ollama" + assert cfg.hotkey == "ctrl+alt+a" + assert "Réponds directement" in cfg.system_prompt + + +def test_reject_invalid_provider(tmp_path): + path = tmp_path / "config.json" + path.write_text(json.dumps({"provider": "bad"}), encoding="utf-8") + + with pytest.raises(ValueError): + load_config(path)