@nosserb/prmt 0.0.0-stage → 2.0.0
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.
- package/LICENSE +21 -0
- package/README.md +191 -2
- package/bin/prmt.mjs +174 -0
- package/dist/assets/AuditModule-BOO_iRy8.js +1 -0
- package/dist/assets/Audits-yUBm0EpW.js +1 -0
- package/dist/assets/BrainPage-FIWO_9Nc.js +3 -0
- package/dist/assets/BrainPage-XQCEGRc5.css +1 -0
- package/dist/assets/Carte-CPuiR1Ha.css +1 -0
- package/dist/assets/Carte-xycu1mQn.js +1 -0
- package/dist/assets/Dashboard-DKvCQcX8.js +2 -0
- package/dist/assets/Dashboard-XdhC6_hM.css +1 -0
- package/dist/assets/Field-C7c2mCbb.js +2 -0
- package/dist/assets/Journal-BLIIlh80.css +1 -0
- package/dist/assets/Journal-BaelFjwV.js +1 -0
- package/dist/assets/Landing-B2jxV3zs.css +1 -0
- package/dist/assets/Landing-Dr612Zdu.js +26 -0
- package/dist/assets/Register-DA_Aq5f8.js +1 -0
- package/dist/assets/Reports-8wfDNSxH.js +1 -0
- package/dist/assets/Reports-B8GXN95N.css +1 -0
- package/dist/assets/SavoirPage-BTxbH3LY.css +1 -0
- package/dist/assets/SavoirPage-Dp3eUCHp.js +1 -0
- package/dist/assets/SettingsPage-BElIP46c.css +1 -0
- package/dist/assets/SettingsPage-BiQbnw3o.js +1 -0
- package/dist/assets/Shell-BAyHVljp.css +1 -0
- package/dist/assets/Shell-CQRlx9-V.js +2 -0
- package/dist/assets/Table-DUmGel5e.js +1 -0
- package/dist/assets/Tabs-CfhbELZk.js +1 -0
- package/dist/assets/Terminal-Bv1IJfi9.js +3 -0
- package/dist/assets/Terminal-CEfj38NV.css +1 -0
- package/dist/assets/arrow-right-Cx3pAEhl.js +1 -0
- package/dist/assets/auditViews-BMza2Sdz.js +1 -0
- package/dist/assets/auditViews-BePWj26h.css +1 -0
- package/dist/assets/bricolage-grotesque-latin-ext-opsz-normal-IcJDqblK.woff2 +0 -0
- package/dist/assets/bricolage-grotesque-latin-opsz-normal-Cre6nC2_.woff2 +0 -0
- package/dist/assets/bricolage-grotesque-vietnamese-opsz-normal-D9N6E8K1.woff2 +0 -0
- package/dist/assets/charts-BcijSJ4J.js +1 -0
- package/dist/assets/circle-stop-C33BAcKW.js +1 -0
- package/dist/assets/command-ChA49Xk7.js +1 -0
- package/dist/assets/command-Ck4pdQCv.css +1 -0
- package/dist/assets/download-CeuKOenD.js +1 -0
- package/dist/assets/eye-CXXHBBpr.js +1 -0
- package/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/assets/habillage-B5mMBadn.js +1 -0
- package/dist/assets/index-BH1etemR.css +1 -0
- package/dist/assets/index-COSiM-R9.js +14 -0
- package/dist/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
- package/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist/assets/layers-2x-Bpkbi35X.png +0 -0
- package/dist/assets/layers-BWBAp2CZ.png +0 -0
- package/dist/assets/markdown-DF87LQh1.css +1 -0
- package/dist/assets/markdown-o5sMhPHN.js +4 -0
- package/dist/assets/marker-icon-hN30_KVU.png +0 -0
- package/dist/assets/play-Daw4K_73.js +1 -0
- package/dist/assets/plus-D_QHm8ud.js +1 -0
- package/dist/assets/trash-DLpHM-s8.js +1 -0
- package/dist/assets/web-6j1-aY67.js +1 -0
- package/dist/favicon.svg +1 -0
- package/dist/index.html +18 -0
- package/docs/ANALYSE.md +87 -0
- package/docs/SECURITE.md +188 -0
- package/lib/scripts/lier.js +15 -0
- package/lib/scripts/searxng.js +91 -0
- package/lib/server/app.js +444 -0
- package/lib/server/audits/modules/local.js +429 -0
- package/lib/server/audits/modules/network.js +331 -0
- package/lib/server/audits/modules/web.js +302 -0
- package/lib/server/audits/net.js +141 -0
- package/lib/server/audits/registry.js +202 -0
- package/lib/server/audits/types.js +58 -0
- package/lib/server/brain/brain.js +624 -0
- package/lib/server/brain/conversations.js +82 -0
- package/lib/server/brain/ollama.js +197 -0
- package/lib/server/brain/prompt.js +188 -0
- package/lib/server/brain/provider.js +1 -0
- package/lib/server/cli/bridge.js +292 -0
- package/lib/server/cli/pont.js +62 -0
- package/lib/server/config.js +68 -0
- package/lib/server/engine/analyzer.js +205 -0
- package/lib/server/engine/catalog.js +229 -0
- package/lib/server/engine/decoder.js +91 -0
- package/lib/server/engine/engine.js +194 -0
- package/lib/server/engine/paths.js +129 -0
- package/lib/server/engine/policy.js +397 -0
- package/lib/server/engine/relax.js +10 -0
- package/lib/server/engine/runner.js +193 -0
- package/lib/server/errors.js +23 -0
- package/lib/server/http/guard.js +75 -0
- package/lib/server/http/router.js +154 -0
- package/lib/server/http/static.js +76 -0
- package/lib/server/index.js +40 -0
- package/lib/server/journal.js +139 -0
- package/lib/server/lien.js +33 -0
- package/lib/server/savoir/base.js +284 -0
- package/lib/server/savoir/lecture.js +406 -0
- package/lib/server/savoir/memoire.js +115 -0
- package/lib/server/search/categories.js +179 -0
- package/lib/server/search/fiche.js +326 -0
- package/lib/server/search/lieux.js +134 -0
- package/lib/server/search/overpass.js +216 -0
- package/lib/server/search/searxng.js +223 -0
- package/lib/server/search/tiles.js +103 -0
- package/lib/server/status.js +157 -0
- package/lib/server/stores.js +141 -0
- package/lib/server/telephone/acces.js +457 -0
- package/lib/server/telephone/certificat.js +79 -0
- package/lib/server/telephone/qr.js +308 -0
- package/lib/shared/analysis.js +40 -0
- package/lib/shared/schemas.js +105 -0
- package/lib/shared/types.js +10 -0
- package/package.json +81 -4
- package/searxng/settings.yml +35 -0
- package/tui/Modelfile +23 -0
- package/tui/README.md +103 -0
- package/tui/prmt_tui.py +65 -0
- package/tui/promethee/__init__.py +1 -0
- package/tui/promethee/__main__.py +4 -0
- package/tui/promethee/accueil.py +66 -0
- package/tui/promethee/agents.py +220 -0
- package/tui/promethee/app/__init__.py +4 -0
- package/tui/promethee/app/application.py +299 -0
- package/tui/promethee/app/clavier.py +99 -0
- package/tui/promethee/app/commandes_slash.py +164 -0
- package/tui/promethee/app/contexte.py +188 -0
- package/tui/promethee/app/definitions.py +518 -0
- package/tui/promethee/app/ecran.py +177 -0
- package/tui/promethee/app/envoi.py +141 -0
- package/tui/promethee/app/historique.py +373 -0
- package/tui/promethee/app/modele.py +108 -0
- package/tui/promethee/app/notes.py +63 -0
- package/tui/promethee/app/outils.py +589 -0
- package/tui/promethee/app/plateforme.py +409 -0
- package/tui/promethee/app/saisie.py +100 -0
- package/tui/promethee/app/taches.py +92 -0
- package/tui/promethee/app/tour.py +539 -0
- package/tui/promethee/app/validation.py +210 -0
- package/tui/promethee/appels.py +35 -0
- package/tui/promethee/commandes.py +154 -0
- package/tui/promethee/completion.py +140 -0
- package/tui/promethee/config.py +12 -0
- package/tui/promethee/consignes.py +278 -0
- package/tui/promethee/conversation.py +94 -0
- package/tui/promethee/etat.py +253 -0
- package/tui/promethee/fichiers.py +354 -0
- package/tui/promethee/journal.py +116 -0
- package/tui/promethee/logo.py +132 -0
- package/tui/promethee/memoire.py +50 -0
- package/tui/promethee/ollama.py +52 -0
- package/tui/promethee/plan.py +91 -0
- package/tui/promethee/politique.py +429 -0
- package/tui/promethee/preferences.py +65 -0
- package/tui/promethee/presse_papiers.py +44 -0
- package/tui/promethee/recherche.py +581 -0
- package/tui/promethee/securite.py +204 -0
- package/tui/promethee/shell.py +190 -0
- package/tui/promethee/textes.py +190 -0
- package/tui/promethee/theme.py +92 -0
- package/tui/promethee/widgets.py +493 -0
- package/tui/requirements.txt +1 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Fin d'un tour, compactage et sessions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime as dt
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
from textual import work
|
|
14
|
+
from textual.widget import Widget
|
|
15
|
+
from textual.widgets import Markdown, Static
|
|
16
|
+
|
|
17
|
+
from ..agents import AGENT_TOOL
|
|
18
|
+
from ..consignes import (
|
|
19
|
+
EDIT_TOOL, FILE_TOOL_NAMES, READ_TOOL, REPORT_RE, SEARCH_TOOL_NAMES, WRITE_TOOL, unverified_values,
|
|
20
|
+
)
|
|
21
|
+
from ..conversation import (
|
|
22
|
+
AUTO_COMPACT_PCT, COMPACT_SYSTEM, SESSIONS_DIR, conversation_transcript, describe_when, list_sessions,
|
|
23
|
+
new_session_id, parse_tool_result,
|
|
24
|
+
)
|
|
25
|
+
from ..fichiers import change_summary, resolve_path, search_desc, show_path
|
|
26
|
+
from ..ollama import OLLAMA, http_error_text
|
|
27
|
+
from ..recherche import OUTIL as WEB_TOOL
|
|
28
|
+
from ..shell import SHELL_TAG
|
|
29
|
+
from ..theme import DIM, GREEN, RED, YELLOW
|
|
30
|
+
from ..widgets import AgentView, AssistantMessage, SearchView, ToolCallView
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Historique:
|
|
34
|
+
"""Partie de PrometheeApp : fin d'un tour, compactage et sessions."""
|
|
35
|
+
|
|
36
|
+
# ------------------------------------------------------------------ fin d'un tour
|
|
37
|
+
def finish_agent(self, msgs: list[dict], final: dict | None, error: str | None,
|
|
38
|
+
interrupted: bool, denied: bool, produced: bool) -> None:
|
|
39
|
+
self.generating = False
|
|
40
|
+
self._spinner(False)
|
|
41
|
+
self._turn_model = None
|
|
42
|
+
self.update_status()
|
|
43
|
+
if self._perm is not None:
|
|
44
|
+
self.hide_permission()
|
|
45
|
+
stopped, self._policy_stop = self._policy_stop, False
|
|
46
|
+
if error and not produced and msgs and msgs[-1]["role"] == "user":
|
|
47
|
+
msgs = msgs[:-1] # on retire la question restée sans réponse
|
|
48
|
+
self.messages = msgs
|
|
49
|
+
for m in reversed(msgs):
|
|
50
|
+
if m.get("direct"):
|
|
51
|
+
self.last_answer = m["content"].split("\n", 1)[-1] # sans « Rapport de l'agent … : »
|
|
52
|
+
break
|
|
53
|
+
if m["role"] == "assistant" and m.get("content", "").strip():
|
|
54
|
+
self.last_answer = m["content"]
|
|
55
|
+
break
|
|
56
|
+
if interrupted:
|
|
57
|
+
self.add_notice("Interrompu", DIM)
|
|
58
|
+
elif denied and stopped:
|
|
59
|
+
self.add_notice("Arrêt : la politique a refusé deux commandes dans cette réponse. /politique pour voir les "
|
|
60
|
+
"règles, ou reformule ta demande.", YELLOW)
|
|
61
|
+
elif denied:
|
|
62
|
+
self.add_notice("Refusé. Dis à Prométhée quoi faire autrement.", YELLOW)
|
|
63
|
+
elif error:
|
|
64
|
+
self.add_notice(f"Erreur : {error}", f"bold {RED}")
|
|
65
|
+
elif not produced:
|
|
66
|
+
self.add_notice("(réponse vide)")
|
|
67
|
+
if final:
|
|
68
|
+
reported = (final.get("prompt_eval_count") or 0) + (final.get("eval_count") or 0)
|
|
69
|
+
self.ctx_used = max(reported, self.estimate_tokens())
|
|
70
|
+
if final.get("eval_duration") and final.get("eval_count"):
|
|
71
|
+
self.last_tps = final["eval_count"] / (final["eval_duration"] / 1e9)
|
|
72
|
+
else:
|
|
73
|
+
self.ctx_used = max(self.ctx_used, self.estimate_tokens())
|
|
74
|
+
if self.tasks and produced and not interrupted and not error:
|
|
75
|
+
if self.task_kind == "audit" and REPORT_RE.search(self.last_answer):
|
|
76
|
+
for t in self.tasks:
|
|
77
|
+
if t["pattern"] is None:
|
|
78
|
+
t["state"] = "done" # « Rapport final »
|
|
79
|
+
elif self.task_kind == "plan" and self.plan_steps_left() == 0 and self.last_answer.strip():
|
|
80
|
+
for t in self.tasks:
|
|
81
|
+
t["state"] = "done" # il ne restait que le bilan, et la réponse finale le donne
|
|
82
|
+
self.render_tasks()
|
|
83
|
+
self.update_status()
|
|
84
|
+
self.save_session()
|
|
85
|
+
self.call_after_refresh(self._follow)
|
|
86
|
+
offer = self._run_plan and produced and not (interrupted or error or denied) and self.plan_mode
|
|
87
|
+
# Un plan vient d'être proposé : on te demande d'abord si tu l'exécutes. Le compactage automatique attend
|
|
88
|
+
# la réponse suivante, sinon les deux se lançaient ensemble sur la même conversation.
|
|
89
|
+
if not interrupted and not error and not offer:
|
|
90
|
+
self._maybe_auto_compact()
|
|
91
|
+
if offer:
|
|
92
|
+
self.call_after_refresh(self.offer_plan)
|
|
93
|
+
|
|
94
|
+
def _maybe_auto_compact(self) -> None:
|
|
95
|
+
"""Comme Claude Code : quand le contexte est presque plein, la conversation est résumée automatiquement."""
|
|
96
|
+
pct = self.context_pct(self.ctx_used)
|
|
97
|
+
if pct is not None and pct >= AUTO_COMPACT_PCT and len(self.messages) >= 3:
|
|
98
|
+
self.add_notice(f"Contexte rempli à {pct} % : compactage automatique de la conversation…", YELLOW)
|
|
99
|
+
self.call_after_refresh(self.start_compact, "", True)
|
|
100
|
+
|
|
101
|
+
def context_pct(self, tokens: int) -> int | None:
|
|
102
|
+
return min(100, round(100 * tokens / self.num_ctx)) if self.num_ctx else None
|
|
103
|
+
|
|
104
|
+
# ------------------------------------------------------------------ compactage
|
|
105
|
+
def start_compact(self, instructions: str = "", auto: bool = False) -> None:
|
|
106
|
+
if self.generating:
|
|
107
|
+
self.add_notice("Attends la fin de la réponse avant de compacter (ou Esc pour l'interrompre).", YELLOW)
|
|
108
|
+
return
|
|
109
|
+
if not self.messages:
|
|
110
|
+
self.add_notice("Rien à compacter : la conversation est vide." if not self.summary
|
|
111
|
+
else "Rien de nouveau à compacter depuis le dernier résumé.", YELLOW)
|
|
112
|
+
return
|
|
113
|
+
self.generating = True
|
|
114
|
+
self.cancel_event.clear()
|
|
115
|
+
self._spinner(True, "check", "Compactage de la conversation")
|
|
116
|
+
before = max(self.ctx_used, self.estimate_tokens())
|
|
117
|
+
self.compact_conversation(list(self.messages), self.summary, instructions, auto, before)
|
|
118
|
+
|
|
119
|
+
@work(thread=True, exclusive=True, group="generate")
|
|
120
|
+
def compact_conversation(self, messages: list[dict], summary: str, instructions: str, auto: bool, before: int) -> None:
|
|
121
|
+
transcript = conversation_transcript(messages, summary)
|
|
122
|
+
system = COMPACT_SYSTEM + (f"\nConsigne de l'utilisateur pour ce résumé : {instructions}" if instructions else "")
|
|
123
|
+
payload: dict = {"model": self.model, "stream": True, "options": {"temperature": 0.2, "num_predict": 700},
|
|
124
|
+
"messages": [{"role": "system", "content": system},
|
|
125
|
+
{"role": "user", "content": f"Conversation à résumer :\n\n{transcript}"}]}
|
|
126
|
+
if self.supports_thinking:
|
|
127
|
+
payload["think"] = False
|
|
128
|
+
text, error = "", None
|
|
129
|
+
try:
|
|
130
|
+
text = self._summarize(payload)
|
|
131
|
+
except urllib.error.HTTPError as err:
|
|
132
|
+
error = http_error_text(err)
|
|
133
|
+
except Exception as err:
|
|
134
|
+
if not self.cancel_event.is_set():
|
|
135
|
+
error = f"Impossible de joindre Ollama sur {OLLAMA} ({err})."
|
|
136
|
+
self.call_from_thread(self.finish_compact, messages, text, error, auto, transcript, before)
|
|
137
|
+
|
|
138
|
+
def _summarize(self, payload: dict) -> str:
|
|
139
|
+
req = urllib.request.Request(OLLAMA + "/api/chat", data=json.dumps(payload).encode("utf-8"),
|
|
140
|
+
headers={"Content-Type": "application/json"})
|
|
141
|
+
parts: list[str] = []
|
|
142
|
+
with urllib.request.urlopen(req, timeout=600) as resp:
|
|
143
|
+
self._resp = resp
|
|
144
|
+
try:
|
|
145
|
+
for raw in resp:
|
|
146
|
+
if self.cancel_event.is_set():
|
|
147
|
+
break
|
|
148
|
+
if not raw.strip():
|
|
149
|
+
continue
|
|
150
|
+
chunk = json.loads(raw)
|
|
151
|
+
if "error" in chunk:
|
|
152
|
+
raise RuntimeError(chunk["error"])
|
|
153
|
+
piece = (chunk.get("message") or {}).get("content") or ""
|
|
154
|
+
if piece:
|
|
155
|
+
parts.append(piece)
|
|
156
|
+
if chunk.get("done"):
|
|
157
|
+
break
|
|
158
|
+
finally:
|
|
159
|
+
self._resp = None
|
|
160
|
+
return re.sub(r"<think>.*?</think>", "", "".join(parts), flags=re.S).strip()
|
|
161
|
+
|
|
162
|
+
def finish_compact(self, messages: list[dict], text: str, error: str | None, auto: bool,
|
|
163
|
+
transcript: str, before: int) -> None:
|
|
164
|
+
self.generating = False
|
|
165
|
+
self._spinner(False)
|
|
166
|
+
if self.cancel_event.is_set():
|
|
167
|
+
self.add_notice("Compactage annulé : la conversation est intacte.", DIM)
|
|
168
|
+
return
|
|
169
|
+
if error or not text:
|
|
170
|
+
self.add_notice(f"Compactage impossible : {error or 'résumé vide'}. La conversation est intacte.", f"bold {RED}")
|
|
171
|
+
return
|
|
172
|
+
self.summary = text
|
|
173
|
+
self.messages = self.messages[len(messages):] # (rien de neuf normalement : la saisie est bloquée)
|
|
174
|
+
after = self.estimate_tokens()
|
|
175
|
+
self.ctx_used = after
|
|
176
|
+
pb, pa = self.context_pct(before), self.context_pct(after)
|
|
177
|
+
sizes = f"contexte {pb} % → {pa} %" if pb is not None else f"≈{before} → {after} jetons"
|
|
178
|
+
self.add_notice(f"Conversation compactée{' automatiquement' if auto else ''} ({sizes}). "
|
|
179
|
+
"Prométhée continue avec ce résumé :", GREEN)
|
|
180
|
+
self.add_notice_md(text)
|
|
181
|
+
missing = unverified_values(text, transcript)
|
|
182
|
+
if missing:
|
|
183
|
+
self.add_notice("À vérifier : ces valeurs du résumé n'apparaissent pas dans la conversation : "
|
|
184
|
+
+ ", ".join(missing[:8]), YELLOW)
|
|
185
|
+
self.update_status()
|
|
186
|
+
self.save_session()
|
|
187
|
+
self.call_after_refresh(self._follow)
|
|
188
|
+
|
|
189
|
+
# ------------------------------------------------------------------ sessions (reprise)
|
|
190
|
+
def save_session(self) -> None:
|
|
191
|
+
"""Enregistre la conversation après chaque réponse (Documents\\Promethee\\sessions), pour /reprendre."""
|
|
192
|
+
if not self.messages and not self.summary:
|
|
193
|
+
return
|
|
194
|
+
data = {"version": 1, "id": self.session_id, "model": self.model, "cwd": str(self.cwd),
|
|
195
|
+
"created": self.session_created, "updated": dt.datetime.now().isoformat(timespec="seconds"),
|
|
196
|
+
"title": self.session_title, "summary": self.summary, "messages": self.messages}
|
|
197
|
+
try:
|
|
198
|
+
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
path = SESSIONS_DIR / f"{self.session_id}.json"
|
|
200
|
+
tmp = path.with_suffix(".tmp")
|
|
201
|
+
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
202
|
+
os.replace(tmp, path)
|
|
203
|
+
except (OSError, TypeError, ValueError) as err:
|
|
204
|
+
self.add_notice(f"Sauvegarde automatique impossible : {err}", YELLOW)
|
|
205
|
+
|
|
206
|
+
def cmd_resume(self, arg: str = "") -> None:
|
|
207
|
+
if self.generating:
|
|
208
|
+
self.add_notice("Attends la fin de la réponse avant de changer de conversation.", YELLOW)
|
|
209
|
+
return
|
|
210
|
+
sessions = [s for s in list_sessions(9) if s.get("id") != self.session_id]
|
|
211
|
+
if not sessions:
|
|
212
|
+
self.add_notice("Aucune autre conversation enregistrée à reprendre.", YELLOW)
|
|
213
|
+
return
|
|
214
|
+
options = []
|
|
215
|
+
for s in sessions:
|
|
216
|
+
n = sum(1 for m in s.get("messages") or [] if m.get("role") == "user")
|
|
217
|
+
title = (s.get("title") or "(sans titre)")[:60]
|
|
218
|
+
label = f"{describe_when(s.get('updated', ''))} · {title}"
|
|
219
|
+
if n:
|
|
220
|
+
label += f" · {n} message{'s' if n > 1 else ''}"
|
|
221
|
+
if s.get("summary"):
|
|
222
|
+
label += " · compactée"
|
|
223
|
+
if s.get("model") and s.get("model") != self.model:
|
|
224
|
+
label += f" · {s['model']}"
|
|
225
|
+
options.append((s.get("id", ""), label))
|
|
226
|
+
options.append(("no", "Annuler (Esc)"))
|
|
227
|
+
by_id = {s.get("id"): s for s in sessions}
|
|
228
|
+
|
|
229
|
+
def chosen(decision: str) -> None:
|
|
230
|
+
if decision in by_id:
|
|
231
|
+
self.call_later(self.load_session, by_id[decision])
|
|
232
|
+
|
|
233
|
+
self.ask("Reprendre une conversation", None, "Quelle conversation reprendre ?", options, chosen, enter="reprendre")
|
|
234
|
+
|
|
235
|
+
async def load_session(self, data: dict) -> None:
|
|
236
|
+
if self.generating:
|
|
237
|
+
self.add_notice("Attends la fin de la réponse avant de changer de conversation.", YELLOW)
|
|
238
|
+
return
|
|
239
|
+
self.messages = [m for m in data.get("messages") or [] if isinstance(m, dict) and m.get("role")]
|
|
240
|
+
self.summary = data.get("summary") or ""
|
|
241
|
+
self.session_id = data.get("id") or new_session_id()
|
|
242
|
+
self.session_created = data.get("created") or dt.datetime.now().isoformat(timespec="seconds")
|
|
243
|
+
self.session_title = data.get("title") or ""
|
|
244
|
+
self.allow_edits = False
|
|
245
|
+
self.last_output = ""
|
|
246
|
+
self.tasks = []
|
|
247
|
+
self.render_tasks()
|
|
248
|
+
self.last_answer = next((m["content"] for m in reversed(self.messages)
|
|
249
|
+
if m["role"] == "assistant" and (m.get("content") or "").strip()), "")
|
|
250
|
+
cwd = Path(data.get("cwd") or "")
|
|
251
|
+
if data.get("cwd") and cwd.is_dir():
|
|
252
|
+
self.cwd = cwd
|
|
253
|
+
await self.chat.remove_children()
|
|
254
|
+
await self.chat.mount_all(list(self._welcome_widgets()))
|
|
255
|
+
if self.summary:
|
|
256
|
+
await self.chat.mount(Static(Text.assemble(("⎿ ", DIM), ("Début de la conversation compacté. Résumé :", DIM)),
|
|
257
|
+
classes="notice"))
|
|
258
|
+
await self.chat.mount(Markdown(self.summary, classes="notice-md"))
|
|
259
|
+
await self.chat.mount_all(self._replay_widgets())
|
|
260
|
+
when = describe_when(data.get("updated", ""))
|
|
261
|
+
self.add_notice(f"Conversation reprise ({when}) : la suite sera enregistrée avec elle.", GREEN)
|
|
262
|
+
if data.get("model") and data["model"] != self.model:
|
|
263
|
+
self.add_notice(f"Elle avait été tenue avec le modèle {data['model']} (tu utilises {self.model} ; "
|
|
264
|
+
f"/modele {data['model']} pour changer).", YELLOW)
|
|
265
|
+
self.ctx_used = self.estimate_tokens()
|
|
266
|
+
self.update_status()
|
|
267
|
+
self.chat.scroll_end(animate=False)
|
|
268
|
+
self.call_after_refresh(self._follow)
|
|
269
|
+
|
|
270
|
+
def _replay_widgets(self) -> list[Widget]:
|
|
271
|
+
"""Réaffiche une conversation enregistrée : messages, commandes et leurs résultats."""
|
|
272
|
+
widgets: list[Widget] = []
|
|
273
|
+
waiting: list[tuple[str, ToolCallView]] = [] # appels d'outils en attente de leur résultat
|
|
274
|
+
for m in self.messages:
|
|
275
|
+
role, content = m.get("role"), m.get("content") or ""
|
|
276
|
+
if role != "tool":
|
|
277
|
+
waiting.clear() # les appels restés sans résultat gardent « Non exécutée »
|
|
278
|
+
if role == "user":
|
|
279
|
+
widgets.append(self._replay_shell(content) if content.startswith(SHELL_TAG)
|
|
280
|
+
else self.user_widget(m.get("display") or content))
|
|
281
|
+
elif role == "assistant":
|
|
282
|
+
if content.strip():
|
|
283
|
+
widgets.append(AssistantMessage(content))
|
|
284
|
+
for call in m.get("tool_calls") or []:
|
|
285
|
+
fn = call.get("function") or {}
|
|
286
|
+
name = fn.get("name") or "?"
|
|
287
|
+
args = fn.get("arguments") or {}
|
|
288
|
+
if not isinstance(args, dict):
|
|
289
|
+
args = {"commande": str(args)}
|
|
290
|
+
if name == AGENT_TOOL:
|
|
291
|
+
view = AgentView(str(args.get("agent") or "?"), str(args.get("tache") or ""))
|
|
292
|
+
view._initial = lambda v: v.set_done(-1, 0, 0, "Non terminé", DIM)
|
|
293
|
+
waiting.append((name, view))
|
|
294
|
+
widgets.append(view)
|
|
295
|
+
continue
|
|
296
|
+
if name == WEB_TOOL:
|
|
297
|
+
# Recherche web : requête, sources et lieu viennent du message lui-même (clé locale « search »)
|
|
298
|
+
search = m.get("search") if isinstance(m.get("search"), dict) else None
|
|
299
|
+
view = SearchView(str(args.get("requete") or ""), search, replay=True)
|
|
300
|
+
waiting.append((name, view))
|
|
301
|
+
widgets.append(view)
|
|
302
|
+
continue
|
|
303
|
+
if name in SEARCH_TOOL_NAMES:
|
|
304
|
+
view = ToolCallView(search_desc(name, args, str(args.get("dossier") or "")), "Chercher", "")
|
|
305
|
+
elif name in FILE_TOOL_NAMES:
|
|
306
|
+
label = {READ_TOOL: "Lire", WRITE_TOOL: "Écrire", EDIT_TOOL: "Modifier"}[name]
|
|
307
|
+
shown = show_path(resolve_path(str(args.get("chemin") or "?"), self.cwd), self.cwd)
|
|
308
|
+
view = ToolCallView(shown, label, "")
|
|
309
|
+
else:
|
|
310
|
+
view = ToolCallView(str(args.get("commande") or ""), "PowerShell", "")
|
|
311
|
+
view._initial = lambda v: v.set_skipped("Non exécutée")
|
|
312
|
+
waiting.append((name, view))
|
|
313
|
+
widgets.append(view)
|
|
314
|
+
elif role == "tool" and waiting:
|
|
315
|
+
name, view = waiting.pop(0)
|
|
316
|
+
if name == WEB_TOOL:
|
|
317
|
+
continue # son résultat est déjà affiché par la vue de la recherche
|
|
318
|
+
view._initial = self._replayed_result(name, content)
|
|
319
|
+
report = content.split("\n", 1)[-1]
|
|
320
|
+
if m.get("direct") and not report.startswith(("L'utilisateur a refusé", "(Agent interrompu")):
|
|
321
|
+
widgets.append(AssistantMessage(report)) # rapport d'un agent lancé avec @agent-nom
|
|
322
|
+
return widgets
|
|
323
|
+
|
|
324
|
+
@staticmethod
|
|
325
|
+
def _replayed_result(name: str, content: str):
|
|
326
|
+
if name == AGENT_TOOL:
|
|
327
|
+
report = content.split("\n", 1)[-1]
|
|
328
|
+
if report.startswith("L'utilisateur a refusé"):
|
|
329
|
+
return lambda v: v.set_done(-1, 0, 0, "Arrêté : tu as refusé une de ses actions", YELLOW)
|
|
330
|
+
if report.startswith("(Agent interrompu"):
|
|
331
|
+
return lambda v: v.set_done(-1, 0, 0, "Interrompu", DIM)
|
|
332
|
+
return lambda v: v.set_done(-1, 0, 0)
|
|
333
|
+
first = content.split("\n", 1)[0]
|
|
334
|
+
if name in SEARCH_TOOL_NAMES:
|
|
335
|
+
found = re.search(r"\((\d+)", first)
|
|
336
|
+
if content.startswith(("Fichiers ", "Lignes ")) and found:
|
|
337
|
+
what = "fichier" if content.startswith("Fichiers ") else "ligne"
|
|
338
|
+
n = int(found.group(1))
|
|
339
|
+
return lambda v: v.set_text(f"{n} {what}{'s' if n > 1 else ''} trouvé{'e' if what == 'ligne' else ''}"
|
|
340
|
+
f"{'s' if n > 1 else ''}")
|
|
341
|
+
return lambda v: v.set_text(first[:140], None, DIM)
|
|
342
|
+
if content.startswith("L'utilisateur a refusé"):
|
|
343
|
+
return lambda v: v.set_denied()
|
|
344
|
+
if content.startswith("Non exécutée : la politique"):
|
|
345
|
+
reason = re.search(r"Raisons : (.*?)(?: L'utilisateur peut| Ne contourne pas|$)", content, re.S)
|
|
346
|
+
return lambda v: v.set_refused(reason.group(1).strip() if reason else "")
|
|
347
|
+
if name in FILE_TOOL_NAMES:
|
|
348
|
+
if content.startswith("Contenu de "):
|
|
349
|
+
n = re.search(r"\((\d+) lignes\)", first)
|
|
350
|
+
return lambda v: v.set_text(f"Lu ({n.group(1)} lignes)" if n else "Lu")
|
|
351
|
+
if content.startswith("Fichier "):
|
|
352
|
+
n = re.search(r"\(\+(\d+) −(\d+) lignes\)", first)
|
|
353
|
+
summary = (change_summary(int(n.group(1)), int(n.group(2)), content.startswith("Fichier créé"))
|
|
354
|
+
if n else first.rstrip("."))
|
|
355
|
+
return lambda v: v.set_text(summary)
|
|
356
|
+
if content.startswith("Aucun changement"):
|
|
357
|
+
return lambda v: v.set_text("Aucun changement", None, DIM)
|
|
358
|
+
return lambda v: v.set_skipped(first[:140] + ("…" if len(first) > 140 else ""))
|
|
359
|
+
if content.startswith(("Code de sortie", "Commande non terminée")):
|
|
360
|
+
code, body = parse_tool_result(content)
|
|
361
|
+
note = re.search(r"\((.+)\)$", first)
|
|
362
|
+
body = "" if body.strip() == "(aucune sortie)" else body
|
|
363
|
+
return lambda v: v.set_result(code, body, note.group(1) if note and code is None else "")
|
|
364
|
+
return lambda v: v.set_skipped(first[:200])
|
|
365
|
+
|
|
366
|
+
def _replay_shell(self, content: str) -> Widget:
|
|
367
|
+
match = re.match(re.escape(SHELL_TAG) + r" `(?P<cmd>.*?)`\n(?:Code de sortie : (?P<code>-?\d+)|Commande non terminée)"
|
|
368
|
+
r"(?: \((?P<note>[^\n]*)\))?\n(?P<out>.*)$", content, re.S)
|
|
369
|
+
if not match:
|
|
370
|
+
return self.user_widget(content)
|
|
371
|
+
code = int(match["code"]) if match["code"] is not None else None
|
|
372
|
+
out = "" if match["out"].strip() == "(aucune sortie)" else match["out"]
|
|
373
|
+
return ToolCallView(match["cmd"], "!", "", initial=lambda v: v.set_result(code, out, match["note"] or ""))
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Chargement, liste et changement de modèle."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import urllib.error
|
|
6
|
+
|
|
7
|
+
from textual import work
|
|
8
|
+
|
|
9
|
+
from ..ollama import OLLAMA, api, context_from_env, http_error_text
|
|
10
|
+
from ..theme import RED
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Modele:
|
|
14
|
+
"""Partie de PrometheeApp : chargement, liste et changement de modèle."""
|
|
15
|
+
|
|
16
|
+
# ------------------------------------------------------------------ modèle
|
|
17
|
+
def _set_model_state(self, model: str, state: str) -> None:
|
|
18
|
+
"""État affiché du modèle. Ignoré si tu as changé de modèle entre-temps (deux /modele rapprochés)."""
|
|
19
|
+
if model == self.model:
|
|
20
|
+
self.model_state = state
|
|
21
|
+
self.update_status()
|
|
22
|
+
|
|
23
|
+
def read_model(self, model: str) -> tuple[str, str]:
|
|
24
|
+
"""Lit les caractéristiques du modèle auprès d'Ollama (fil de travail).
|
|
25
|
+
Renvoie ("", "") si tout va bien, sinon (état à afficher, message d'erreur)."""
|
|
26
|
+
try:
|
|
27
|
+
info = api("/api/show", {"model": model}, timeout=15)
|
|
28
|
+
except urllib.error.HTTPError as err:
|
|
29
|
+
return "introuvable", f"Modèle « {model} » : {http_error_text(err)}. Tape /modele pour voir la liste."
|
|
30
|
+
except Exception as err:
|
|
31
|
+
return "Ollama injoignable", (f"Ollama ne répond pas sur {OLLAMA} ({err}). Lance l'application Ollama : "
|
|
32
|
+
"Prométhée réessaiera à ton prochain message.")
|
|
33
|
+
if model != self.model:
|
|
34
|
+
return "", ""
|
|
35
|
+
match = re.search(r"num_ctx\s+(\d+)", info.get("parameters") or "")
|
|
36
|
+
self.num_ctx = int(match.group(1)) if match else context_from_env()
|
|
37
|
+
details = info.get("details") or {}
|
|
38
|
+
self.model_desc = " ".join(x for x in (details.get("parameter_size"), details.get("quantization_level")) if x)
|
|
39
|
+
self.model_system = info.get("system") or ""
|
|
40
|
+
self.supports_tools = "tools" in (info.get("capabilities") or [])
|
|
41
|
+
self.supports_thinking = "thinking" in (info.get("capabilities") or [])
|
|
42
|
+
self._caps.pop(model, None)
|
|
43
|
+
return "", ""
|
|
44
|
+
|
|
45
|
+
@work(thread=True, exclusive=True, group="model")
|
|
46
|
+
def load_model(self) -> None:
|
|
47
|
+
model = self.model
|
|
48
|
+
state, problem = self.read_model(model)
|
|
49
|
+
if problem:
|
|
50
|
+
self.call_from_thread(self._set_model_state, model, state)
|
|
51
|
+
if model == self.model:
|
|
52
|
+
self.call_from_thread(self.add_notice, problem, f"bold {RED}")
|
|
53
|
+
return
|
|
54
|
+
self.call_from_thread(self._set_model_state, model, "chargement…")
|
|
55
|
+
try:
|
|
56
|
+
api("/api/chat", {"model": model, "messages": []}, timeout=300) # précharge en mémoire GPU
|
|
57
|
+
self._loaded_model = model
|
|
58
|
+
self.call_from_thread(self._set_model_state, model, "")
|
|
59
|
+
self._check_gpu(model)
|
|
60
|
+
except Exception as err:
|
|
61
|
+
self.call_from_thread(self._set_model_state, model, "erreur de chargement")
|
|
62
|
+
if model == self.model:
|
|
63
|
+
self.call_from_thread(self.add_notice, f"Chargement impossible : {err}", f"bold {RED}")
|
|
64
|
+
|
|
65
|
+
@work(thread=True, exclusive=True, group="model")
|
|
66
|
+
def list_models(self, switch_to: str | None) -> None:
|
|
67
|
+
try:
|
|
68
|
+
models = sorted((m for m in api("/api/tags", timeout=10).get("models", []) if m.get("name")),
|
|
69
|
+
key=lambda m: m["name"])
|
|
70
|
+
except Exception as err:
|
|
71
|
+
self.call_from_thread(self.add_notice, f"Ollama ne répond pas sur {OLLAMA} ({err}).", f"bold {RED}")
|
|
72
|
+
return
|
|
73
|
+
names = [m["name"] for m in models]
|
|
74
|
+
short = [n[:-7] if n.endswith(":latest") else n for n in names]
|
|
75
|
+
if switch_to:
|
|
76
|
+
wanted = switch_to[:-7] if switch_to.endswith(":latest") else switch_to
|
|
77
|
+
if wanted not in short:
|
|
78
|
+
self.call_from_thread(self.add_notice, f"Modèle « {switch_to} » introuvable. Tape /modele pour voir la liste.", f"bold {RED}")
|
|
79
|
+
return
|
|
80
|
+
self.call_from_thread(self._switch_model, wanted)
|
|
81
|
+
return
|
|
82
|
+
try:
|
|
83
|
+
loaded = {m.get("name") for m in api("/api/ps", timeout=6).get("models") or []}
|
|
84
|
+
except Exception:
|
|
85
|
+
loaded = set() # la liste reste utile sans savoir lesquels sont en mémoire
|
|
86
|
+
|
|
87
|
+
def detail(m: dict) -> str:
|
|
88
|
+
d = m.get("details") or {}
|
|
89
|
+
parts = [x for x in (d.get("parameter_size"), d.get("quantization_level")) if x]
|
|
90
|
+
if m.get("size"):
|
|
91
|
+
parts.append(f"{m['size'] / 1e9:.1f} Go".replace(".", ","))
|
|
92
|
+
if m["name"] in loaded:
|
|
93
|
+
parts.append("en mémoire")
|
|
94
|
+
return " · " + " · ".join(parts) if parts else ""
|
|
95
|
+
|
|
96
|
+
lines = "\n".join(f"- `{n}`{detail(m)}" + (" ← actuel" if n == self.model else "")
|
|
97
|
+
+ (" ← IA de code" if n == self.code_model and n != self.model else "")
|
|
98
|
+
for n, m in zip(short, models))
|
|
99
|
+
self.call_from_thread(self.add_notice_md, f"**Modèles installés** (change avec `/modele <nom>`)\n\n{lines}")
|
|
100
|
+
|
|
101
|
+
def _switch_model(self, name: str) -> None:
|
|
102
|
+
self.model = name
|
|
103
|
+
self.num_ctx = None
|
|
104
|
+
self.last_tps = None
|
|
105
|
+
self.model_state = "chargement…"
|
|
106
|
+
self.update_status()
|
|
107
|
+
self.add_notice(f"Modèle changé : {name} (la conversation continue, /clear pour repartir de zéro)")
|
|
108
|
+
self.load_model()
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Mémoire « # » : enregistrer, afficher, ouvrir."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
from ..config import CREATE_NO_WINDOW
|
|
10
|
+
from ..fichiers import HOME, new_file_format, show_path, write_text_file
|
|
11
|
+
from ..memoire import GLOBAL_MEMORY, MEMORY_HEADER, MEMORY_NAME, add_memory_note, read_memory
|
|
12
|
+
from ..theme import DIM, GREEN, MEMORY_COLOR, RED
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Notes:
|
|
16
|
+
"""Partie de PrometheeApp : mémoire « # » : enregistrer, afficher, ouvrir."""
|
|
17
|
+
|
|
18
|
+
# ------------------------------------------------------------------ mémoire « # »
|
|
19
|
+
def ask_memory_target(self, note: str) -> None:
|
|
20
|
+
local = self.cwd / MEMORY_NAME
|
|
21
|
+
targets = {"global": ("globale", GLOBAL_MEMORY)}
|
|
22
|
+
options = [("global", "Mémoire globale : pour toutes les conversations")]
|
|
23
|
+
body = Text.assemble((f" {note}\n\n", "bold"), (f" globale : {GLOBAL_MEMORY}", DIM))
|
|
24
|
+
if os.path.normcase(str(local)) != os.path.normcase(str(GLOBAL_MEMORY)):
|
|
25
|
+
targets["local"] = ("du dossier", local)
|
|
26
|
+
options.append(("local", f"Mémoire de ce dossier : seulement quand tu travailles dans {self.cwd.name or self.cwd}"))
|
|
27
|
+
body.append(f"\n dossier : {local}", style=DIM)
|
|
28
|
+
options.append(("no", "Annuler (Esc)"))
|
|
29
|
+
|
|
30
|
+
def chosen(decision: str) -> None:
|
|
31
|
+
if decision not in targets:
|
|
32
|
+
return
|
|
33
|
+
label, path = targets[decision]
|
|
34
|
+
try:
|
|
35
|
+
add_memory_note(path, note)
|
|
36
|
+
except (OSError, ValueError) as err:
|
|
37
|
+
self.add_notice(f"Impossible d'écrire dans {path} : {err}", f"bold {RED}")
|
|
38
|
+
return
|
|
39
|
+
self.add_notice(f"# {note} → noté dans la mémoire {label} ({show_path(path, HOME)})", MEMORY_COLOR)
|
|
40
|
+
self.ctx_used = max(self.ctx_used, self.estimate_tokens())
|
|
41
|
+
self.update_status()
|
|
42
|
+
|
|
43
|
+
self.ask("Ajouter à la mémoire", body, "Où enregistrer cette information ?", options, chosen)
|
|
44
|
+
|
|
45
|
+
def show_memory(self) -> None:
|
|
46
|
+
memory = read_memory(self.cwd)
|
|
47
|
+
if not memory:
|
|
48
|
+
self.add_notice(f"Mémoire vide. Ajoute une information en commençant ton message par # (par exemple "
|
|
49
|
+
f"« # je suis étudiant en BTS SIO »). Fichier : {GLOBAL_MEMORY}")
|
|
50
|
+
return
|
|
51
|
+
parts = [f"**Mémoire {label}** (`{path}`)\n\n{text}" for label, path, text in memory]
|
|
52
|
+
self.add_notice_md("\n\n".join(parts) + "\n\n*`/memoire ouvrir` pour la modifier · `# …` pour ajouter une ligne*")
|
|
53
|
+
|
|
54
|
+
def open_memory(self) -> None:
|
|
55
|
+
try:
|
|
56
|
+
if not GLOBAL_MEMORY.is_file():
|
|
57
|
+
write_text_file(GLOBAL_MEMORY, MEMORY_HEADER, *new_file_format(GLOBAL_MEMORY))
|
|
58
|
+
subprocess.Popen(["notepad.exe", str(GLOBAL_MEMORY)], creationflags=CREATE_NO_WINDOW)
|
|
59
|
+
except OSError as err:
|
|
60
|
+
self.add_notice(f"Impossible d'ouvrir {GLOBAL_MEMORY} : {err}", f"bold {RED}")
|
|
61
|
+
return
|
|
62
|
+
self.add_notice(f"Mémoire ouverte dans le Bloc-notes ({GLOBAL_MEMORY}). Les changements comptent dès ton "
|
|
63
|
+
"prochain message.", GREEN)
|