opsiom-cli 1.0.2__tar.gz → 1.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {opsiom_cli-1.0.2 → opsiom_cli-1.1.0}/PKG-INFO +1 -1
- {opsiom_cli-1.0.2 → opsiom_cli-1.1.0}/pyproject.toml +1 -1
- opsiom_cli-1.1.0/src/opsiom_cli/cli.py +491 -0
- {opsiom_cli-1.0.2 → opsiom_cli-1.1.0}/src/opsiom_cli.egg-info/PKG-INFO +1 -1
- opsiom_cli-1.0.2/src/opsiom_cli/cli.py +0 -712
- {opsiom_cli-1.0.2 → opsiom_cli-1.1.0}/README.md +0 -0
- {opsiom_cli-1.0.2 → opsiom_cli-1.1.0}/setup.cfg +0 -0
- {opsiom_cli-1.0.2 → opsiom_cli-1.1.0}/src/opsiom_cli/__init__.py +0 -0
- {opsiom_cli-1.0.2 → opsiom_cli-1.1.0}/src/opsiom_cli.egg-info/SOURCES.txt +0 -0
- {opsiom_cli-1.0.2 → opsiom_cli-1.1.0}/src/opsiom_cli.egg-info/dependency_links.txt +0 -0
- {opsiom_cli-1.0.2 → opsiom_cli-1.1.0}/src/opsiom_cli.egg-info/entry_points.txt +0 -0
- {opsiom_cli-1.0.2 → opsiom_cli-1.1.0}/src/opsiom_cli.egg-info/requires.txt +0 -0
- {opsiom_cli-1.0.2 → opsiom_cli-1.1.0}/src/opsiom_cli.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# ============================================================================
|
|
3
|
+
# Opsiom CLI — interface en ligne de commande pour discuter avec Opsiom
|
|
4
|
+
#
|
|
5
|
+
# Design : une seule couleur d'accent, texte de contenu en couleur par
|
|
6
|
+
# défaut du terminal (compatible thèmes clairs et sombres), un spinner
|
|
7
|
+
# discret plutôt que des animations plein écran. Inspiré des CLI d'outils
|
|
8
|
+
# IA professionnels (Claude Code, Mistral CLI).
|
|
9
|
+
#
|
|
10
|
+
# Dépendances : uniquement `requests`
|
|
11
|
+
# pip install requests
|
|
12
|
+
#
|
|
13
|
+
# Configuration (aucune URL n'est codée en dur dans ce fichier) :
|
|
14
|
+
# Au premier lancement, le CLI demande l'URL du serveur (et une éventuelle
|
|
15
|
+
# clé API) puis les enregistre dans ~/.config/opsiom/config.json.
|
|
16
|
+
# Alternative : variables d'environnement OPSIOM_URL / OPSIOM_API_KEY,
|
|
17
|
+
# ou options --url / --api-key.
|
|
18
|
+
# ============================================================================
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
import json
|
|
23
|
+
import time
|
|
24
|
+
import shutil
|
|
25
|
+
import textwrap
|
|
26
|
+
import argparse
|
|
27
|
+
import threading
|
|
28
|
+
import itertools
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
import requests
|
|
33
|
+
except ImportError:
|
|
34
|
+
print("Ce CLI nécessite le paquet 'requests'. Installe-le avec : pip install requests")
|
|
35
|
+
sys.exit(1)
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
import readline # historique flèche-haut persistant
|
|
39
|
+
except ImportError:
|
|
40
|
+
readline = None # absent par défaut sur certains Windows (pyreadline3 comble le manque)
|
|
41
|
+
|
|
42
|
+
VERSION = "1.1.0"
|
|
43
|
+
CONFIG_PATH = Path.home() / ".config" / "opsiom" / "config.json"
|
|
44
|
+
HISTORY_PATH = Path.home() / ".local" / "share" / "opsiom" / "history"
|
|
45
|
+
TRANSCRIPTS_DIR = Path.home() / "opsiom-conversations"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ----------------------------------------------------------------------------
|
|
49
|
+
# Palette — une seule couleur d'accent, le reste reste en couleur par défaut
|
|
50
|
+
# du terminal. C'est ce qui distingue un outil pro d'une démo colorée : le
|
|
51
|
+
# contenu doit rester lisible sur fond clair ou sombre, sans texte forcé
|
|
52
|
+
# en blanc/noir codé en dur.
|
|
53
|
+
# ----------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
RESET = "\033[0m"
|
|
56
|
+
BOLD = "\033[1m"
|
|
57
|
+
DIM = "\033[2m"
|
|
58
|
+
HIDE_CURSOR = "\033[?25l"
|
|
59
|
+
SHOW_CURSOR = "\033[?25h"
|
|
60
|
+
|
|
61
|
+
ACCENT = (150, 130, 235) # violet discret — prompt, spinner, éléments actifs
|
|
62
|
+
ERROR = (206, 100, 100) # rouge sobre — erreurs uniquement
|
|
63
|
+
OK = (120, 170, 140) # vert sobre — statut connecté uniquement
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def accent(text, bold=False):
|
|
67
|
+
r, g, b = ACCENT
|
|
68
|
+
return f"{BOLD if bold else ''}\033[38;2;{r};{g};{b}m{text}{RESET}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def muted(text):
|
|
72
|
+
return f"{DIM}{text}{RESET}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def error_text(text, bold=False):
|
|
76
|
+
r, g, b = ERROR
|
|
77
|
+
return f"{BOLD if bold else ''}\033[38;2;{r};{g};{b}m{text}{RESET}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def ok_text(text):
|
|
81
|
+
r, g, b = OK
|
|
82
|
+
return f"\033[38;2;{OK[0]};{OK[1]};{OK[2]}m{text}{RESET}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ----------------------------------------------------------------------------
|
|
86
|
+
# Marque — un petit repère ASCII discret, affiché une seule fois au
|
|
87
|
+
# démarrage (pas d'animation en boucle : c'est un logo, pas un spectacle).
|
|
88
|
+
# ----------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
OCTOPUS_MARK = [
|
|
91
|
+
" .------.",
|
|
92
|
+
" / o o \\",
|
|
93
|
+
" | -- |",
|
|
94
|
+
" \\________/",
|
|
95
|
+
" /| | |\\",
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def print_header(url, connected, api_key, models_loaded, current_model, show_mark=True):
|
|
100
|
+
print()
|
|
101
|
+
if show_mark:
|
|
102
|
+
for line in OCTOPUS_MARK:
|
|
103
|
+
print(" " + accent(line))
|
|
104
|
+
print()
|
|
105
|
+
print(f" {accent('Opsiom', bold=True)} {muted(f'v{VERSION}')}")
|
|
106
|
+
print(f" {muted('assistant IA francophone')}")
|
|
107
|
+
print()
|
|
108
|
+
status_symbol = ok_text("✓") if connected else error_text("✗")
|
|
109
|
+
print(f" {status_symbol} {muted('connecté à')} {url}")
|
|
110
|
+
print(f" {muted('auth')} {muted('clé API' if api_key else 'aucune')}")
|
|
111
|
+
if models_loaded:
|
|
112
|
+
print(f" {muted('modèles')} {' · '.join(models_loaded)}")
|
|
113
|
+
if current_model:
|
|
114
|
+
print(f" {muted('actif')} {accent(current_model)}")
|
|
115
|
+
print()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ----------------------------------------------------------------------------
|
|
119
|
+
# Spinner — une ligne, un caractère braille tournant, un minuteur en
|
|
120
|
+
# direct. C'est la convention de facto des CLI professionnelles (npm,
|
|
121
|
+
# cargo, Claude Code) : discret, informatif, jamais envahissant.
|
|
122
|
+
# ----------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Spinner:
|
|
128
|
+
def __init__(self, label="Réflexion"):
|
|
129
|
+
self.label = label
|
|
130
|
+
self._stop_event = threading.Event()
|
|
131
|
+
self._thread = None
|
|
132
|
+
|
|
133
|
+
def _run(self):
|
|
134
|
+
start = time.perf_counter()
|
|
135
|
+
frames = itertools.cycle(SPINNER_FRAMES)
|
|
136
|
+
print(HIDE_CURSOR, end="")
|
|
137
|
+
while not self._stop_event.is_set():
|
|
138
|
+
elapsed = time.perf_counter() - start
|
|
139
|
+
frame = next(frames)
|
|
140
|
+
line = (
|
|
141
|
+
f"{accent(frame)} {muted(self.label)} {muted(f'{elapsed:0.1f}s')}"
|
|
142
|
+
f" {muted('(Ctrl+C pour annuler)')}"
|
|
143
|
+
)
|
|
144
|
+
sys.stdout.write("\r\033[K" + line)
|
|
145
|
+
sys.stdout.flush()
|
|
146
|
+
time.sleep(0.08)
|
|
147
|
+
sys.stdout.write("\r\033[K")
|
|
148
|
+
sys.stdout.write(SHOW_CURSOR)
|
|
149
|
+
sys.stdout.flush()
|
|
150
|
+
|
|
151
|
+
def start(self):
|
|
152
|
+
self._stop_event.clear()
|
|
153
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
154
|
+
self._thread.start()
|
|
155
|
+
|
|
156
|
+
def stop(self):
|
|
157
|
+
self._stop_event.set()
|
|
158
|
+
if self._thread:
|
|
159
|
+
self._thread.join()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ----------------------------------------------------------------------------
|
|
163
|
+
# Historique de saisie persistant
|
|
164
|
+
# ----------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def setup_history():
|
|
167
|
+
if not readline:
|
|
168
|
+
return
|
|
169
|
+
HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
try:
|
|
171
|
+
readline.read_history_file(HISTORY_PATH)
|
|
172
|
+
except OSError:
|
|
173
|
+
pass
|
|
174
|
+
readline.set_history_length(500)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def save_history():
|
|
178
|
+
if not readline:
|
|
179
|
+
return
|
|
180
|
+
try:
|
|
181
|
+
readline.write_history_file(HISTORY_PATH)
|
|
182
|
+
except OSError:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def save_transcript(transcript, filename=None):
|
|
187
|
+
TRANSCRIPTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
if not filename:
|
|
189
|
+
filename = f"opsiom-{time.strftime('%Y%m%d-%H%M%S')}"
|
|
190
|
+
if not filename.endswith(".md"):
|
|
191
|
+
filename += ".md"
|
|
192
|
+
path = TRANSCRIPTS_DIR / filename
|
|
193
|
+
lines = ["# Conversation Opsiom", ""]
|
|
194
|
+
for entry in transcript:
|
|
195
|
+
if entry["role"] == "user":
|
|
196
|
+
lines.append(f"**Vous :** {entry['text']}")
|
|
197
|
+
else:
|
|
198
|
+
lines.append(f"**Opsiom ({entry['model']}) :** {entry['text']} _({entry['elapsed_ms']} ms)_")
|
|
199
|
+
lines.append("")
|
|
200
|
+
path.write_text("\n".join(lines), encoding="utf-8")
|
|
201
|
+
return path
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ----------------------------------------------------------------------------
|
|
205
|
+
# Configuration — aucune URL n'est codée en dur : on lit env / config / prompt
|
|
206
|
+
# ----------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def load_config():
|
|
209
|
+
if CONFIG_PATH.exists():
|
|
210
|
+
try:
|
|
211
|
+
return json.loads(CONFIG_PATH.read_text())
|
|
212
|
+
except (json.JSONDecodeError, OSError):
|
|
213
|
+
return {}
|
|
214
|
+
return {}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def save_config(config):
|
|
218
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
CONFIG_PATH.write_text(json.dumps(config, indent=2))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def prompt_for_config():
|
|
223
|
+
print(f" {accent('Configuration initiale', bold=True)}")
|
|
224
|
+
print(f" {muted('ces informations te sont transmises par la personne qui héberge Opsiom')}\n")
|
|
225
|
+
url = input(" URL du serveur : ").strip().rstrip("/")
|
|
226
|
+
api_key = input(" Clé API (vide si aucune) : ").strip()
|
|
227
|
+
save = input(muted(" Sauvegarder localement pour la prochaine fois ? [O/n] ")).strip().lower()
|
|
228
|
+
config = {"url": url, "api_key": api_key}
|
|
229
|
+
if save in ("", "o", "oui", "y", "yes"):
|
|
230
|
+
save_config(config)
|
|
231
|
+
print(muted(f" → enregistré dans {CONFIG_PATH}\n"))
|
|
232
|
+
return config
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def resolve_settings(args):
|
|
236
|
+
config = {} if args.configure else load_config()
|
|
237
|
+
url = args.url or os.environ.get("OPSIOM_URL") or config.get("url")
|
|
238
|
+
api_key = args.api_key or os.environ.get("OPSIOM_API_KEY") or config.get("api_key")
|
|
239
|
+
if args.configure or not url:
|
|
240
|
+
new_config = prompt_for_config()
|
|
241
|
+
url = new_config["url"] or url
|
|
242
|
+
api_key = new_config["api_key"] or api_key
|
|
243
|
+
return url, api_key
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ----------------------------------------------------------------------------
|
|
247
|
+
# Client API
|
|
248
|
+
# ----------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
class OpsiomClient:
|
|
251
|
+
def __init__(self, base_url, api_key=None, timeout=60):
|
|
252
|
+
self.base_url = base_url.rstrip("/")
|
|
253
|
+
self.timeout = timeout
|
|
254
|
+
self.session = requests.Session()
|
|
255
|
+
headers = {"ngrok-skip-browser-warning": "true"}
|
|
256
|
+
if api_key:
|
|
257
|
+
headers["X-API-Key"] = api_key
|
|
258
|
+
self.session.headers.update(headers)
|
|
259
|
+
|
|
260
|
+
def health(self):
|
|
261
|
+
r = self.session.get(f"{self.base_url}/api/health", timeout=10)
|
|
262
|
+
r.raise_for_status()
|
|
263
|
+
return r.json()
|
|
264
|
+
|
|
265
|
+
def list_models(self):
|
|
266
|
+
r = self.session.get(f"{self.base_url}/api/models", timeout=10)
|
|
267
|
+
r.raise_for_status()
|
|
268
|
+
return r.json()
|
|
269
|
+
|
|
270
|
+
def chat(self, message, model=None, **params):
|
|
271
|
+
payload = {"message": message}
|
|
272
|
+
if model:
|
|
273
|
+
payload["model"] = model
|
|
274
|
+
payload.update(params)
|
|
275
|
+
r = self.session.post(f"{self.base_url}/api/chat", json=payload, timeout=self.timeout)
|
|
276
|
+
r.raise_for_status()
|
|
277
|
+
return r.json()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ----------------------------------------------------------------------------
|
|
281
|
+
# Interface interactive
|
|
282
|
+
# ----------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
HELP_TEXT = """
|
|
285
|
+
/model menu numéroté pour choisir le modèle
|
|
286
|
+
/model <id|num> change directement de modèle
|
|
287
|
+
/models liste les modèles disponibles
|
|
288
|
+
/status réaffiche l'en-tête de connexion
|
|
289
|
+
/save [nom] exporte la conversation en Markdown
|
|
290
|
+
/clear efface l'écran
|
|
291
|
+
/help affiche cette aide
|
|
292
|
+
/quit, /exit quitte le CLI
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def term_width(default=80):
|
|
297
|
+
return shutil.get_terminal_size((default, 20)).columns
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def format_elapsed(ms):
|
|
301
|
+
return f"{ms} ms" if ms < 1000 else f"{ms / 1000:.1f} s"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def print_error(text, detail=None):
|
|
305
|
+
print(f" {error_text('✗', bold=True)} {text}")
|
|
306
|
+
if detail:
|
|
307
|
+
print(f" {muted(detail)}")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def choose_default_model(models_payload):
|
|
311
|
+
default = models_payload.get("default")
|
|
312
|
+
models = models_payload.get("models", [])
|
|
313
|
+
return default or (models[0]["id"] if models else None)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def print_models(models_payload, current_model):
|
|
317
|
+
for i, m in enumerate(models_payload.get("models", []), start=1):
|
|
318
|
+
is_current = m["id"] == current_model
|
|
319
|
+
marker = accent("›") if is_current else " "
|
|
320
|
+
label = accent(m["label"], bold=True) if is_current else m["label"]
|
|
321
|
+
detail = f"{m['params']} · {m['id']}"
|
|
322
|
+
print(f" {marker} {muted(f'[{i}]')} {label} {muted(detail)}")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def resolve_model_choice(choice, models):
|
|
326
|
+
if choice.isdigit():
|
|
327
|
+
idx = int(choice) - 1
|
|
328
|
+
if 0 <= idx < len(models):
|
|
329
|
+
return models[idx]["id"]
|
|
330
|
+
return None
|
|
331
|
+
ids = {m["id"] for m in models}
|
|
332
|
+
if choice in ids:
|
|
333
|
+
return choice
|
|
334
|
+
matches = [m["id"] for m in models if m["id"].startswith(choice) or m["label"].lower().startswith(choice.lower())]
|
|
335
|
+
return matches[0] if len(matches) == 1 else None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def select_model_interactively(models_payload, current_model):
|
|
339
|
+
models = models_payload.get("models", [])
|
|
340
|
+
if not models:
|
|
341
|
+
print_error("aucun modèle disponible côté serveur.")
|
|
342
|
+
return current_model
|
|
343
|
+
print_models(models_payload, current_model)
|
|
344
|
+
choice = input(f"\n {muted('Numéro (Entrée pour annuler) :')} ").strip()
|
|
345
|
+
if not choice:
|
|
346
|
+
return current_model
|
|
347
|
+
resolved = resolve_model_choice(choice, models)
|
|
348
|
+
if resolved is None:
|
|
349
|
+
print_error(f"choix invalide : '{choice}'")
|
|
350
|
+
return current_model
|
|
351
|
+
print(f" {accent('→')} modèle actif : {accent(resolved, bold=True)}")
|
|
352
|
+
return resolved
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def print_assistant_message(text, elapsed_ms):
|
|
356
|
+
width = max(term_width() - 4, 30)
|
|
357
|
+
wrapped = textwrap.fill(text, width=width)
|
|
358
|
+
print()
|
|
359
|
+
for line in wrapped.split("\n"):
|
|
360
|
+
print(f" {line}")
|
|
361
|
+
print(f" {muted(format_elapsed(elapsed_ms))}")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def run_chat(client: OpsiomClient, url, api_key):
|
|
365
|
+
setup_history()
|
|
366
|
+
transcript = []
|
|
367
|
+
current_model = None
|
|
368
|
+
models_payload = {"models": []}
|
|
369
|
+
connected = False
|
|
370
|
+
models_loaded = []
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
health = client.health()
|
|
374
|
+
connected = health.get("status") == "ok"
|
|
375
|
+
models_loaded = health.get("models_loaded", [])
|
|
376
|
+
except requests.RequestException:
|
|
377
|
+
connected = False
|
|
378
|
+
|
|
379
|
+
try:
|
|
380
|
+
models_payload = client.list_models()
|
|
381
|
+
current_model = choose_default_model(models_payload)
|
|
382
|
+
except requests.RequestException:
|
|
383
|
+
pass
|
|
384
|
+
|
|
385
|
+
print_header(url, connected, api_key, models_loaded, current_model)
|
|
386
|
+
print(f" {muted('Tapez votre message, ou /help pour la liste des commandes.')}\n")
|
|
387
|
+
|
|
388
|
+
while True:
|
|
389
|
+
try:
|
|
390
|
+
user_input = input(f"{accent('›')} ").strip()
|
|
391
|
+
except (EOFError, KeyboardInterrupt):
|
|
392
|
+
print()
|
|
393
|
+
break
|
|
394
|
+
|
|
395
|
+
if not user_input:
|
|
396
|
+
continue
|
|
397
|
+
|
|
398
|
+
if user_input.startswith("/"):
|
|
399
|
+
cmd, *rest = user_input[1:].split(maxsplit=1)
|
|
400
|
+
arg = rest[0].strip() if rest else ""
|
|
401
|
+
cmd = cmd.lower()
|
|
402
|
+
|
|
403
|
+
if cmd in ("quit", "exit"):
|
|
404
|
+
break
|
|
405
|
+
elif cmd == "help":
|
|
406
|
+
print(muted(HELP_TEXT))
|
|
407
|
+
elif cmd == "clear":
|
|
408
|
+
sys.stdout.write("\033[H\033[J")
|
|
409
|
+
sys.stdout.flush()
|
|
410
|
+
elif cmd == "status":
|
|
411
|
+
print_header(url, connected, api_key, models_loaded, current_model, show_mark=False)
|
|
412
|
+
elif cmd == "save":
|
|
413
|
+
if not transcript:
|
|
414
|
+
print(muted(" rien à enregistrer pour l'instant."))
|
|
415
|
+
else:
|
|
416
|
+
path = save_transcript(transcript, arg or None)
|
|
417
|
+
print(f" {accent('→')} enregistré dans {path}")
|
|
418
|
+
elif cmd == "models":
|
|
419
|
+
try:
|
|
420
|
+
models_payload = client.list_models()
|
|
421
|
+
print_models(models_payload, current_model)
|
|
422
|
+
except requests.RequestException as e:
|
|
423
|
+
print_error("impossible de récupérer les modèles", str(e))
|
|
424
|
+
elif cmd == "model":
|
|
425
|
+
if not arg:
|
|
426
|
+
current_model = select_model_interactively(models_payload, current_model)
|
|
427
|
+
else:
|
|
428
|
+
resolved = resolve_model_choice(arg, models_payload.get("models", [])) or arg
|
|
429
|
+
current_model = resolved
|
|
430
|
+
print(f" {accent('→')} modèle actif : {accent(resolved, bold=True)}")
|
|
431
|
+
else:
|
|
432
|
+
print_error(f"commande inconnue : /{cmd}")
|
|
433
|
+
print()
|
|
434
|
+
continue
|
|
435
|
+
|
|
436
|
+
transcript.append({"role": "user", "text": user_input})
|
|
437
|
+
|
|
438
|
+
spinner = Spinner(label="Réflexion")
|
|
439
|
+
spinner.start()
|
|
440
|
+
start_time = time.perf_counter()
|
|
441
|
+
try:
|
|
442
|
+
result = client.chat(user_input, model=current_model)
|
|
443
|
+
except KeyboardInterrupt:
|
|
444
|
+
spinner.stop()
|
|
445
|
+
print(muted(" (requête annulée)\n"))
|
|
446
|
+
continue
|
|
447
|
+
except requests.RequestException as e:
|
|
448
|
+
spinner.stop()
|
|
449
|
+
print_error("la requête a échoué", str(e))
|
|
450
|
+
print()
|
|
451
|
+
continue
|
|
452
|
+
spinner.stop()
|
|
453
|
+
elapsed_ms = int((time.perf_counter() - start_time) * 1000)
|
|
454
|
+
|
|
455
|
+
response_text = result.get("response", "(réponse vide)")
|
|
456
|
+
used_model = result.get("model", current_model or "?")
|
|
457
|
+
print_assistant_message(response_text, elapsed_ms)
|
|
458
|
+
print()
|
|
459
|
+
transcript.append({"role": "assistant", "text": response_text, "model": used_model, "elapsed_ms": elapsed_ms})
|
|
460
|
+
|
|
461
|
+
save_history()
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# ----------------------------------------------------------------------------
|
|
465
|
+
# Point d'entrée
|
|
466
|
+
# ----------------------------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
def main():
|
|
469
|
+
parser = argparse.ArgumentParser(prog="opsiom", description="CLI de chat pour Opsiom")
|
|
470
|
+
parser.add_argument("--url", default=None, help="URL du serveur Opsiom (sinon config/prompt)")
|
|
471
|
+
parser.add_argument("--api-key", default=None, help="clé API si le serveur en exige une")
|
|
472
|
+
parser.add_argument("--configure", action="store_true", help="reconfigure l'URL/clé et les réenregistre")
|
|
473
|
+
parser.add_argument("--no-logo", action="store_true", help="masque le repère ASCII au démarrage")
|
|
474
|
+
parser.add_argument("--version", action="version", version=f"opsiom-cli {VERSION}")
|
|
475
|
+
args = parser.parse_args()
|
|
476
|
+
|
|
477
|
+
url, api_key = resolve_settings(args)
|
|
478
|
+
if not url:
|
|
479
|
+
print_error("aucune URL configurée, abandon.")
|
|
480
|
+
sys.exit(1)
|
|
481
|
+
|
|
482
|
+
client = OpsiomClient(url, api_key=api_key)
|
|
483
|
+
if args.no_logo:
|
|
484
|
+
global OCTOPUS_MARK
|
|
485
|
+
OCTOPUS_MARK = []
|
|
486
|
+
run_chat(client, url, api_key)
|
|
487
|
+
print(muted("À bientôt.\n"))
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
if __name__ == "__main__":
|
|
491
|
+
main()
|
|
@@ -1,712 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# ============================================================================
|
|
3
|
-
# Opsiom CLI — interface en ligne de commande pour discuter avec Opsiom
|
|
4
|
-
# Thème violet / rouge / rose. Mascotte poulpe procédurale avec plusieurs
|
|
5
|
-
# animations distinctes : respiration, ondulation des tentacules, clin
|
|
6
|
-
# d'œil, salut d'accueil, bulles de réflexion, et nuage d'encre en cas
|
|
7
|
-
# d'erreur.
|
|
8
|
-
#
|
|
9
|
-
# Dépendances : uniquement `requests`
|
|
10
|
-
# pip install requests
|
|
11
|
-
#
|
|
12
|
-
# Configuration (aucune URL n'est codée en dur dans ce fichier) :
|
|
13
|
-
# Au premier lancement, le CLI demande l'URL du serveur (et une éventuelle
|
|
14
|
-
# clé API) puis les enregistre dans ~/.config/opsiom/config.json.
|
|
15
|
-
# Alternative : variables d'environnement OPSIOM_URL / OPSIOM_API_KEY,
|
|
16
|
-
# ou options --url / --api-key.
|
|
17
|
-
# ============================================================================
|
|
18
|
-
|
|
19
|
-
import os
|
|
20
|
-
import sys
|
|
21
|
-
import json
|
|
22
|
-
import time
|
|
23
|
-
import shutil
|
|
24
|
-
import textwrap
|
|
25
|
-
import argparse
|
|
26
|
-
import threading
|
|
27
|
-
import itertools
|
|
28
|
-
from pathlib import Path
|
|
29
|
-
|
|
30
|
-
try:
|
|
31
|
-
import requests
|
|
32
|
-
except ImportError:
|
|
33
|
-
print("Ce CLI nécessite le paquet 'requests'. Installe-le avec : pip install requests")
|
|
34
|
-
sys.exit(1)
|
|
35
|
-
|
|
36
|
-
try:
|
|
37
|
-
import readline # active l'historique flèche-haut et son édition de ligne
|
|
38
|
-
except ImportError:
|
|
39
|
-
readline = None # non disponible nativement sur certains Windows (pyreadline3 comble le manque)
|
|
40
|
-
|
|
41
|
-
VERSION = "1.0.2"
|
|
42
|
-
CONFIG_PATH = Path.home() / ".config" / "opsiom" / "config.json"
|
|
43
|
-
HISTORY_PATH = Path.home() / ".local" / "share" / "opsiom" / "history"
|
|
44
|
-
TRANSCRIPTS_DIR = Path.home() / "opsiom-conversations"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def setup_history():
|
|
48
|
-
if not readline:
|
|
49
|
-
return
|
|
50
|
-
HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
-
try:
|
|
52
|
-
readline.read_history_file(HISTORY_PATH)
|
|
53
|
-
except OSError:
|
|
54
|
-
pass
|
|
55
|
-
readline.set_history_length(500)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def save_history():
|
|
59
|
-
if not readline:
|
|
60
|
-
return
|
|
61
|
-
try:
|
|
62
|
-
readline.write_history_file(HISTORY_PATH)
|
|
63
|
-
except OSError:
|
|
64
|
-
pass
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def save_transcript(transcript, filename=None):
|
|
68
|
-
TRANSCRIPTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
69
|
-
if not filename:
|
|
70
|
-
filename = f"opsiom-{time.strftime('%Y%m%d-%H%M%S')}"
|
|
71
|
-
if not filename.endswith(".md"):
|
|
72
|
-
filename += ".md"
|
|
73
|
-
path = TRANSCRIPTS_DIR / filename
|
|
74
|
-
lines = ["# Conversation Opsiom", ""]
|
|
75
|
-
for entry in transcript:
|
|
76
|
-
if entry["role"] == "user":
|
|
77
|
-
lines.append(f"**Vous :** {entry['text']}")
|
|
78
|
-
else:
|
|
79
|
-
lines.append(f"**Opsiom ({entry['model']}) :** {entry['text']} _({entry['elapsed_ms']} ms)_")
|
|
80
|
-
lines.append("")
|
|
81
|
-
path.write_text("\n".join(lines), encoding="utf-8")
|
|
82
|
-
return path
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
# ----------------------------------------------------------------------------
|
|
86
|
-
# Palette — dégradé violet -> rose -> rouge, en vraies couleurs 24 bits (ANSI)
|
|
87
|
-
# ----------------------------------------------------------------------------
|
|
88
|
-
|
|
89
|
-
RESET = "\033[0m"
|
|
90
|
-
BOLD = "\033[1m"
|
|
91
|
-
HIDE_CURSOR = "\033[?25l"
|
|
92
|
-
SHOW_CURSOR = "\033[?25h"
|
|
93
|
-
|
|
94
|
-
GRADIENT_STOPS = [
|
|
95
|
-
(124, 58, 199),
|
|
96
|
-
(168, 60, 178),
|
|
97
|
-
(214, 62, 140),
|
|
98
|
-
(235, 74, 96),
|
|
99
|
-
]
|
|
100
|
-
ACCENT = (222, 90, 176)
|
|
101
|
-
MUTED = (150, 110, 150)
|
|
102
|
-
USER_COLOR = (235, 130, 190)
|
|
103
|
-
ERROR_COLOR = (235, 90, 90)
|
|
104
|
-
SUCCESS_COLOR = (180, 230, 190)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def rgb(color, text, bold=False):
|
|
108
|
-
r, g, b = color
|
|
109
|
-
prefix = BOLD if bold else ""
|
|
110
|
-
return f"{prefix}\033[38;2;{r};{g};{b}m{text}{RESET}"
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def _lerp(a, b, t):
|
|
114
|
-
return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def gradient_line(text, stops=GRADIENT_STOPS, bold=False):
|
|
118
|
-
n = max(len(text) - 1, 1)
|
|
119
|
-
segments = len(stops) - 1
|
|
120
|
-
out = []
|
|
121
|
-
prefix = BOLD if bold else ""
|
|
122
|
-
for i, ch in enumerate(text):
|
|
123
|
-
t = (i / n) * segments
|
|
124
|
-
seg = min(int(t), segments - 1)
|
|
125
|
-
local_t = t - seg
|
|
126
|
-
r, g, b = _lerp(stops[seg], stops[seg + 1], local_t)
|
|
127
|
-
out.append(f"{prefix}\033[38;2;{r};{g};{b}m{ch}")
|
|
128
|
-
return "".join(out) + RESET
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def gradient_lines(lines, stops=GRADIENT_STOPS):
|
|
132
|
-
n = max(len(lines) - 1, 1)
|
|
133
|
-
segments = len(stops) - 1
|
|
134
|
-
out = []
|
|
135
|
-
for i, line in enumerate(lines):
|
|
136
|
-
t = (i / n) * segments
|
|
137
|
-
seg = min(int(t), segments - 1)
|
|
138
|
-
local_t = t - seg
|
|
139
|
-
color = _lerp(stops[seg], stops[seg + 1], local_t)
|
|
140
|
-
out.append(rgb(color, line))
|
|
141
|
-
return out
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def gradient_block(lines, stops=GRADIENT_STOPS):
|
|
145
|
-
return "\n".join(gradient_lines(lines, stops))
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
def draw_panel(lines_with_colors, min_width=46):
|
|
149
|
-
"""Panneau encadré (box-drawing) : une couleur unie par ligne, largeur auto."""
|
|
150
|
-
plain_texts = [t for t, _ in lines_with_colors]
|
|
151
|
-
content_width = max(min_width, max((len(t) for t in plain_texts), default=0) + 2)
|
|
152
|
-
top = "╭" + "─" * (content_width + 2) + "╮"
|
|
153
|
-
bottom = "╰" + "─" * (content_width + 2) + "╯"
|
|
154
|
-
out = [rgb(ACCENT, top)]
|
|
155
|
-
for text, color in lines_with_colors:
|
|
156
|
-
padded = text.ljust(content_width)
|
|
157
|
-
out.append(rgb(ACCENT, "│") + " " + rgb(color, padded) + " " + rgb(ACCENT, "│"))
|
|
158
|
-
out.append(rgb(ACCENT, bottom))
|
|
159
|
-
return "\n".join(out)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
# ----------------------------------------------------------------------------
|
|
163
|
-
# La mascotte — un poulpe construit à partir de gabarits (tête fixe, yeux /
|
|
164
|
-
# bouche / tentacules interchangeables) pour composer beaucoup de frames
|
|
165
|
-
# différentes sans tout redessiner à la main à chaque fois.
|
|
166
|
-
# ----------------------------------------------------------------------------
|
|
167
|
-
|
|
168
|
-
FRAME_HEIGHT = 13
|
|
169
|
-
|
|
170
|
-
EYES_OPEN = ("◉", "◉")
|
|
171
|
-
EYES_BLINK = ("─", "─")
|
|
172
|
-
EYES_WINK = ("◉", "◕")
|
|
173
|
-
EYES_SAD = ("×", "×")
|
|
174
|
-
|
|
175
|
-
MOUTH_NEUTRAL = "⌣"
|
|
176
|
-
MOUTH_SAD = "⌢"
|
|
177
|
-
MOUTH_OPEN = "○"
|
|
178
|
-
|
|
179
|
-
TENTACLES_REST = [
|
|
180
|
-
" / / | \\ \\",
|
|
181
|
-
" ( ( | ) )",
|
|
182
|
-
" \\ \\ | / /",
|
|
183
|
-
" `._\\ | /_.'",
|
|
184
|
-
]
|
|
185
|
-
TENTACLES_REST_CUPS = [
|
|
186
|
-
" / / o|o \\ \\",
|
|
187
|
-
" ( ( | ) )",
|
|
188
|
-
" \\ \\ o|o / /",
|
|
189
|
-
" `._\\ | /_.'",
|
|
190
|
-
]
|
|
191
|
-
TENTACLES_LEFT = [
|
|
192
|
-
" \\ / | \\ /",
|
|
193
|
-
" ( ( | ) )",
|
|
194
|
-
" / \\ | / \\",
|
|
195
|
-
" -' `._ | _.' `- ",
|
|
196
|
-
]
|
|
197
|
-
TENTACLES_RIGHT = [
|
|
198
|
-
" / \\ | / \\",
|
|
199
|
-
" ( ) | ( ) ",
|
|
200
|
-
" \\ / | \\ /",
|
|
201
|
-
" `-' _|_ `-'",
|
|
202
|
-
]
|
|
203
|
-
TENTACLES_DROOP = [
|
|
204
|
-
" ~ / \\ | / \\ ~",
|
|
205
|
-
" ~ ( ) | ( ) ~",
|
|
206
|
-
" ~ \\ / | \\ / ~",
|
|
207
|
-
" * `.|.' *",
|
|
208
|
-
]
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
def build_frame(top, eyes, mouth, tentacles, bottom=""):
|
|
212
|
-
left_eye, right_eye = eyes
|
|
213
|
-
return [
|
|
214
|
-
top,
|
|
215
|
-
" .───────.",
|
|
216
|
-
" .─' '─.",
|
|
217
|
-
f" / {left_eye} {right_eye} \\",
|
|
218
|
-
" | |",
|
|
219
|
-
f" | {mouth} |",
|
|
220
|
-
" \\ /",
|
|
221
|
-
" '─..-----..─'",
|
|
222
|
-
tentacles[0],
|
|
223
|
-
tentacles[1],
|
|
224
|
-
tentacles[2],
|
|
225
|
-
tentacles[3],
|
|
226
|
-
bottom,
|
|
227
|
-
]
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
# --- Respiration au repos (2 frames) ---------------------------------------
|
|
231
|
-
FRAMES_BREATHE = [
|
|
232
|
-
build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_REST),
|
|
233
|
-
build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_REST_CUPS),
|
|
234
|
-
]
|
|
235
|
-
|
|
236
|
-
# --- Clin d'œil, inséré de temps en temps dans les cycles -------------------
|
|
237
|
-
FRAME_BLINK = build_frame("", EYES_BLINK, MOUTH_NEUTRAL, TENTACLES_REST)
|
|
238
|
-
|
|
239
|
-
# --- Ondulation des tentacules (4 frames) -----------------------------------
|
|
240
|
-
FRAMES_WAVE = [
|
|
241
|
-
build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_LEFT),
|
|
242
|
-
build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_REST_CUPS),
|
|
243
|
-
build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_RIGHT),
|
|
244
|
-
FRAME_BLINK,
|
|
245
|
-
]
|
|
246
|
-
|
|
247
|
-
# --- Bulles de réflexion qui montent (utilisées pendant l'appel réseau) ----
|
|
248
|
-
_BUBBLE_STAGES = ["", " .", " o",
|
|
249
|
-
" O", " 0",
|
|
250
|
-
" °", ""]
|
|
251
|
-
_THINKING_TENTACLES = itertools.cycle([TENTACLES_REST, TENTACLES_LEFT, TENTACLES_REST_CUPS, TENTACLES_RIGHT])
|
|
252
|
-
FRAMES_THINKING = [
|
|
253
|
-
build_frame(bubble, EYES_BLINK if i == 3 else EYES_OPEN, MOUTH_NEUTRAL, next(_THINKING_TENTACLES))
|
|
254
|
-
for i, bubble in enumerate(_BUBBLE_STAGES)
|
|
255
|
-
]
|
|
256
|
-
|
|
257
|
-
# --- Salut d'accueil : un tentacule se lève et fait coucou -----------------
|
|
258
|
-
FRAMES_GREETING = [
|
|
259
|
-
[
|
|
260
|
-
" o",
|
|
261
|
-
" .───────. /",
|
|
262
|
-
" .─' '─. _/",
|
|
263
|
-
" / ◉ ◕ \\",
|
|
264
|
-
" | |",
|
|
265
|
-
" | ⌣ |",
|
|
266
|
-
" \\ /",
|
|
267
|
-
" '─..-----..─'",
|
|
268
|
-
" / / | \\ \\",
|
|
269
|
-
" ( ( | ) )",
|
|
270
|
-
" \\ \\ | / /",
|
|
271
|
-
" `._\\ | /_.'",
|
|
272
|
-
"",
|
|
273
|
-
],
|
|
274
|
-
[
|
|
275
|
-
" \\ o",
|
|
276
|
-
" .───────. \\",
|
|
277
|
-
" .─' '─.",
|
|
278
|
-
" / ◕ ◉ \\",
|
|
279
|
-
" | |",
|
|
280
|
-
" | ⌣ |",
|
|
281
|
-
" \\ /",
|
|
282
|
-
" '─..-----..─'",
|
|
283
|
-
" / / | \\ \\",
|
|
284
|
-
" ( ( | ) )",
|
|
285
|
-
" \\ \\ | / /",
|
|
286
|
-
" `._\\ | /_.'",
|
|
287
|
-
"",
|
|
288
|
-
],
|
|
289
|
-
build_frame("", EYES_WINK, MOUTH_NEUTRAL, TENTACLES_REST),
|
|
290
|
-
build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_REST),
|
|
291
|
-
]
|
|
292
|
-
|
|
293
|
-
# --- Nuage d'encre + mine déconfite (erreur réseau) -------------------------
|
|
294
|
-
FRAMES_ERROR = [
|
|
295
|
-
build_frame(" . *", EYES_SAD, MOUTH_SAD, TENTACLES_DROOP, " * ~ ."),
|
|
296
|
-
build_frame(" * . *", EYES_SAD, MOUTH_SAD, TENTACLES_DROOP, " . ~ * ~ ."),
|
|
297
|
-
]
|
|
298
|
-
|
|
299
|
-
# --- Petite étincelle affichée brièvement après une réponse réussie --------
|
|
300
|
-
SPARKLE_FRAMES = [" ✦", " · ✦ ·", " ˚ · ✦ · ˚"]
|
|
301
|
-
|
|
302
|
-
TITLE = [
|
|
303
|
-
" ██████╗ ██████╗ ███████╗██╗ ██████╗ ███╗ ███╗",
|
|
304
|
-
"██╔═══██╗██╔══██╗██╔════╝██║██╔═══██╗████╗ ████║",
|
|
305
|
-
"██║ ██║██████╔╝███████╗██║██║ ██║██╔████╔██║",
|
|
306
|
-
"██║ ██║██╔═══╝ ╚════██║██║██║ ██║██║╚██╔╝██║",
|
|
307
|
-
"╚██████╔╝██║ ███████║██║╚██████╔╝██║ ╚═╝ ██║",
|
|
308
|
-
" ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═╝",
|
|
309
|
-
]
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
# ----------------------------------------------------------------------------
|
|
313
|
-
# Moteur d'animation générique — anime n'importe quelle liste de frames,
|
|
314
|
-
# avec ou sans étiquette de texte sous le poulpe.
|
|
315
|
-
# ----------------------------------------------------------------------------
|
|
316
|
-
|
|
317
|
-
class OctopusAnimation:
|
|
318
|
-
def __init__(self, frames, label=None, interval=0.2, loop=True, dynamic=None):
|
|
319
|
-
self.frames = frames
|
|
320
|
-
self.label = label
|
|
321
|
-
self.interval = interval
|
|
322
|
-
self.loop = loop
|
|
323
|
-
self.dynamic = dynamic # callable optionnel -> suffixe texte (ex: minuteur en direct)
|
|
324
|
-
self._stop_event = threading.Event()
|
|
325
|
-
self._thread = None
|
|
326
|
-
|
|
327
|
-
def _iter_frames(self):
|
|
328
|
-
return itertools.cycle(self.frames) if self.loop else iter(self.frames)
|
|
329
|
-
|
|
330
|
-
def _run(self):
|
|
331
|
-
dots = itertools.cycle([" ", ". ", ".. ", "..."]) if (self.label and not self.dynamic) else None
|
|
332
|
-
n_lines = FRAME_HEIGHT + (1 if self.label else 0)
|
|
333
|
-
print(HIDE_CURSOR, end="")
|
|
334
|
-
first = True
|
|
335
|
-
for frame in self._iter_frames():
|
|
336
|
-
if self._stop_event.is_set():
|
|
337
|
-
break
|
|
338
|
-
if not first:
|
|
339
|
-
sys.stdout.write(f"\033[{n_lines}A")
|
|
340
|
-
first = False
|
|
341
|
-
for line in gradient_lines(frame):
|
|
342
|
-
sys.stdout.write(line + "\033[K\n")
|
|
343
|
-
if self.label:
|
|
344
|
-
suffix = self.dynamic() if self.dynamic else next(dots)
|
|
345
|
-
sys.stdout.write(rgb(ACCENT, f" {self.label}{suffix}") + "\033[K\n")
|
|
346
|
-
sys.stdout.flush()
|
|
347
|
-
time.sleep(self.interval)
|
|
348
|
-
sys.stdout.write(f"\033[{n_lines}A")
|
|
349
|
-
for _ in range(n_lines):
|
|
350
|
-
sys.stdout.write("\033[K\n")
|
|
351
|
-
sys.stdout.write(f"\033[{n_lines}A")
|
|
352
|
-
sys.stdout.write(SHOW_CURSOR)
|
|
353
|
-
sys.stdout.flush()
|
|
354
|
-
|
|
355
|
-
def start(self):
|
|
356
|
-
self._stop_event.clear()
|
|
357
|
-
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
358
|
-
self._thread.start()
|
|
359
|
-
|
|
360
|
-
def stop(self):
|
|
361
|
-
self._stop_event.set()
|
|
362
|
-
if self._thread:
|
|
363
|
-
self._thread.join()
|
|
364
|
-
|
|
365
|
-
def play_once_blocking(self):
|
|
366
|
-
"""Joue la séquence une fois, sans thread, puis rend la main (pour l'intro)."""
|
|
367
|
-
self.loop = False
|
|
368
|
-
print(HIDE_CURSOR, end="")
|
|
369
|
-
for frame in self.frames:
|
|
370
|
-
sys.stdout.write("\033[H\033[J")
|
|
371
|
-
sys.stdout.write("\n" + gradient_block(frame) + "\n")
|
|
372
|
-
sys.stdout.flush()
|
|
373
|
-
time.sleep(self.interval)
|
|
374
|
-
print(SHOW_CURSOR, end="")
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
def print_intro_animation():
|
|
378
|
-
OctopusAnimation(FRAMES_GREETING + [FRAMES_BREATHE[0]], interval=0.22).play_once_blocking()
|
|
379
|
-
sys.stdout.write("\033[H\033[J")
|
|
380
|
-
print()
|
|
381
|
-
print(gradient_block(TITLE))
|
|
382
|
-
print(rgb(MUTED, f" — assistant IA francophone · v{VERSION} —\n"))
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
def flash_sparkle():
|
|
386
|
-
"""Étincelle furtive affichée juste avant une réponse réussie."""
|
|
387
|
-
print(HIDE_CURSOR, end="")
|
|
388
|
-
for frame in SPARKLE_FRAMES:
|
|
389
|
-
sys.stdout.write("\r" + rgb(SUCCESS_COLOR, frame) + "\033[K")
|
|
390
|
-
sys.stdout.flush()
|
|
391
|
-
time.sleep(0.08)
|
|
392
|
-
sys.stdout.write("\r\033[K")
|
|
393
|
-
sys.stdout.write(SHOW_CURSOR)
|
|
394
|
-
sys.stdout.flush()
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
def show_error_animation():
|
|
398
|
-
"""Nuage d'encre + mine déconfite, joué deux fois puis nettoyé."""
|
|
399
|
-
OctopusAnimation(FRAMES_ERROR, interval=0.3, loop=False)._run()
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
# ----------------------------------------------------------------------------
|
|
403
|
-
# Configuration — aucune URL n'est codée en dur : on lit env / config / prompt
|
|
404
|
-
# ----------------------------------------------------------------------------
|
|
405
|
-
|
|
406
|
-
def load_config():
|
|
407
|
-
if CONFIG_PATH.exists():
|
|
408
|
-
try:
|
|
409
|
-
return json.loads(CONFIG_PATH.read_text())
|
|
410
|
-
except (json.JSONDecodeError, OSError):
|
|
411
|
-
return {}
|
|
412
|
-
return {}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
def save_config(config):
|
|
416
|
-
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
417
|
-
CONFIG_PATH.write_text(json.dumps(config, indent=2))
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
def prompt_for_config():
|
|
421
|
-
print(rgb(ACCENT, "Configuration initiale d'Opsiom CLI", bold=True))
|
|
422
|
-
print(rgb(MUTED, " (ces informations te sont normalement transmises par la personne qui héberge Opsiom)\n"))
|
|
423
|
-
url = input(rgb(USER_COLOR, "URL du serveur Opsiom : ")).strip().rstrip("/")
|
|
424
|
-
api_key = input(rgb(USER_COLOR, "Clé API (laisser vide si aucune) : ")).strip()
|
|
425
|
-
save = input(rgb(MUTED, "Sauvegarder ces informations localement pour la prochaine fois ? [O/n] ")).strip().lower()
|
|
426
|
-
config = {"url": url, "api_key": api_key}
|
|
427
|
-
if save in ("", "o", "oui", "y", "yes"):
|
|
428
|
-
save_config(config)
|
|
429
|
-
print(rgb(MUTED, f" → enregistré dans {CONFIG_PATH}\n"))
|
|
430
|
-
return config
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
def resolve_settings(args):
|
|
434
|
-
config = {} if args.configure else load_config()
|
|
435
|
-
url = args.url or os.environ.get("OPSIOM_URL") or config.get("url")
|
|
436
|
-
api_key = args.api_key or os.environ.get("OPSIOM_API_KEY") or config.get("api_key")
|
|
437
|
-
if args.configure or not url:
|
|
438
|
-
new_config = prompt_for_config()
|
|
439
|
-
url = new_config["url"] or url
|
|
440
|
-
api_key = new_config["api_key"] or api_key
|
|
441
|
-
return url, api_key
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
# ----------------------------------------------------------------------------
|
|
445
|
-
# Client API
|
|
446
|
-
# ----------------------------------------------------------------------------
|
|
447
|
-
|
|
448
|
-
class OpsiomClient:
|
|
449
|
-
def __init__(self, base_url, api_key=None, timeout=60):
|
|
450
|
-
self.base_url = base_url.rstrip("/")
|
|
451
|
-
self.timeout = timeout
|
|
452
|
-
self.session = requests.Session()
|
|
453
|
-
headers = {"ngrok-skip-browser-warning": "true"}
|
|
454
|
-
if api_key:
|
|
455
|
-
headers["X-API-Key"] = api_key
|
|
456
|
-
self.session.headers.update(headers)
|
|
457
|
-
|
|
458
|
-
def health(self):
|
|
459
|
-
r = self.session.get(f"{self.base_url}/api/health", timeout=10)
|
|
460
|
-
r.raise_for_status()
|
|
461
|
-
return r.json()
|
|
462
|
-
|
|
463
|
-
def list_models(self):
|
|
464
|
-
r = self.session.get(f"{self.base_url}/api/models", timeout=10)
|
|
465
|
-
r.raise_for_status()
|
|
466
|
-
return r.json()
|
|
467
|
-
|
|
468
|
-
def chat(self, message, model=None, **params):
|
|
469
|
-
payload = {"message": message}
|
|
470
|
-
if model:
|
|
471
|
-
payload["model"] = model
|
|
472
|
-
payload.update(params)
|
|
473
|
-
r = self.session.post(f"{self.base_url}/api/chat", json=payload, timeout=self.timeout)
|
|
474
|
-
r.raise_for_status()
|
|
475
|
-
return r.json()
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
# ----------------------------------------------------------------------------
|
|
479
|
-
# Interface interactive
|
|
480
|
-
# ----------------------------------------------------------------------------
|
|
481
|
-
|
|
482
|
-
HELP_TEXT = """
|
|
483
|
-
Commandes disponibles :
|
|
484
|
-
/model menu numéroté pour choisir le modèle
|
|
485
|
-
/model <id|num> change directement de modèle (ex: /model small, /model 2)
|
|
486
|
-
/models liste les modèles disponibles
|
|
487
|
-
/status réaffiche le panneau de connexion
|
|
488
|
-
/save [nom] exporte la conversation en Markdown (~/opsiom-conversations/)
|
|
489
|
-
/clear efface l'écran
|
|
490
|
-
/help affiche cette aide
|
|
491
|
-
/quit, /exit quitte le CLI (Ctrl+C pendant une réponse : annule juste cette requête)
|
|
492
|
-
"""
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
def term_width(default=76):
|
|
496
|
-
return shutil.get_terminal_size((default, 20)).columns
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
def print_separator():
|
|
500
|
-
print(rgb(MUTED, "─" * min(term_width(), 76)))
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
def print_status_panel(url, api_key, connected, models_loaded):
|
|
504
|
-
status_text = f"● Connecté — {url}" if connected else f"⨯ Non connecté — {url}"
|
|
505
|
-
status_color = ACCENT if connected else ERROR_COLOR
|
|
506
|
-
lines = [
|
|
507
|
-
(f"OPSIOM CLI · v{VERSION}", ACCENT),
|
|
508
|
-
(status_text, status_color),
|
|
509
|
-
(f"Authentification : {'clé API fournie' if api_key else 'aucune'}", MUTED),
|
|
510
|
-
(f"Modèles chargés : {', '.join(models_loaded)}" if models_loaded else "Modèles : indisponible", MUTED),
|
|
511
|
-
]
|
|
512
|
-
print(draw_panel(lines))
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
def print_assistant_message(text, model_id, elapsed_ms=None):
|
|
516
|
-
prefix_plain = f"Opsiom ({model_id}) › "
|
|
517
|
-
prefix_colored = rgb(ACCENT, "Opsiom", bold=True) + rgb(MUTED, f" ({model_id})") + rgb(MUTED, " › ")
|
|
518
|
-
width = max(term_width() - len(prefix_plain) - 12, 24)
|
|
519
|
-
wrapped = textwrap.fill(text, width=width, subsequent_indent=" " * len(prefix_plain))
|
|
520
|
-
suffix = rgb(MUTED, f" [{elapsed_ms} ms]") if elapsed_ms is not None else ""
|
|
521
|
-
print(prefix_colored + wrapped + suffix)
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
def print_error(text):
|
|
525
|
-
print(rgb(ERROR_COLOR, f"⨯ {text}", bold=True))
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
def choose_default_model(models_payload):
|
|
529
|
-
default = models_payload.get("default")
|
|
530
|
-
models = models_payload.get("models", [])
|
|
531
|
-
if default:
|
|
532
|
-
return default
|
|
533
|
-
return models[0]["id"] if models else None
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
def print_models(models_payload, current_model):
|
|
537
|
-
print(rgb(ACCENT, "Modèles disponibles :", bold=True))
|
|
538
|
-
for i, m in enumerate(models_payload.get("models", []), start=1):
|
|
539
|
-
marker = rgb(ACCENT, "●") if m["id"] == current_model else rgb(MUTED, "○")
|
|
540
|
-
detail = f"({m['params']} — id: {m['id']})"
|
|
541
|
-
print(f" {rgb(MUTED, f'[{i}]')} {marker} {gradient_line(m['label'])} {rgb(MUTED, detail)}")
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
def resolve_model_choice(choice, models):
|
|
545
|
-
if choice.isdigit():
|
|
546
|
-
idx = int(choice) - 1
|
|
547
|
-
if 0 <= idx < len(models):
|
|
548
|
-
return models[idx]["id"]
|
|
549
|
-
return None
|
|
550
|
-
ids = {m["id"] for m in models}
|
|
551
|
-
if choice in ids:
|
|
552
|
-
return choice
|
|
553
|
-
matches = [m["id"] for m in models if m["id"].startswith(choice) or m["label"].lower().startswith(choice.lower())]
|
|
554
|
-
return matches[0] if len(matches) == 1 else None
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
def select_model_interactively(models_payload, current_model):
|
|
558
|
-
models = models_payload.get("models", [])
|
|
559
|
-
if not models:
|
|
560
|
-
print_error("aucun modèle disponible côté serveur.")
|
|
561
|
-
return current_model
|
|
562
|
-
print_models(models_payload, current_model)
|
|
563
|
-
choice = input(rgb(USER_COLOR, "Choisis un numéro (ou Entrée pour annuler) : ")).strip()
|
|
564
|
-
if not choice:
|
|
565
|
-
return current_model
|
|
566
|
-
resolved = resolve_model_choice(choice, models)
|
|
567
|
-
if resolved is None:
|
|
568
|
-
print_error(f"choix invalide : '{choice}'")
|
|
569
|
-
return current_model
|
|
570
|
-
print(rgb(ACCENT, f"→ modèle changé pour '{resolved}'"))
|
|
571
|
-
return resolved
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
def run_chat(client: OpsiomClient, url, api_key):
|
|
575
|
-
setup_history()
|
|
576
|
-
transcript = []
|
|
577
|
-
current_model = None
|
|
578
|
-
models_payload = {"models": []}
|
|
579
|
-
connected = False
|
|
580
|
-
models_loaded = []
|
|
581
|
-
try:
|
|
582
|
-
health = client.health()
|
|
583
|
-
connected = health.get("status") == "ok"
|
|
584
|
-
models_loaded = health.get("models_loaded", [])
|
|
585
|
-
except requests.RequestException:
|
|
586
|
-
connected = False
|
|
587
|
-
|
|
588
|
-
print_status_panel(url, api_key, connected, models_loaded)
|
|
589
|
-
print()
|
|
590
|
-
|
|
591
|
-
try:
|
|
592
|
-
models_payload = client.list_models()
|
|
593
|
-
current_model = choose_default_model(models_payload)
|
|
594
|
-
print_models(models_payload, current_model)
|
|
595
|
-
print()
|
|
596
|
-
except requests.RequestException as e:
|
|
597
|
-
print_error(f"impossible de récupérer la liste des modèles ({e})")
|
|
598
|
-
print()
|
|
599
|
-
|
|
600
|
-
print(rgb(MUTED, "Tape ton message, ou /help pour la liste des commandes.\n"))
|
|
601
|
-
|
|
602
|
-
while True:
|
|
603
|
-
try:
|
|
604
|
-
user_input = input(rgb(USER_COLOR, "vous", bold=True) + rgb(MUTED, " › "))
|
|
605
|
-
except (EOFError, KeyboardInterrupt):
|
|
606
|
-
print()
|
|
607
|
-
break
|
|
608
|
-
|
|
609
|
-
user_input = user_input.strip()
|
|
610
|
-
if not user_input:
|
|
611
|
-
continue
|
|
612
|
-
|
|
613
|
-
if user_input.startswith("/"):
|
|
614
|
-
cmd, *rest = user_input[1:].split(maxsplit=1)
|
|
615
|
-
arg = rest[0].strip() if rest else ""
|
|
616
|
-
cmd = cmd.lower()
|
|
617
|
-
|
|
618
|
-
if cmd in ("quit", "exit"):
|
|
619
|
-
break
|
|
620
|
-
elif cmd == "help":
|
|
621
|
-
print(rgb(MUTED, HELP_TEXT))
|
|
622
|
-
elif cmd == "clear":
|
|
623
|
-
sys.stdout.write("\033[H\033[J")
|
|
624
|
-
sys.stdout.flush()
|
|
625
|
-
elif cmd == "status":
|
|
626
|
-
print_status_panel(url, api_key, connected, models_loaded)
|
|
627
|
-
elif cmd == "save":
|
|
628
|
-
if not transcript:
|
|
629
|
-
print(rgb(MUTED, " rien à enregistrer pour l'instant."))
|
|
630
|
-
else:
|
|
631
|
-
path = save_transcript(transcript, arg or None)
|
|
632
|
-
print(rgb(ACCENT, f"→ conversation enregistrée dans {path}"))
|
|
633
|
-
elif cmd == "models":
|
|
634
|
-
try:
|
|
635
|
-
models_payload = client.list_models()
|
|
636
|
-
print_models(models_payload, current_model)
|
|
637
|
-
except requests.RequestException as e:
|
|
638
|
-
print_error(f"requête échouée ({e})")
|
|
639
|
-
elif cmd == "model":
|
|
640
|
-
if not arg:
|
|
641
|
-
current_model = select_model_interactively(models_payload, current_model)
|
|
642
|
-
else:
|
|
643
|
-
resolved = resolve_model_choice(arg, models_payload.get("models", [])) or arg
|
|
644
|
-
current_model = resolved
|
|
645
|
-
print(rgb(ACCENT, f"→ modèle changé pour '{resolved}'"))
|
|
646
|
-
else:
|
|
647
|
-
print_error(f"commande inconnue : /{cmd}")
|
|
648
|
-
continue
|
|
649
|
-
|
|
650
|
-
transcript.append({"role": "user", "text": user_input})
|
|
651
|
-
|
|
652
|
-
start_time = time.perf_counter()
|
|
653
|
-
anim = OctopusAnimation(
|
|
654
|
-
FRAMES_THINKING, label="Opsiom réfléchit", interval=0.2,
|
|
655
|
-
dynamic=lambda: f" {time.perf_counter() - start_time:0.1f}s",
|
|
656
|
-
)
|
|
657
|
-
anim.start()
|
|
658
|
-
try:
|
|
659
|
-
result = client.chat(user_input, model=current_model)
|
|
660
|
-
except KeyboardInterrupt:
|
|
661
|
-
anim.stop()
|
|
662
|
-
print(rgb(MUTED, " (requête annulée — le serveur continue peut-être de calculer en arrière-plan)"))
|
|
663
|
-
continue
|
|
664
|
-
except requests.RequestException as e:
|
|
665
|
-
anim.stop()
|
|
666
|
-
show_error_animation()
|
|
667
|
-
print_error(f"la requête a échoué ({e})")
|
|
668
|
-
continue
|
|
669
|
-
anim.stop()
|
|
670
|
-
elapsed_ms = int((time.perf_counter() - start_time) * 1000)
|
|
671
|
-
|
|
672
|
-
flash_sparkle()
|
|
673
|
-
response_text = result.get("response", "(réponse vide)")
|
|
674
|
-
used_model = result.get("model", current_model or "?")
|
|
675
|
-
print_assistant_message(response_text, used_model, elapsed_ms)
|
|
676
|
-
print_separator()
|
|
677
|
-
transcript.append({"role": "assistant", "text": response_text, "model": used_model, "elapsed_ms": elapsed_ms})
|
|
678
|
-
|
|
679
|
-
save_history()
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
# ----------------------------------------------------------------------------
|
|
683
|
-
# Point d'entrée
|
|
684
|
-
# ----------------------------------------------------------------------------
|
|
685
|
-
|
|
686
|
-
def main():
|
|
687
|
-
parser = argparse.ArgumentParser(prog="opsiom", description="CLI de chat pour Opsiom")
|
|
688
|
-
parser.add_argument("--url", default=None, help="URL du serveur Opsiom (sinon config/prompt)")
|
|
689
|
-
parser.add_argument("--api-key", default=None, help="clé API si le serveur en exige une")
|
|
690
|
-
parser.add_argument("--configure", action="store_true", help="reconfigure l'URL/clé et les réenregistre")
|
|
691
|
-
parser.add_argument("--no-animation", action="store_true", help="désactive l'animation d'intro")
|
|
692
|
-
parser.add_argument("--version", action="version", version=f"opsiom-cli {VERSION}")
|
|
693
|
-
args = parser.parse_args()
|
|
694
|
-
|
|
695
|
-
if not args.no_animation:
|
|
696
|
-
print_intro_animation()
|
|
697
|
-
else:
|
|
698
|
-
print(gradient_block(TITLE))
|
|
699
|
-
print()
|
|
700
|
-
|
|
701
|
-
url, api_key = resolve_settings(args)
|
|
702
|
-
if not url:
|
|
703
|
-
print_error("aucune URL configurée, abandon.")
|
|
704
|
-
sys.exit(1)
|
|
705
|
-
|
|
706
|
-
client = OpsiomClient(url, api_key=api_key)
|
|
707
|
-
run_chat(client, url, api_key)
|
|
708
|
-
print(rgb(MUTED, "\nÀ bientôt.\n"))
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
if __name__ == "__main__":
|
|
712
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|