opsiom-cli 1.0.0__py3-none-any.whl
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/__init__.py
ADDED
opsiom_cli/cli.py
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
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
|
+
VERSION = "1.0.0"
|
|
37
|
+
CONFIG_PATH = Path.home() / ".config" / "opsiom" / "config.json"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ----------------------------------------------------------------------------
|
|
41
|
+
# Palette — dégradé violet -> rose -> rouge, en vraies couleurs 24 bits (ANSI)
|
|
42
|
+
# ----------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
RESET = "\033[0m"
|
|
45
|
+
BOLD = "\033[1m"
|
|
46
|
+
HIDE_CURSOR = "\033[?25l"
|
|
47
|
+
SHOW_CURSOR = "\033[?25h"
|
|
48
|
+
|
|
49
|
+
GRADIENT_STOPS = [
|
|
50
|
+
(124, 58, 199),
|
|
51
|
+
(168, 60, 178),
|
|
52
|
+
(214, 62, 140),
|
|
53
|
+
(235, 74, 96),
|
|
54
|
+
]
|
|
55
|
+
ACCENT = (222, 90, 176)
|
|
56
|
+
MUTED = (150, 110, 150)
|
|
57
|
+
USER_COLOR = (235, 130, 190)
|
|
58
|
+
ERROR_COLOR = (235, 90, 90)
|
|
59
|
+
SUCCESS_COLOR = (180, 230, 190)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def rgb(color, text, bold=False):
|
|
63
|
+
r, g, b = color
|
|
64
|
+
prefix = BOLD if bold else ""
|
|
65
|
+
return f"{prefix}\033[38;2;{r};{g};{b}m{text}{RESET}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _lerp(a, b, t):
|
|
69
|
+
return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def gradient_line(text, stops=GRADIENT_STOPS, bold=False):
|
|
73
|
+
n = max(len(text) - 1, 1)
|
|
74
|
+
segments = len(stops) - 1
|
|
75
|
+
out = []
|
|
76
|
+
prefix = BOLD if bold else ""
|
|
77
|
+
for i, ch in enumerate(text):
|
|
78
|
+
t = (i / n) * segments
|
|
79
|
+
seg = min(int(t), segments - 1)
|
|
80
|
+
local_t = t - seg
|
|
81
|
+
r, g, b = _lerp(stops[seg], stops[seg + 1], local_t)
|
|
82
|
+
out.append(f"{prefix}\033[38;2;{r};{g};{b}m{ch}")
|
|
83
|
+
return "".join(out) + RESET
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def gradient_lines(lines, stops=GRADIENT_STOPS):
|
|
87
|
+
n = max(len(lines) - 1, 1)
|
|
88
|
+
segments = len(stops) - 1
|
|
89
|
+
out = []
|
|
90
|
+
for i, line in enumerate(lines):
|
|
91
|
+
t = (i / n) * segments
|
|
92
|
+
seg = min(int(t), segments - 1)
|
|
93
|
+
local_t = t - seg
|
|
94
|
+
color = _lerp(stops[seg], stops[seg + 1], local_t)
|
|
95
|
+
out.append(rgb(color, line))
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def gradient_block(lines, stops=GRADIENT_STOPS):
|
|
100
|
+
return "\n".join(gradient_lines(lines, stops))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def draw_panel(lines_with_colors, min_width=46):
|
|
104
|
+
"""Panneau encadré (box-drawing) : une couleur unie par ligne, largeur auto."""
|
|
105
|
+
plain_texts = [t for t, _ in lines_with_colors]
|
|
106
|
+
content_width = max(min_width, max((len(t) for t in plain_texts), default=0) + 2)
|
|
107
|
+
top = "╭" + "─" * (content_width + 2) + "╮"
|
|
108
|
+
bottom = "╰" + "─" * (content_width + 2) + "╯"
|
|
109
|
+
out = [rgb(ACCENT, top)]
|
|
110
|
+
for text, color in lines_with_colors:
|
|
111
|
+
padded = text.ljust(content_width)
|
|
112
|
+
out.append(rgb(ACCENT, "│") + " " + rgb(color, padded) + " " + rgb(ACCENT, "│"))
|
|
113
|
+
out.append(rgb(ACCENT, bottom))
|
|
114
|
+
return "\n".join(out)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ----------------------------------------------------------------------------
|
|
118
|
+
# La mascotte — un poulpe construit à partir de gabarits (tête fixe, yeux /
|
|
119
|
+
# bouche / tentacules interchangeables) pour composer beaucoup de frames
|
|
120
|
+
# différentes sans tout redessiner à la main à chaque fois.
|
|
121
|
+
# ----------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
FRAME_HEIGHT = 13
|
|
124
|
+
|
|
125
|
+
EYES_OPEN = ("◉", "◉")
|
|
126
|
+
EYES_BLINK = ("─", "─")
|
|
127
|
+
EYES_WINK = ("◉", "◕")
|
|
128
|
+
EYES_SAD = ("×", "×")
|
|
129
|
+
|
|
130
|
+
MOUTH_NEUTRAL = "⌣"
|
|
131
|
+
MOUTH_SAD = "⌢"
|
|
132
|
+
MOUTH_OPEN = "○"
|
|
133
|
+
|
|
134
|
+
TENTACLES_REST = [
|
|
135
|
+
" / / | \\ \\",
|
|
136
|
+
" ( ( | ) )",
|
|
137
|
+
" \\ \\ | / /",
|
|
138
|
+
" `._\\ | /_.'",
|
|
139
|
+
]
|
|
140
|
+
TENTACLES_REST_CUPS = [
|
|
141
|
+
" / / o \\ \\",
|
|
142
|
+
" ( ( | ) )",
|
|
143
|
+
" \\ \\ o / /",
|
|
144
|
+
" `._\\ | /_.'",
|
|
145
|
+
]
|
|
146
|
+
TENTACLES_LEFT = [
|
|
147
|
+
" \\ / | \\ /",
|
|
148
|
+
" ) ( | ) (",
|
|
149
|
+
" / \\ | / \\",
|
|
150
|
+
" -' `._ | _.' `-",
|
|
151
|
+
]
|
|
152
|
+
TENTACLES_RIGHT = [
|
|
153
|
+
" / \\ | / \\",
|
|
154
|
+
" ( ) | ( )",
|
|
155
|
+
" \\ / | \\ /",
|
|
156
|
+
" `-' ._|_. `-'",
|
|
157
|
+
]
|
|
158
|
+
TENTACLES_DROOP = [
|
|
159
|
+
" ~ / \\ | / \\ ~",
|
|
160
|
+
" ~ ( ) | ( ) ~",
|
|
161
|
+
" ~ \\ / | \\ / ~",
|
|
162
|
+
" * `.|.` *",
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def build_frame(top, eyes, mouth, tentacles, bottom=""):
|
|
167
|
+
left_eye, right_eye = eyes
|
|
168
|
+
return [
|
|
169
|
+
top,
|
|
170
|
+
" .───────.",
|
|
171
|
+
" .─' '─.",
|
|
172
|
+
f" / {left_eye} {right_eye} \\",
|
|
173
|
+
" | |",
|
|
174
|
+
f" | {mouth} |",
|
|
175
|
+
" \\ /",
|
|
176
|
+
" '─..-----..─'",
|
|
177
|
+
tentacles[0],
|
|
178
|
+
tentacles[1],
|
|
179
|
+
tentacles[2],
|
|
180
|
+
tentacles[3],
|
|
181
|
+
bottom,
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# --- Respiration au repos (2 frames) ---------------------------------------
|
|
186
|
+
FRAMES_BREATHE = [
|
|
187
|
+
build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_REST),
|
|
188
|
+
build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_REST_CUPS),
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
# --- Clin d'œil, inséré de temps en temps dans les cycles -------------------
|
|
192
|
+
FRAME_BLINK = build_frame("", EYES_BLINK, MOUTH_NEUTRAL, TENTACLES_REST)
|
|
193
|
+
|
|
194
|
+
# --- Ondulation des tentacules (4 frames) -----------------------------------
|
|
195
|
+
FRAMES_WAVE = [
|
|
196
|
+
build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_LEFT),
|
|
197
|
+
build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_REST_CUPS),
|
|
198
|
+
build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_RIGHT),
|
|
199
|
+
FRAME_BLINK,
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
# --- Bulles de réflexion qui montent (utilisées pendant l'appel réseau) ----
|
|
203
|
+
_BUBBLE_STAGES = ["", " .", " o",
|
|
204
|
+
" O", " 0",
|
|
205
|
+
" °", ""]
|
|
206
|
+
_THINKING_TENTACLES = itertools.cycle([TENTACLES_REST, TENTACLES_LEFT, TENTACLES_REST_CUPS, TENTACLES_RIGHT])
|
|
207
|
+
FRAMES_THINKING = [
|
|
208
|
+
build_frame(bubble, EYES_BLINK if i == 3 else EYES_OPEN, MOUTH_NEUTRAL, next(_THINKING_TENTACLES))
|
|
209
|
+
for i, bubble in enumerate(_BUBBLE_STAGES)
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
# --- Salut d'accueil : un tentacule se lève et fait coucou -----------------
|
|
213
|
+
FRAMES_GREETING = [
|
|
214
|
+
[
|
|
215
|
+
" o",
|
|
216
|
+
" .───────. /",
|
|
217
|
+
" .─' '─. _/",
|
|
218
|
+
" / ◉ ◕ \\",
|
|
219
|
+
" | |",
|
|
220
|
+
" | ⌣ |",
|
|
221
|
+
" \\ /",
|
|
222
|
+
" '─..-----..─'",
|
|
223
|
+
" / / | \\ \\",
|
|
224
|
+
" ( ( | ) )",
|
|
225
|
+
" \\ \\ | / /",
|
|
226
|
+
" `._\\ | /_.'",
|
|
227
|
+
"",
|
|
228
|
+
],
|
|
229
|
+
[
|
|
230
|
+
" \\ o",
|
|
231
|
+
" .───────. \\",
|
|
232
|
+
" .─' '─.",
|
|
233
|
+
" / ◕ ◉ \\",
|
|
234
|
+
" | |",
|
|
235
|
+
" | ⌣ |",
|
|
236
|
+
" \\ /",
|
|
237
|
+
" '─..-----..─'",
|
|
238
|
+
" / / | \\ \\",
|
|
239
|
+
" ( ( | ) )",
|
|
240
|
+
" \\ \\ | / /",
|
|
241
|
+
" `._\\ | /_.'",
|
|
242
|
+
"",
|
|
243
|
+
],
|
|
244
|
+
build_frame("", EYES_WINK, MOUTH_NEUTRAL, TENTACLES_REST),
|
|
245
|
+
build_frame("", EYES_OPEN, MOUTH_NEUTRAL, TENTACLES_REST),
|
|
246
|
+
]
|
|
247
|
+
|
|
248
|
+
# --- Nuage d'encre + mine déconfite (erreur réseau) -------------------------
|
|
249
|
+
FRAMES_ERROR = [
|
|
250
|
+
build_frame(" . *", EYES_SAD, MOUTH_SAD, TENTACLES_DROOP, " * ~ ."),
|
|
251
|
+
build_frame(" * . *", EYES_SAD, MOUTH_SAD, TENTACLES_DROOP, " . ~ * ~ ."),
|
|
252
|
+
]
|
|
253
|
+
|
|
254
|
+
# --- Petite étincelle affichée brièvement après une réponse réussie --------
|
|
255
|
+
SPARKLE_FRAMES = [" ✦", " · ✦ ·", " ˚ · ✦ · ˚"]
|
|
256
|
+
|
|
257
|
+
TITLE = [
|
|
258
|
+
" ██████╗ ██████╗ ███████╗██╗ ██████╗ ███╗ ███╗",
|
|
259
|
+
"██╔═══██╗██╔══██╗██╔════╝██║██╔═══██╗████╗ ████║",
|
|
260
|
+
"██║ ██║██████╔╝███████╗██║██║ ██║██╔████╔██║",
|
|
261
|
+
"██║ ██║██╔═══╝ ╚════██║██║██║ ██║██║╚██╔╝██║",
|
|
262
|
+
"╚██████╔╝██║ ███████║██║╚██████╔╝██║ ╚═╝ ██║",
|
|
263
|
+
" ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═╝",
|
|
264
|
+
]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ----------------------------------------------------------------------------
|
|
268
|
+
# Moteur d'animation générique — anime n'importe quelle liste de frames,
|
|
269
|
+
# avec ou sans étiquette de texte sous le poulpe.
|
|
270
|
+
# ----------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
class OctopusAnimation:
|
|
273
|
+
def __init__(self, frames, label=None, interval=0.2, loop=True):
|
|
274
|
+
self.frames = frames
|
|
275
|
+
self.label = label
|
|
276
|
+
self.interval = interval
|
|
277
|
+
self.loop = loop
|
|
278
|
+
self._stop_event = threading.Event()
|
|
279
|
+
self._thread = None
|
|
280
|
+
|
|
281
|
+
def _iter_frames(self):
|
|
282
|
+
return itertools.cycle(self.frames) if self.loop else iter(self.frames)
|
|
283
|
+
|
|
284
|
+
def _run(self):
|
|
285
|
+
dots = itertools.cycle([" ", ". ", ".. ", "..."]) if self.label else None
|
|
286
|
+
n_lines = FRAME_HEIGHT + (1 if self.label else 0)
|
|
287
|
+
print(HIDE_CURSOR, end="")
|
|
288
|
+
first = True
|
|
289
|
+
for frame in self._iter_frames():
|
|
290
|
+
if self._stop_event.is_set():
|
|
291
|
+
break
|
|
292
|
+
if not first:
|
|
293
|
+
sys.stdout.write(f"\033[{n_lines}A")
|
|
294
|
+
first = False
|
|
295
|
+
for line in gradient_lines(frame):
|
|
296
|
+
sys.stdout.write(line + "\033[K\n")
|
|
297
|
+
if self.label:
|
|
298
|
+
sys.stdout.write(rgb(ACCENT, f" {self.label}{next(dots)}") + "\033[K\n")
|
|
299
|
+
sys.stdout.flush()
|
|
300
|
+
time.sleep(self.interval)
|
|
301
|
+
sys.stdout.write(f"\033[{n_lines}A")
|
|
302
|
+
for _ in range(n_lines):
|
|
303
|
+
sys.stdout.write("\033[K\n")
|
|
304
|
+
sys.stdout.write(f"\033[{n_lines}A")
|
|
305
|
+
sys.stdout.write(SHOW_CURSOR)
|
|
306
|
+
sys.stdout.flush()
|
|
307
|
+
|
|
308
|
+
def start(self):
|
|
309
|
+
self._stop_event.clear()
|
|
310
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
311
|
+
self._thread.start()
|
|
312
|
+
|
|
313
|
+
def stop(self):
|
|
314
|
+
self._stop_event.set()
|
|
315
|
+
if self._thread:
|
|
316
|
+
self._thread.join()
|
|
317
|
+
|
|
318
|
+
def play_once_blocking(self):
|
|
319
|
+
"""Joue la séquence une fois, sans thread, puis rend la main (pour l'intro)."""
|
|
320
|
+
self.loop = False
|
|
321
|
+
print(HIDE_CURSOR, end="")
|
|
322
|
+
for frame in self.frames:
|
|
323
|
+
sys.stdout.write("\033[H\033[J")
|
|
324
|
+
sys.stdout.write("\n" + gradient_block(frame) + "\n")
|
|
325
|
+
sys.stdout.flush()
|
|
326
|
+
time.sleep(self.interval)
|
|
327
|
+
print(SHOW_CURSOR, end="")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def print_intro_animation():
|
|
331
|
+
OctopusAnimation(FRAMES_GREETING + [FRAMES_BREATHE[0]], interval=0.22).play_once_blocking()
|
|
332
|
+
sys.stdout.write("\033[H\033[J")
|
|
333
|
+
print()
|
|
334
|
+
print(gradient_block(TITLE))
|
|
335
|
+
print(rgb(MUTED, f" — assistant IA francophone · v{VERSION} —\n"))
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def flash_sparkle():
|
|
339
|
+
"""Étincelle furtive affichée juste avant une réponse réussie."""
|
|
340
|
+
print(HIDE_CURSOR, end="")
|
|
341
|
+
for frame in SPARKLE_FRAMES:
|
|
342
|
+
sys.stdout.write("\r" + rgb(SUCCESS_COLOR, frame) + "\033[K")
|
|
343
|
+
sys.stdout.flush()
|
|
344
|
+
time.sleep(0.08)
|
|
345
|
+
sys.stdout.write("\r\033[K")
|
|
346
|
+
sys.stdout.write(SHOW_CURSOR)
|
|
347
|
+
sys.stdout.flush()
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def show_error_animation():
|
|
351
|
+
"""Nuage d'encre + mine déconfite, joué deux fois puis nettoyé."""
|
|
352
|
+
OctopusAnimation(FRAMES_ERROR, interval=0.3, loop=False)._run()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# ----------------------------------------------------------------------------
|
|
356
|
+
# Configuration — aucune URL n'est codée en dur : on lit env / config / prompt
|
|
357
|
+
# ----------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
def load_config():
|
|
360
|
+
if CONFIG_PATH.exists():
|
|
361
|
+
try:
|
|
362
|
+
return json.loads(CONFIG_PATH.read_text())
|
|
363
|
+
except (json.JSONDecodeError, OSError):
|
|
364
|
+
return {}
|
|
365
|
+
return {}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def save_config(config):
|
|
369
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
370
|
+
CONFIG_PATH.write_text(json.dumps(config, indent=2))
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def prompt_for_config():
|
|
374
|
+
print(rgb(ACCENT, "Configuration initiale d'Opsiom CLI", bold=True))
|
|
375
|
+
print(rgb(MUTED, " (ces informations te sont normalement transmises par la personne qui héberge Opsiom)\n"))
|
|
376
|
+
url = input(rgb(USER_COLOR, "URL du serveur Opsiom : ")).strip().rstrip("/")
|
|
377
|
+
api_key = input(rgb(USER_COLOR, "Clé API (laisser vide si aucune) : ")).strip()
|
|
378
|
+
save = input(rgb(MUTED, "Sauvegarder ces informations localement pour la prochaine fois ? [O/n] ")).strip().lower()
|
|
379
|
+
config = {"url": url, "api_key": api_key}
|
|
380
|
+
if save in ("", "o", "oui", "y", "yes"):
|
|
381
|
+
save_config(config)
|
|
382
|
+
print(rgb(MUTED, f" → enregistré dans {CONFIG_PATH}\n"))
|
|
383
|
+
return config
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def resolve_settings(args):
|
|
387
|
+
config = {} if args.configure else load_config()
|
|
388
|
+
url = args.url or os.environ.get("OPSIOM_URL") or config.get("url")
|
|
389
|
+
api_key = args.api_key or os.environ.get("OPSIOM_API_KEY") or config.get("api_key")
|
|
390
|
+
if args.configure or not url:
|
|
391
|
+
new_config = prompt_for_config()
|
|
392
|
+
url = new_config["url"] or url
|
|
393
|
+
api_key = new_config["api_key"] or api_key
|
|
394
|
+
return url, api_key
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# ----------------------------------------------------------------------------
|
|
398
|
+
# Client API
|
|
399
|
+
# ----------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
class OpsiomClient:
|
|
402
|
+
def __init__(self, base_url, api_key=None, timeout=60):
|
|
403
|
+
self.base_url = base_url.rstrip("/")
|
|
404
|
+
self.timeout = timeout
|
|
405
|
+
self.session = requests.Session()
|
|
406
|
+
headers = {"ngrok-skip-browser-warning": "true"}
|
|
407
|
+
if api_key:
|
|
408
|
+
headers["X-API-Key"] = api_key
|
|
409
|
+
self.session.headers.update(headers)
|
|
410
|
+
|
|
411
|
+
def health(self):
|
|
412
|
+
r = self.session.get(f"{self.base_url}/api/health", timeout=10)
|
|
413
|
+
r.raise_for_status()
|
|
414
|
+
return r.json()
|
|
415
|
+
|
|
416
|
+
def list_models(self):
|
|
417
|
+
r = self.session.get(f"{self.base_url}/api/models", timeout=10)
|
|
418
|
+
r.raise_for_status()
|
|
419
|
+
return r.json()
|
|
420
|
+
|
|
421
|
+
def chat(self, message, model=None, **params):
|
|
422
|
+
payload = {"message": message}
|
|
423
|
+
if model:
|
|
424
|
+
payload["model"] = model
|
|
425
|
+
payload.update(params)
|
|
426
|
+
r = self.session.post(f"{self.base_url}/api/chat", json=payload, timeout=self.timeout)
|
|
427
|
+
r.raise_for_status()
|
|
428
|
+
return r.json()
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# ----------------------------------------------------------------------------
|
|
432
|
+
# Interface interactive
|
|
433
|
+
# ----------------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
HELP_TEXT = """
|
|
436
|
+
Commandes disponibles :
|
|
437
|
+
/model menu numéroté pour choisir le modèle
|
|
438
|
+
/model <id|num> change directement de modèle (ex: /model small, /model 2)
|
|
439
|
+
/models liste les modèles disponibles
|
|
440
|
+
/status réaffiche le panneau de connexion
|
|
441
|
+
/clear efface l'écran
|
|
442
|
+
/help affiche cette aide
|
|
443
|
+
/quit, /exit quitte le CLI
|
|
444
|
+
"""
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def term_width(default=76):
|
|
448
|
+
return shutil.get_terminal_size((default, 20)).columns
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def print_separator():
|
|
452
|
+
print(rgb(MUTED, "─" * min(term_width(), 76)))
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def print_status_panel(url, api_key, connected, models_loaded):
|
|
456
|
+
status_text = f"● Connecté — {url}" if connected else f"⨯ Non connecté — {url}"
|
|
457
|
+
status_color = ACCENT if connected else ERROR_COLOR
|
|
458
|
+
lines = [
|
|
459
|
+
(f"OPSIOM CLI · v{VERSION}", ACCENT),
|
|
460
|
+
(status_text, status_color),
|
|
461
|
+
(f"Authentification : {'clé API fournie' if api_key else 'aucune'}", MUTED),
|
|
462
|
+
(f"Modèles chargés : {', '.join(models_loaded)}" if models_loaded else "Modèles : indisponible", MUTED),
|
|
463
|
+
]
|
|
464
|
+
print(draw_panel(lines))
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def print_assistant_message(text, model_id, elapsed_ms=None):
|
|
468
|
+
prefix_plain = f"Opsiom ({model_id}) › "
|
|
469
|
+
prefix_colored = rgb(ACCENT, "Opsiom", bold=True) + rgb(MUTED, f" ({model_id})") + rgb(MUTED, " › ")
|
|
470
|
+
width = max(term_width() - len(prefix_plain) - 12, 24)
|
|
471
|
+
wrapped = textwrap.fill(text, width=width, subsequent_indent=" " * len(prefix_plain))
|
|
472
|
+
suffix = rgb(MUTED, f" [{elapsed_ms} ms]") if elapsed_ms is not None else ""
|
|
473
|
+
print(prefix_colored + wrapped + suffix)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def print_error(text):
|
|
477
|
+
print(rgb(ERROR_COLOR, f"⨯ {text}", bold=True))
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def choose_default_model(models_payload):
|
|
481
|
+
default = models_payload.get("default")
|
|
482
|
+
models = models_payload.get("models", [])
|
|
483
|
+
if default:
|
|
484
|
+
return default
|
|
485
|
+
return models[0]["id"] if models else None
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def print_models(models_payload, current_model):
|
|
489
|
+
print(rgb(ACCENT, "Modèles disponibles :", bold=True))
|
|
490
|
+
for i, m in enumerate(models_payload.get("models", []), start=1):
|
|
491
|
+
marker = rgb(ACCENT, "●") if m["id"] == current_model else rgb(MUTED, "○")
|
|
492
|
+
detail = f"({m['params']} — id: {m['id']})"
|
|
493
|
+
print(f" {rgb(MUTED, f'[{i}]')} {marker} {gradient_line(m['label'])} {rgb(MUTED, detail)}")
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def resolve_model_choice(choice, models):
|
|
497
|
+
if choice.isdigit():
|
|
498
|
+
idx = int(choice) - 1
|
|
499
|
+
if 0 <= idx < len(models):
|
|
500
|
+
return models[idx]["id"]
|
|
501
|
+
return None
|
|
502
|
+
ids = {m["id"] for m in models}
|
|
503
|
+
if choice in ids:
|
|
504
|
+
return choice
|
|
505
|
+
matches = [m["id"] for m in models if m["id"].startswith(choice) or m["label"].lower().startswith(choice.lower())]
|
|
506
|
+
return matches[0] if len(matches) == 1 else None
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def select_model_interactively(models_payload, current_model):
|
|
510
|
+
models = models_payload.get("models", [])
|
|
511
|
+
if not models:
|
|
512
|
+
print_error("aucun modèle disponible côté serveur.")
|
|
513
|
+
return current_model
|
|
514
|
+
print_models(models_payload, current_model)
|
|
515
|
+
choice = input(rgb(USER_COLOR, "Choisis un numéro (ou Entrée pour annuler) : ")).strip()
|
|
516
|
+
if not choice:
|
|
517
|
+
return current_model
|
|
518
|
+
resolved = resolve_model_choice(choice, models)
|
|
519
|
+
if resolved is None:
|
|
520
|
+
print_error(f"choix invalide : '{choice}'")
|
|
521
|
+
return current_model
|
|
522
|
+
print(rgb(ACCENT, f"→ modèle changé pour '{resolved}'"))
|
|
523
|
+
return resolved
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def run_chat(client: OpsiomClient, url, api_key):
|
|
527
|
+
current_model = None
|
|
528
|
+
models_payload = {"models": []}
|
|
529
|
+
connected = False
|
|
530
|
+
models_loaded = []
|
|
531
|
+
try:
|
|
532
|
+
health = client.health()
|
|
533
|
+
connected = health.get("status") == "ok"
|
|
534
|
+
models_loaded = health.get("models_loaded", [])
|
|
535
|
+
except requests.RequestException:
|
|
536
|
+
connected = False
|
|
537
|
+
|
|
538
|
+
print_status_panel(url, api_key, connected, models_loaded)
|
|
539
|
+
print()
|
|
540
|
+
|
|
541
|
+
try:
|
|
542
|
+
models_payload = client.list_models()
|
|
543
|
+
current_model = choose_default_model(models_payload)
|
|
544
|
+
print_models(models_payload, current_model)
|
|
545
|
+
print()
|
|
546
|
+
except requests.RequestException as e:
|
|
547
|
+
print_error(f"impossible de récupérer la liste des modèles ({e})")
|
|
548
|
+
print()
|
|
549
|
+
|
|
550
|
+
print(rgb(MUTED, "Tape ton message, ou /help pour la liste des commandes.\n"))
|
|
551
|
+
|
|
552
|
+
while True:
|
|
553
|
+
try:
|
|
554
|
+
user_input = input(rgb(USER_COLOR, "vous", bold=True) + rgb(MUTED, " › "))
|
|
555
|
+
except (EOFError, KeyboardInterrupt):
|
|
556
|
+
print()
|
|
557
|
+
break
|
|
558
|
+
|
|
559
|
+
user_input = user_input.strip()
|
|
560
|
+
if not user_input:
|
|
561
|
+
continue
|
|
562
|
+
|
|
563
|
+
if user_input.startswith("/"):
|
|
564
|
+
cmd, *rest = user_input[1:].split(maxsplit=1)
|
|
565
|
+
arg = rest[0].strip() if rest else ""
|
|
566
|
+
cmd = cmd.lower()
|
|
567
|
+
|
|
568
|
+
if cmd in ("quit", "exit"):
|
|
569
|
+
break
|
|
570
|
+
elif cmd == "help":
|
|
571
|
+
print(rgb(MUTED, HELP_TEXT))
|
|
572
|
+
elif cmd == "clear":
|
|
573
|
+
sys.stdout.write("\033[H\033[J")
|
|
574
|
+
sys.stdout.flush()
|
|
575
|
+
elif cmd == "status":
|
|
576
|
+
print_status_panel(url, api_key, connected, models_loaded)
|
|
577
|
+
elif cmd == "models":
|
|
578
|
+
try:
|
|
579
|
+
models_payload = client.list_models()
|
|
580
|
+
print_models(models_payload, current_model)
|
|
581
|
+
except requests.RequestException as e:
|
|
582
|
+
print_error(f"requête échouée ({e})")
|
|
583
|
+
elif cmd == "model":
|
|
584
|
+
if not arg:
|
|
585
|
+
current_model = select_model_interactively(models_payload, current_model)
|
|
586
|
+
else:
|
|
587
|
+
resolved = resolve_model_choice(arg, models_payload.get("models", [])) or arg
|
|
588
|
+
current_model = resolved
|
|
589
|
+
print(rgb(ACCENT, f"→ modèle changé pour '{resolved}'"))
|
|
590
|
+
else:
|
|
591
|
+
print_error(f"commande inconnue : /{cmd}")
|
|
592
|
+
continue
|
|
593
|
+
|
|
594
|
+
anim = OctopusAnimation(FRAMES_THINKING, label="Opsiom réfléchit", interval=0.2)
|
|
595
|
+
anim.start()
|
|
596
|
+
start_time = time.perf_counter()
|
|
597
|
+
try:
|
|
598
|
+
result = client.chat(user_input, model=current_model)
|
|
599
|
+
except requests.RequestException as e:
|
|
600
|
+
anim.stop()
|
|
601
|
+
show_error_animation()
|
|
602
|
+
print_error(f"la requête a échoué ({e})")
|
|
603
|
+
continue
|
|
604
|
+
anim.stop()
|
|
605
|
+
elapsed_ms = int((time.perf_counter() - start_time) * 1000)
|
|
606
|
+
|
|
607
|
+
flash_sparkle()
|
|
608
|
+
response_text = result.get("response", "(réponse vide)")
|
|
609
|
+
used_model = result.get("model", current_model or "?")
|
|
610
|
+
print_assistant_message(response_text, used_model, elapsed_ms)
|
|
611
|
+
print_separator()
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
# ----------------------------------------------------------------------------
|
|
615
|
+
# Point d'entrée
|
|
616
|
+
# ----------------------------------------------------------------------------
|
|
617
|
+
|
|
618
|
+
def main():
|
|
619
|
+
parser = argparse.ArgumentParser(prog="opsiom", description="CLI de chat pour Opsiom")
|
|
620
|
+
parser.add_argument("--url", default=None, help="URL du serveur Opsiom (sinon config/prompt)")
|
|
621
|
+
parser.add_argument("--api-key", default=None, help="clé API si le serveur en exige une")
|
|
622
|
+
parser.add_argument("--configure", action="store_true", help="reconfigure l'URL/clé et les réenregistre")
|
|
623
|
+
parser.add_argument("--no-animation", action="store_true", help="désactive l'animation d'intro")
|
|
624
|
+
parser.add_argument("--version", action="version", version=f"opsiom-cli {VERSION}")
|
|
625
|
+
args = parser.parse_args()
|
|
626
|
+
|
|
627
|
+
if not args.no_animation:
|
|
628
|
+
print_intro_animation()
|
|
629
|
+
else:
|
|
630
|
+
print(gradient_block(TITLE))
|
|
631
|
+
print()
|
|
632
|
+
|
|
633
|
+
url, api_key = resolve_settings(args)
|
|
634
|
+
if not url:
|
|
635
|
+
print_error("aucune URL configurée, abandon.")
|
|
636
|
+
sys.exit(1)
|
|
637
|
+
|
|
638
|
+
client = OpsiomClient(url, api_key=api_key)
|
|
639
|
+
run_chat(client, url, api_key)
|
|
640
|
+
print(rgb(MUTED, "\nÀ bientôt.\n"))
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
if __name__ == "__main__":
|
|
644
|
+
main()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opsiom-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: CLI de chat pour Opsiom, l'assistant IA francophone.
|
|
5
|
+
Author-email: Ton nom <toi@example.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ton-compte/opsiom-cli
|
|
8
|
+
Keywords: opsiom,cli,chatbot,ia
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: requests>=2.31
|
|
12
|
+
|
|
13
|
+
# Opsiom CLI
|
|
14
|
+
|
|
15
|
+
Interface en ligne de commande pour discuter avec Opsiom, l'assistant IA
|
|
16
|
+
francophone — avec une mascotte poulpe animée et un thème violet/rose/rouge.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install opsiom-cli
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
(ou, en isolant proprement l'outil : `pipx install opsiom-cli`)
|
|
25
|
+
|
|
26
|
+
## Utilisation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
opsiom
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Au premier lancement, le CLI te demande l'URL du serveur Opsiom (et une
|
|
33
|
+
éventuelle clé API) puis les enregistre dans `~/.config/opsiom/config.json`.
|
|
34
|
+
**Ce paquet ne contient aucune URL de serveur codée en dur** — il te faut
|
|
35
|
+
l'URL et, si le serveur en exige une, la clé API fournie par la personne qui
|
|
36
|
+
héberge l'instance à laquelle tu veux te connecter.
|
|
37
|
+
|
|
38
|
+
Alternative sans fichier de config :
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
opsiom --url https://xxxx.ngrok-free.dev --api-key ta_cle
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Reconfigurer :
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
opsiom --configure
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Commandes du chat
|
|
51
|
+
|
|
52
|
+
- `/model` — menu numéroté pour choisir le modèle
|
|
53
|
+
- `/model <id|numéro>` — change directement de modèle
|
|
54
|
+
- `/models` — liste les modèles disponibles
|
|
55
|
+
- `/status` — réaffiche le panneau de connexion
|
|
56
|
+
- `/clear` — efface l'écran
|
|
57
|
+
- `/help` — aide
|
|
58
|
+
- `/quit`, `/exit` — quitte
|
|
59
|
+
|
|
60
|
+
## Licence
|
|
61
|
+
|
|
62
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
opsiom_cli/__init__.py,sha256=lNmdCs8Kj7aC1UJwSsfk-VfLjSgBbdIN1Brt4IhrvzM,70
|
|
2
|
+
opsiom_cli/cli.py,sha256=aNt4O0WkjNSpxcMtC3rMkYh7Fj0NtD4I17rlZWOWZwk,23727
|
|
3
|
+
opsiom_cli-1.0.0.dist-info/METADATA,sha256=lglVTpMDTRZisi79JPk1RDL6Y8f_tLky1be2NhNuxJc,1603
|
|
4
|
+
opsiom_cli-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
opsiom_cli-1.0.0.dist-info/entry_points.txt,sha256=DmuAEcK58CeDAE7WrO-DddHmWfsBPXHUiRD2CD3ca6Y,47
|
|
6
|
+
opsiom_cli-1.0.0.dist-info/top_level.txt,sha256=2BU4m7Mc8vxCKbZdiLx_RXu5I-Hi6JdGjTnj1i8b_g8,11
|
|
7
|
+
opsiom_cli-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
opsiom_cli
|