opsiom-cli 1.2.4__tar.gz → 1.3.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.2.4 → opsiom_cli-1.3.0}/PKG-INFO +1 -1
- {opsiom_cli-1.2.4 → opsiom_cli-1.3.0}/pyproject.toml +2 -2
- {opsiom_cli-1.2.4 → opsiom_cli-1.3.0}/src/opsiom_cli/cli.py +230 -55
- {opsiom_cli-1.2.4 → opsiom_cli-1.3.0}/src/opsiom_cli.egg-info/PKG-INFO +1 -1
- {opsiom_cli-1.2.4 → opsiom_cli-1.3.0}/README.md +0 -0
- {opsiom_cli-1.2.4 → opsiom_cli-1.3.0}/setup.cfg +0 -0
- {opsiom_cli-1.2.4 → opsiom_cli-1.3.0}/src/opsiom_cli/__init__.py +0 -0
- {opsiom_cli-1.2.4 → opsiom_cli-1.3.0}/src/opsiom_cli.egg-info/SOURCES.txt +0 -0
- {opsiom_cli-1.2.4 → opsiom_cli-1.3.0}/src/opsiom_cli.egg-info/dependency_links.txt +0 -0
- {opsiom_cli-1.2.4 → opsiom_cli-1.3.0}/src/opsiom_cli.egg-info/entry_points.txt +0 -0
- {opsiom_cli-1.2.4 → opsiom_cli-1.3.0}/src/opsiom_cli.egg-info/requires.txt +0 -0
- {opsiom_cli-1.2.4 → opsiom_cli-1.3.0}/src/opsiom_cli.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "opsiom-cli"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.3.0"
|
|
8
8
|
description = "CLI de chat pour Opsiom, l'assistant IA francophone."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -20,4 +20,4 @@ Homepage = "https://github.com/ton-compte/opsiom-cli"
|
|
|
20
20
|
opsiom = "opsiom_cli.cli:main"
|
|
21
21
|
|
|
22
22
|
[tool.setuptools.packages.find]
|
|
23
|
-
where = ["src"]
|
|
23
|
+
where = ["src"]
|
|
@@ -23,7 +23,6 @@ import json
|
|
|
23
23
|
import time
|
|
24
24
|
import shutil
|
|
25
25
|
import hashlib
|
|
26
|
-
import textwrap
|
|
27
26
|
import argparse
|
|
28
27
|
import threading
|
|
29
28
|
import itertools
|
|
@@ -40,12 +39,18 @@ try:
|
|
|
40
39
|
except ImportError:
|
|
41
40
|
readline = None # absent par défaut sur certains Windows (pyreadline3 comble le manque)
|
|
42
41
|
|
|
43
|
-
VERSION = "1.
|
|
42
|
+
VERSION = "1.3.0"
|
|
44
43
|
CONFIG_PATH = Path.home() / ".config" / "opsiom" / "config.json"
|
|
45
44
|
HISTORY_PATH = Path.home() / ".local" / "share" / "opsiom" / "history"
|
|
46
45
|
TRANSCRIPTS_DIR = Path.home() / "opsiom-conversations"
|
|
47
46
|
USAGE_PATH = Path.home() / ".config" / "opsiom" / "usage.json"
|
|
48
|
-
|
|
47
|
+
# Quota de repli (= forfait Free, voir plans.py côté front) : utilisé UNIQUEMENT
|
|
48
|
+
# tant que le serveur n'a pas encore renvoyé la vraie limite du compte. La
|
|
49
|
+
# limite réelle dépend du forfait (Free 2000 / Plus 10 000 / Pro 50 000 tokens
|
|
50
|
+
# par jour) et fait foi dès la première réponse du serveur.
|
|
51
|
+
DAILY_TOKEN_QUOTA = 2000
|
|
52
|
+
UNLIMITED_THRESHOLD = 10**9 # même valeur que UNLIMITED_REMAINING côté front
|
|
53
|
+
PLAN_LABELS = {"free": "Opsiom Free", "plus": "Opsiom Plus", "pro": "Opsiom Pro"}
|
|
49
54
|
|
|
50
55
|
|
|
51
56
|
# ----------------------------------------------------------------------------
|
|
@@ -142,9 +147,11 @@ def print_header(url, connected, api_key, models_loaded, current_model, show_mar
|
|
|
142
147
|
print(f" {status_symbol} {muted('connecté à')} {url}")
|
|
143
148
|
print(f" {muted('auth')} {muted('clé API' if api_key else 'aucune')}")
|
|
144
149
|
if api_key:
|
|
145
|
-
|
|
146
|
-
quota_color = error_text if remaining <= 0 else muted
|
|
147
|
-
|
|
150
|
+
state = get_quota_state(api_key)
|
|
151
|
+
quota_color = error_text if (not state["unlimited"] and state["remaining"] <= 0) else muted
|
|
152
|
+
if state["plan"]:
|
|
153
|
+
print(f" {muted('forfait')} {accent(plan_label(state['plan']))}")
|
|
154
|
+
print(f" {muted('quota')} {quota_color(format_quota(state))}")
|
|
148
155
|
if models_loaded:
|
|
149
156
|
print(f" {muted('modèles')} {' · '.join(models_loaded)}")
|
|
150
157
|
if current_model:
|
|
@@ -281,11 +288,13 @@ def resolve_settings(args):
|
|
|
281
288
|
|
|
282
289
|
|
|
283
290
|
# ----------------------------------------------------------------------------
|
|
284
|
-
# Quota
|
|
285
|
-
#
|
|
286
|
-
#
|
|
287
|
-
#
|
|
288
|
-
#
|
|
291
|
+
# Quota — dépend du FORFAIT du compte (Free 2000 / Plus 10 000 / Pro 50 000
|
|
292
|
+
# tokens par jour ; les comptes administrateurs sont illimités). La limite
|
|
293
|
+
# n'est plus codée en dur : le serveur renvoie la limite et le quota restant
|
|
294
|
+
# avec chaque réponse, et c'est cette valeur qui fait foi. Le fichier
|
|
295
|
+
# usage.json ne sert que de cache d'affichage (par exemple pour /quota juste
|
|
296
|
+
# après le lancement, avant la toute première requête de la session). Le
|
|
297
|
+
# serveur refuse de toute façon les requêtes une fois son compteur à zéro (429).
|
|
289
298
|
# ----------------------------------------------------------------------------
|
|
290
299
|
|
|
291
300
|
def _api_key_id(api_key):
|
|
@@ -308,6 +317,10 @@ def estimate_tokens(text):
|
|
|
308
317
|
return max(1, round(len(text) / 4))
|
|
309
318
|
|
|
310
319
|
|
|
320
|
+
def plan_label(plan_id):
|
|
321
|
+
return PLAN_LABELS.get(plan_id, str(plan_id))
|
|
322
|
+
|
|
323
|
+
|
|
311
324
|
def load_usage():
|
|
312
325
|
if USAGE_PATH.exists():
|
|
313
326
|
try:
|
|
@@ -323,24 +336,61 @@ def save_usage(usage):
|
|
|
323
336
|
|
|
324
337
|
|
|
325
338
|
def get_quota_state(api_key):
|
|
326
|
-
"""
|
|
327
|
-
réinitialise tout seul dès que la date
|
|
328
|
-
du jour
|
|
339
|
+
"""État du quota sous forme de dict : used, remaining, limit, plan,
|
|
340
|
+
unlimited. Le compteur d'usage se réinitialise tout seul dès que la date
|
|
341
|
+
enregistrée n'est plus celle du jour ; la limite et le forfait, eux,
|
|
342
|
+
restent ceux du dernier échange avec le serveur."""
|
|
329
343
|
entry = load_usage().get(_api_key_id(api_key), {})
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
344
|
+
limit = entry.get("limit") or DAILY_TOKEN_QUOTA
|
|
345
|
+
unlimited = bool(entry.get("unlimited"))
|
|
346
|
+
plan = entry.get("plan")
|
|
347
|
+
used = entry.get("tokens_used", 0) if entry.get("date") == today_str() else 0
|
|
348
|
+
remaining = UNLIMITED_THRESHOLD if unlimited else max(0, limit - used)
|
|
349
|
+
return {"used": used, "remaining": remaining, "limit": limit, "plan": plan, "unlimited": unlimited}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def format_quota(state):
|
|
353
|
+
if state["unlimited"]:
|
|
354
|
+
return "illimité"
|
|
355
|
+
return f"{state['used']}/{state['limit']} tokens aujourd’hui ({state['remaining']} restants)"
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def parse_quota_event(event):
|
|
359
|
+
"""Extrait (remaining, limit, plan, unlimited) de l'événement final du
|
|
360
|
+
flux. Accepte à la fois les champs à plat (remaining_quota, quota_limit,
|
|
361
|
+
plan) et un objet `quota` au format du front : {used, limit, remaining,
|
|
362
|
+
plan, unlimited}. Les champs absents valent None."""
|
|
363
|
+
q = event.get("quota") if isinstance(event.get("quota"), dict) else {}
|
|
364
|
+
remaining = event.get("remaining_quota")
|
|
365
|
+
if remaining is None:
|
|
366
|
+
remaining = q.get("remaining")
|
|
367
|
+
limit = event.get("quota_limit", event.get("limit"))
|
|
368
|
+
if limit is None:
|
|
369
|
+
limit = q.get("limit")
|
|
370
|
+
plan = event.get("plan") or q.get("plan")
|
|
371
|
+
unlimited = bool(event.get("unlimited") or q.get("unlimited"))
|
|
372
|
+
if remaining is not None and remaining >= UNLIMITED_THRESHOLD:
|
|
373
|
+
unlimited = True
|
|
374
|
+
return remaining, limit, plan, unlimited
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def record_authoritative_quota(api_key, remaining, limit=None, plan=None, unlimited=False):
|
|
378
|
+
"""Enregistre ce que le serveur vient de dire (quota restant, et si
|
|
379
|
+
fournis : limite du forfait et nom du forfait). Si la limite n'est pas
|
|
380
|
+
renvoyée, on garde la dernière connue plutôt que de retomber sur la
|
|
381
|
+
valeur de repli."""
|
|
382
|
+
if remaining is None and not unlimited:
|
|
383
|
+
return
|
|
337
384
|
usage = load_usage()
|
|
338
385
|
key_id = _api_key_id(api_key)
|
|
339
|
-
|
|
340
|
-
if
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
386
|
+
previous = usage.get(key_id, {})
|
|
387
|
+
if unlimited:
|
|
388
|
+
usage[key_id] = {"date": today_str(), "tokens_used": 0, "unlimited": True,
|
|
389
|
+
"limit": previous.get("limit") or DAILY_TOKEN_QUOTA, "plan": plan or previous.get("plan")}
|
|
390
|
+
else:
|
|
391
|
+
eff_limit = limit or previous.get("limit") or DAILY_TOKEN_QUOTA
|
|
392
|
+
usage[key_id] = {"date": today_str(), "tokens_used": max(0, eff_limit - remaining),
|
|
393
|
+
"limit": eff_limit, "unlimited": False, "plan": plan or previous.get("plan")}
|
|
344
394
|
save_usage(usage)
|
|
345
395
|
|
|
346
396
|
|
|
@@ -377,6 +427,33 @@ class OpsiomClient:
|
|
|
377
427
|
r.raise_for_status()
|
|
378
428
|
return r.json()
|
|
379
429
|
|
|
430
|
+
def chat_stream(self, message, model=None, **params):
|
|
431
|
+
"""Générateur : cède chaque fragment de texte au fur et à mesure,
|
|
432
|
+
puis le dict final {'done', 'model', 'tokens_used', 'remaining_quota'}.
|
|
433
|
+
Lève requests.HTTPError si le serveur refuse la requête (401/429/...) —
|
|
434
|
+
dans ce cas rien n'a encore été cédé, l'appelant peut afficher l'erreur
|
|
435
|
+
comme pour un appel non-streamé.
|
|
436
|
+
"""
|
|
437
|
+
payload = {"message": message}
|
|
438
|
+
if model:
|
|
439
|
+
payload["model"] = model
|
|
440
|
+
payload.update(params)
|
|
441
|
+
r = self.session.post(
|
|
442
|
+
f"{self.base_url}/api/chat/stream", json=payload,
|
|
443
|
+
timeout=self.timeout, stream=True,
|
|
444
|
+
)
|
|
445
|
+
r.raise_for_status()
|
|
446
|
+
for line in r.iter_lines(decode_unicode=True):
|
|
447
|
+
if not line or not line.startswith("data: "):
|
|
448
|
+
continue
|
|
449
|
+
event = json.loads(line[len("data: "):])
|
|
450
|
+
if event.get("done"):
|
|
451
|
+
yield event
|
|
452
|
+
return
|
|
453
|
+
token = event.get("token")
|
|
454
|
+
if token:
|
|
455
|
+
yield token
|
|
456
|
+
|
|
380
457
|
|
|
381
458
|
# ----------------------------------------------------------------------------
|
|
382
459
|
# Interface interactive
|
|
@@ -454,13 +531,59 @@ def select_model_interactively(models_payload, current_model):
|
|
|
454
531
|
return resolved
|
|
455
532
|
|
|
456
533
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
534
|
+
class StreamPrinter:
|
|
535
|
+
"""Affiche la réponse au fur et à mesure qu'elle arrive (streaming),
|
|
536
|
+
avec un retour à la ligne automatique à la largeur du terminal.
|
|
537
|
+
|
|
538
|
+
Contrairement à un `textwrap.fill` relancé à chaque fragment reçu (qui
|
|
539
|
+
réimprimerait tout depuis le début et ferait scintiller le terminal),
|
|
540
|
+
on ne pousse en sortie que du texte jamais encore affiché, mot par mot.
|
|
541
|
+
"""
|
|
542
|
+
|
|
543
|
+
def __init__(self, indent=" "):
|
|
544
|
+
self.indent = indent
|
|
545
|
+
self.width = max(term_width() - len(indent), 30)
|
|
546
|
+
self.line_len = 0
|
|
547
|
+
self.buffer = ""
|
|
548
|
+
self.started = False
|
|
549
|
+
|
|
550
|
+
def _flush_word(self, word):
|
|
551
|
+
if not self.started:
|
|
552
|
+
sys.stdout.write("\n" + self.indent)
|
|
553
|
+
self.started = True
|
|
554
|
+
if self.line_len > 0 and self.line_len + 1 + len(word) > self.width:
|
|
555
|
+
sys.stdout.write("\n" + self.indent)
|
|
556
|
+
self.line_len = 0
|
|
557
|
+
elif self.line_len > 0:
|
|
558
|
+
sys.stdout.write(" ")
|
|
559
|
+
self.line_len += 1
|
|
560
|
+
sys.stdout.write(word)
|
|
561
|
+
self.line_len += len(word)
|
|
562
|
+
sys.stdout.flush()
|
|
563
|
+
|
|
564
|
+
def feed(self, chunk):
|
|
565
|
+
self.buffer += chunk
|
|
566
|
+
while True:
|
|
567
|
+
idx = next((i for i, c in enumerate(self.buffer) if c in " \n"), None)
|
|
568
|
+
if idx is None:
|
|
569
|
+
break
|
|
570
|
+
word, sep = self.buffer[:idx], self.buffer[idx]
|
|
571
|
+
if word:
|
|
572
|
+
self._flush_word(word)
|
|
573
|
+
if sep == "\n":
|
|
574
|
+
if not self.started:
|
|
575
|
+
sys.stdout.write("\n" + self.indent)
|
|
576
|
+
self.started = True
|
|
577
|
+
sys.stdout.write("\n" + self.indent)
|
|
578
|
+
self.line_len = 0
|
|
579
|
+
sys.stdout.flush()
|
|
580
|
+
self.buffer = self.buffer[idx + 1:]
|
|
581
|
+
|
|
582
|
+
def close(self):
|
|
583
|
+
if self.buffer:
|
|
584
|
+
self._flush_word(self.buffer)
|
|
585
|
+
self.buffer = ""
|
|
586
|
+
print()
|
|
464
587
|
|
|
465
588
|
|
|
466
589
|
def run_chat(client: OpsiomClient, url, api_key):
|
|
@@ -519,8 +642,14 @@ def run_chat(client: OpsiomClient, url, api_key):
|
|
|
519
642
|
elif cmd == "status":
|
|
520
643
|
print_header(url, connected, api_key, models_loaded, current_model, show_mark=False)
|
|
521
644
|
elif cmd == "quota":
|
|
522
|
-
|
|
523
|
-
|
|
645
|
+
state = get_quota_state(api_key)
|
|
646
|
+
if state["plan"]:
|
|
647
|
+
print(f" {muted('forfait :')} {accent(plan_label(state['plan']))}")
|
|
648
|
+
if state["unlimited"]:
|
|
649
|
+
print(f" {muted('quota illimité.')}")
|
|
650
|
+
else:
|
|
651
|
+
u, lim, rem = state["used"], state["limit"], state["remaining"]
|
|
652
|
+
print(f" {muted(f'{u}/{lim} tokens utilisés aujourd’hui — {rem} restants.')}")
|
|
524
653
|
elif cmd == "save":
|
|
525
654
|
if not transcript:
|
|
526
655
|
print(muted(" rien à enregistrer pour l'instant."))
|
|
@@ -545,11 +674,12 @@ def run_chat(client: OpsiomClient, url, api_key):
|
|
|
545
674
|
print()
|
|
546
675
|
continue
|
|
547
676
|
|
|
548
|
-
|
|
549
|
-
if
|
|
677
|
+
state = get_quota_state(api_key)
|
|
678
|
+
if not state["unlimited"] and state["remaining"] <= 0:
|
|
679
|
+
plan_txt = f" ({plan_label(state['plan'])})" if state["plan"] else ""
|
|
550
680
|
print_error(
|
|
551
|
-
f"quota quotidien de {
|
|
552
|
-
"réessaie demain, ou
|
|
681
|
+
f"quota quotidien de {state['limit']} tokens atteint{plan_txt}.",
|
|
682
|
+
"réessaie demain, ou débloque un forfait supérieur via les missions Opsiom",
|
|
553
683
|
)
|
|
554
684
|
print()
|
|
555
685
|
continue
|
|
@@ -559,38 +689,83 @@ def run_chat(client: OpsiomClient, url, api_key):
|
|
|
559
689
|
spinner = Spinner(label="Réflexion")
|
|
560
690
|
spinner.start()
|
|
561
691
|
start_time = time.perf_counter()
|
|
692
|
+
printer = StreamPrinter()
|
|
693
|
+
response_parts = []
|
|
694
|
+
final_event = {}
|
|
695
|
+
spinner_running = True
|
|
562
696
|
try:
|
|
563
|
-
|
|
697
|
+
for piece in client.chat_stream(user_input, model=current_model):
|
|
698
|
+
if isinstance(piece, dict):
|
|
699
|
+
final_event = piece
|
|
700
|
+
break
|
|
701
|
+
if spinner_running:
|
|
702
|
+
spinner.stop()
|
|
703
|
+
spinner_running = False
|
|
704
|
+
response_parts.append(piece)
|
|
705
|
+
printer.feed(piece)
|
|
564
706
|
except KeyboardInterrupt:
|
|
565
|
-
|
|
566
|
-
|
|
707
|
+
if spinner_running:
|
|
708
|
+
spinner.stop()
|
|
709
|
+
print(muted("\n (requête annulée) "))
|
|
567
710
|
continue
|
|
568
711
|
except requests.HTTPError as e:
|
|
569
|
-
|
|
570
|
-
|
|
712
|
+
if spinner_running:
|
|
713
|
+
spinner.stop()
|
|
714
|
+
status = e.response.status_code if e.response is not None else None
|
|
715
|
+
detail = None
|
|
716
|
+
try:
|
|
717
|
+
detail = e.response.json().get("error")
|
|
718
|
+
except (ValueError, AttributeError):
|
|
719
|
+
pass
|
|
720
|
+
if status == 403 and detail and "modèle" in detail.lower():
|
|
721
|
+
# Le forfait du compte n'inclut pas ce modèle (ex. Free : pas de Large).
|
|
722
|
+
print_error(detail, "choisis un autre modèle avec /model, ou débloque un forfait supérieur")
|
|
723
|
+
elif status in (401, 403):
|
|
571
724
|
print_error("clé API invalide ou expirée.", "reconfigure avec : opsiom --configure")
|
|
572
|
-
elif
|
|
573
|
-
print_error("quota dépassé côté serveur.", "réessaie plus tard")
|
|
725
|
+
elif status == 429:
|
|
726
|
+
print_error("quota dépassé côté serveur.", detail or "réessaie plus tard")
|
|
574
727
|
else:
|
|
575
728
|
print_error("la requête a échoué ", str(e))
|
|
576
729
|
print()
|
|
577
730
|
continue
|
|
578
731
|
except requests.RequestException as e:
|
|
579
|
-
|
|
732
|
+
if spinner_running:
|
|
733
|
+
spinner.stop()
|
|
580
734
|
print_error("la requête a échoué ", str(e))
|
|
581
735
|
print()
|
|
582
736
|
continue
|
|
583
|
-
|
|
737
|
+
|
|
738
|
+
if spinner_running:
|
|
739
|
+
spinner.stop() # réponse vide reçue sans le moindre fragment
|
|
740
|
+
printer.close()
|
|
584
741
|
elapsed_ms = int((time.perf_counter() - start_time) * 1000)
|
|
585
742
|
|
|
586
|
-
response_text =
|
|
587
|
-
used_model =
|
|
588
|
-
|
|
743
|
+
response_text = "".join(response_parts) or "(réponse vide)"
|
|
744
|
+
used_model = final_event.get("model", current_model or "?")
|
|
745
|
+
print(f" {muted(format_elapsed(elapsed_ms))}")
|
|
589
746
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
747
|
+
remaining_quota, limit, plan, unlimited = parse_quota_event(final_event)
|
|
748
|
+
if remaining_quota is not None or unlimited:
|
|
749
|
+
record_authoritative_quota(api_key, remaining_quota, limit, plan, unlimited)
|
|
750
|
+
else:
|
|
751
|
+
# Le serveur n'a pas renvoyé de quota faisant autorité (Octix
|
|
752
|
+
# injoignable côté serveur, p. ex.) : on retombe sur l'estimation
|
|
753
|
+
# locale plutôt que de ne rien afficher.
|
|
754
|
+
estimated = estimate_tokens(user_input) + estimate_tokens(response_text)
|
|
755
|
+
usage = load_usage()
|
|
756
|
+
key_id = _api_key_id(api_key)
|
|
757
|
+
entry = usage.get(key_id, {})
|
|
758
|
+
if entry.get("date") != today_str():
|
|
759
|
+
entry = {**entry, "date": today_str(), "tokens_used": 0}
|
|
760
|
+
entry["tokens_used"] = entry.get("tokens_used", 0) + estimated
|
|
761
|
+
usage[key_id] = entry
|
|
762
|
+
save_usage(usage)
|
|
763
|
+
state = get_quota_state(api_key)
|
|
764
|
+
if state["unlimited"]:
|
|
765
|
+
print(f" {muted('quota illimité')}")
|
|
766
|
+
else:
|
|
767
|
+
rem = state["remaining"]
|
|
768
|
+
print(f" {muted(f'{rem} tokens restants aujourd’hui')}")
|
|
594
769
|
print()
|
|
595
770
|
transcript.append({"role": "assistant", "text": response_text, "model": used_model, "elapsed_ms": elapsed_ms})
|
|
596
771
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|