Initial ai typewriter implementation

This commit is contained in:
Hermes Agent
2026-09-11 12:01:35 +02:00
commit 464324aedf
14 changed files with 514 additions and 0 deletions
+19
View File
@@ -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
+75
View File
@@ -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"
```
+12
View File
@@ -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."
}
+4
View File
@@ -0,0 +1,4 @@
from ai_typewriter.app import main
if __name__ == "__main__":
raise SystemExit(main())
+20
View File
@@ -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"]
+5
View File
@@ -0,0 +1,5 @@
keyboard==0.13.5
pyperclip==1.9.0
requests==2.32.5
pyinstaller==6.16.0
pytest==8.4.2
+3
View File
@@ -0,0 +1,3 @@
"""AI Typewriter package."""
__version__ = "0.1.0"
+79
View File
@@ -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()
+87
View File
@@ -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:]))
+29
View File
@@ -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 ""
+54
View File
@@ -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)),
)
+59
View File
@@ -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)
+44
View File
@@ -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"]
+24
View File
@@ -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)