@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,539 @@
|
|
|
1
|
+
"""Un tour avec le modèle : classification de la demande, recherche web, flux de la réponse, relances."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import http.client
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
from textual import work
|
|
12
|
+
from textual.widgets import Markdown
|
|
13
|
+
|
|
14
|
+
from ..agents import AGENT_HINT, AGENT_MENTION, list_agents
|
|
15
|
+
from ..appels import prune_calls
|
|
16
|
+
from ..config import MAX_STEPS
|
|
17
|
+
from ..consignes import (
|
|
18
|
+
ANNOUNCE, ASK_CONFIRM, AUDIT_PLAYBOOK, AUDIT_RE, AUDIT_STEPS, CODE_START, CODE_SYSTEM, CONTINUE, EMPTY_REPORT,
|
|
19
|
+
EXPLAIN_REMINDER, FAILED_CLAIM, MAX_CONTINUES, NO_CONFIRM, NUDGE, REPORT_RE, RETURN_REMINDER, ROUTER_SCHEMA,
|
|
20
|
+
ROUTER_SYSTEM, RUN_CHECK, SUCCESS_CLAIM, TOOL_REMINDER, TOOL_TEXT, unverified_values, verify_nudge,
|
|
21
|
+
writes_commands,
|
|
22
|
+
)
|
|
23
|
+
from ..memoire import read_memory
|
|
24
|
+
from ..ollama import OLLAMA, api, http_error_text
|
|
25
|
+
from ..plan import AUDIT_TASKS, MEMORY_HINT, PLAN_FIX, PLAN_REMINDER, missing_cmdlets
|
|
26
|
+
from ..recherche import (
|
|
27
|
+
OUTIL, SEARCH_ROUTER_SCHEMA, SEARCH_ROUTER_SYSTEM, Classement, chercher, dernier_echange, plan_from,
|
|
28
|
+
search_result_text, search_router_input,
|
|
29
|
+
)
|
|
30
|
+
from ..theme import CODE_COLOR, DIM, YELLOW
|
|
31
|
+
from ..widgets import AssistantMessage, LOCAL_KEYS, ToolState
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class EtatTour:
|
|
36
|
+
"""Ce qu'un tour retient d'une étape à l'autre."""
|
|
37
|
+
msgs: list[dict]
|
|
38
|
+
model: str # modèle qui répond : Prométhée, ou l'IA de code
|
|
39
|
+
use_tools: bool
|
|
40
|
+
mode: str = "" # « action », « explication », « plan », ou "" (modèle sans outils)
|
|
41
|
+
reminder: str = "" # rappel ajouté au dernier message de l'utilisateur, dans la requête seulement
|
|
42
|
+
audit: bool = False
|
|
43
|
+
code_turn: bool = False
|
|
44
|
+
st: ToolState = field(default_factory=ToolState)
|
|
45
|
+
final: dict | None = None # dernier message de fin d'Ollama (compte des jetons, durée)
|
|
46
|
+
error: str | None = None
|
|
47
|
+
denied: bool = False # l'utilisateur a refusé une action, ou la politique en a refusé deux
|
|
48
|
+
produced: bool = False # quelque chose est entré dans l'historique
|
|
49
|
+
executed: bool = False # le modèle a déjà appelé un outil pendant cette réponse
|
|
50
|
+
nudged: bool = False # relancé pour avoir écrit des commandes au lieu de les exécuter
|
|
51
|
+
verifies: int = 0 # relances « vérifie ton code »
|
|
52
|
+
honest: bool = False # relancé pour un succès annoncé après un échec
|
|
53
|
+
run_checked: bool = False # relancé pour un programme compilé mais jamais lancé
|
|
54
|
+
empty_asked: bool = False # relancé pour une réponse vide
|
|
55
|
+
plan_checked: bool = False # les cmdlets du plan ont été vérifiées
|
|
56
|
+
continues: int = 0 # relances « continue »
|
|
57
|
+
pending: dict | None = None # relance en attente (voir _next_nudge)
|
|
58
|
+
search: dict | None = None # recherche web décidée par son classement : {query, place} (voir _search_turn)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Tour:
|
|
62
|
+
"""Partie de PrometheeApp : un tour avec le modèle : classification de la demande, recherche web, flux de la réponse,
|
|
63
|
+
relances."""
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------ boucle d'agent (thread)
|
|
66
|
+
@work(thread=True, exclusive=True, group="generate")
|
|
67
|
+
def run_agent(self, history: list[dict], model: str, plan: bool = False) -> None:
|
|
68
|
+
"""Un tour complet : préparer, enchaîner les étapes avec le modèle, contrôler la réponse, rendre la main."""
|
|
69
|
+
msgs = list(history)
|
|
70
|
+
if not self._model_ready(msgs, model):
|
|
71
|
+
return
|
|
72
|
+
# Mode plan : le modèle ne reçoit aucun outil, il ne peut donc rien exécuter
|
|
73
|
+
use_tools = self.supports_tools and not plan
|
|
74
|
+
last_user = next((m.get("intent") or m.get("display") or m["content"] for m in reversed(msgs)
|
|
75
|
+
if m["role"] == "user"), "")
|
|
76
|
+
# « @agent-nom tâche » : la tâche part directement à l'agent, sans passer par le modèle principal
|
|
77
|
+
mention = AGENT_MENTION.search(last_user) if use_tools else None
|
|
78
|
+
if mention:
|
|
79
|
+
self._direct_agent(msgs, mention.group(1))
|
|
80
|
+
return
|
|
81
|
+
# Recherche web : son classement passe d'abord, puis l'interpréteur s'il n'y a pas de recherche. L'un après
|
|
82
|
+
# l'autre, comme pendant la mesure : lancés ensemble, Ollama les calcule en lot et le classement change (65/68 au
|
|
83
|
+
# lieu de 66/68, lieux moins précis) pour presque rien de gagné.
|
|
84
|
+
web = self._start_search_plan(msgs, model, plan)
|
|
85
|
+
found = None
|
|
86
|
+
if web is not None:
|
|
87
|
+
self.call_from_thread(self._spinner, True, "check", "Interprétation de la demande")
|
|
88
|
+
found = web.attendre()
|
|
89
|
+
if found is not None:
|
|
90
|
+
self._search_turn(EtatTour(msgs=msgs, model=model, use_tools=False, mode="recherche", search=found))
|
|
91
|
+
return
|
|
92
|
+
if self.cancel_event.is_set():
|
|
93
|
+
# Esc pendant ce classement : le tour s'arrête là, l'interpréteur n'est pas interrogé
|
|
94
|
+
self.call_from_thread(self.finish_agent, msgs, None, None, True, False, False)
|
|
95
|
+
return
|
|
96
|
+
t = self._prepare_turn(msgs, model, plan, use_tools, last_user)
|
|
97
|
+
for _ in range(MAX_STEPS):
|
|
98
|
+
if self.cancel_event.is_set() or not self._step(t):
|
|
99
|
+
break
|
|
100
|
+
else:
|
|
101
|
+
self.call_from_thread(self.add_notice, f"Arrêt après {MAX_STEPS} étapes. Dis à Prométhée s'il doit continuer.", YELLOW)
|
|
102
|
+
self._final_checks(t, len(history))
|
|
103
|
+
self.call_from_thread(self.finish_agent, t.msgs, t.final, t.error, self.cancel_event.is_set(), t.denied, t.produced)
|
|
104
|
+
|
|
105
|
+
def _model_ready(self, msgs: list[dict], model: str) -> bool:
|
|
106
|
+
"""Ollama ne répondait pas au démarrage : on réessaie maintenant, plutôt que de répondre sans outils."""
|
|
107
|
+
if self.num_ctx is not None or model != self.model:
|
|
108
|
+
return True
|
|
109
|
+
self.call_from_thread(self._spinner, True, "check", "Connexion à Ollama")
|
|
110
|
+
state, problem = self.read_model(model)
|
|
111
|
+
self.call_from_thread(self._set_model_state, model, state)
|
|
112
|
+
if problem:
|
|
113
|
+
self.call_from_thread(self.finish_agent, msgs, None, problem, False, False, False)
|
|
114
|
+
return not problem
|
|
115
|
+
|
|
116
|
+
def _prepare_turn(self, msgs: list[dict], model: str, plan: bool, use_tools: bool, last_user: str) -> EtatTour:
|
|
117
|
+
"""1) Interpréteur : action (exécuter) ou explication (répondre directement) ? programmation ?
|
|
118
|
+
Choisit le rappel ajouté à la demande et le modèle qui répond (Prométhée, ou l'IA de code)."""
|
|
119
|
+
forced = next((m.get("mode") for m in reversed(msgs) if m["role"] == "user"), None)
|
|
120
|
+
mode, reminder, audit, code_turn = "", "", False, False
|
|
121
|
+
if plan:
|
|
122
|
+
mode, reminder = "plan", PLAN_REMINDER + (AUDIT_PLAYBOOK if AUDIT_RE.search(last_user) else "")
|
|
123
|
+
elif use_tools:
|
|
124
|
+
self.call_from_thread(self._spinner, True, "check", "Interprétation de la demande")
|
|
125
|
+
self._is_code = False
|
|
126
|
+
# Interpréteur lancé sur le modèle déjà en mémoire : pas de rechargement juste pour classer
|
|
127
|
+
judge = self._loaded_model if self._loaded_model in (model, self.code_model) else model
|
|
128
|
+
mode = forced or self._classify(msgs, judge)
|
|
129
|
+
if not forced and judge != model and not self._is_code:
|
|
130
|
+
# L'IA de code (encore chargée) dit « pas du code » : Prométhée va répondre, il doit donc être chargé de
|
|
131
|
+
# toute façon ; c'est lui qui tranche (qwen3.5 classait « merci ! » en « action » 2 fois sur 3).
|
|
132
|
+
mode = self._classify(msgs, model)
|
|
133
|
+
if self._is_code and self.code_auto and self.code_model != model:
|
|
134
|
+
caps = self.model_caps(self.code_model)
|
|
135
|
+
if caps is not None and "tools" in caps:
|
|
136
|
+
code_turn = True
|
|
137
|
+
elif not self._code_warned:
|
|
138
|
+
self._code_warned = True
|
|
139
|
+
self.call_from_thread(self.add_notice, f"IA de code « {self.code_model} » indisponible "
|
|
140
|
+
f"({'non installée' if caps is None else 'sans outils'}) : Prométhée répond. "
|
|
141
|
+
"Change-la avec /code <modèle>.", YELLOW)
|
|
142
|
+
if mode == "explication":
|
|
143
|
+
reminder = EXPLAIN_REMINDER
|
|
144
|
+
else:
|
|
145
|
+
audit = bool(AUDIT_RE.search(last_user))
|
|
146
|
+
memory_hint = MEMORY_HINT if read_memory(self.cwd) else ""
|
|
147
|
+
agent_hint = AGENT_HINT if list_agents(self.cwd) else ""
|
|
148
|
+
reminder = TOOL_REMINDER[:-1] + agent_hint + memory_hint + ")" + (AUDIT_PLAYBOOK if audit else "")
|
|
149
|
+
if audit:
|
|
150
|
+
self.call_from_thread(self.start_tasks, "audit",
|
|
151
|
+
[{"title": t, "pattern": p} for t, p in AUDIT_TASKS])
|
|
152
|
+
if code_turn:
|
|
153
|
+
model = self.code_model
|
|
154
|
+
self.call_from_thread(self.add_notice, f"⇄ IA de code : {model} prend la main pour cette demande "
|
|
155
|
+
"(Prométhée reprend ensuite).", CODE_COLOR)
|
|
156
|
+
elif self._last_code and mode != "plan":
|
|
157
|
+
self.call_from_thread(self.add_notice, "⇄ Retour à Prométhée.", DIM)
|
|
158
|
+
if mode == "explication":
|
|
159
|
+
reminder = RETURN_REMINDER
|
|
160
|
+
self._last_code = code_turn
|
|
161
|
+
self._turn_model = model
|
|
162
|
+
self.call_from_thread(self.update_status)
|
|
163
|
+
return EtatTour(msgs=msgs, model=model, use_tools=use_tools, mode=mode, reminder=reminder, audit=audit, code_turn=code_turn)
|
|
164
|
+
|
|
165
|
+
def _step(self, t: EtatTour) -> bool:
|
|
166
|
+
"""Une étape du tour : interroger le modèle, régler la relance en attente, en décider une nouvelle au besoin,
|
|
167
|
+
sinon garder sa réponse et exécuter ses outils. Renvoie False quand le tour est fini."""
|
|
168
|
+
bubble, text, calls = self._ask_model(t)
|
|
169
|
+
# IA de code relancée (« vérifie ») qui répond encore sans rien lancer : on refait les contrôles de relance
|
|
170
|
+
had_pending = t.pending is not None
|
|
171
|
+
recheck = bool(t.code_turn and t.pending and t.pending["keep"] and not calls)
|
|
172
|
+
text, over = self._settle_pending(t, bubble, text, calls)
|
|
173
|
+
if over:
|
|
174
|
+
return False
|
|
175
|
+
nudge = self._next_nudge(t, bubble, text, calls, had_pending, recheck)
|
|
176
|
+
if nudge is not None:
|
|
177
|
+
t.pending = nudge
|
|
178
|
+
return True
|
|
179
|
+
if not text.strip():
|
|
180
|
+
self.call_from_thread(bubble.remove)
|
|
181
|
+
if calls:
|
|
182
|
+
calls = prune_calls(calls, self.cwd) # délégation ou lectures lancées à l'aveugle
|
|
183
|
+
if text or calls:
|
|
184
|
+
t.produced = True
|
|
185
|
+
# Le template du modèle n'affiche QUE le texte OU l'appel d'outil d'un message.
|
|
186
|
+
# Si on gardait la phrase d'intro (« vous pouvez utiliser la commande… »), le modèle
|
|
187
|
+
# croirait avoir seulement expliqué la commande et arrêterait d'utiliser l'outil.
|
|
188
|
+
entry: dict = {"role": "assistant", "content": "" if calls else text}
|
|
189
|
+
if calls:
|
|
190
|
+
entry["tool_calls"] = calls
|
|
191
|
+
t.msgs.append(entry)
|
|
192
|
+
if t.error or self.cancel_event.is_set() or not calls:
|
|
193
|
+
return False
|
|
194
|
+
t.executed = True
|
|
195
|
+
t.denied = self._run_tool_calls(calls, t.msgs, t.st)
|
|
196
|
+
return not (t.denied or self.cancel_event.is_set())
|
|
197
|
+
|
|
198
|
+
def _ask_model(self, t: EtatTour) -> tuple[AssistantMessage, str, list[dict]]:
|
|
199
|
+
"""Envoie la conversation au modèle et diffuse sa réponse. Le rappel et la relance en attente ne sont ajoutés
|
|
200
|
+
qu'à la requête, jamais à l'historique."""
|
|
201
|
+
self.call_from_thread(self._spinner, True, "model")
|
|
202
|
+
bubble = self.call_from_thread(self._new_bubble)
|
|
203
|
+
system = self.system_message()
|
|
204
|
+
if t.code_turn and system:
|
|
205
|
+
system = {"role": "system", "content": system["content"] + CODE_SYSTEM.format(model=t.model)}
|
|
206
|
+
request = [{k: v for k, v in m.items() if k not in LOCAL_KEYS} for m in t.msgs]
|
|
207
|
+
if t.reminder:
|
|
208
|
+
# Rappel (+ méthode d'audit) sur le dernier message de l'utilisateur, à chaque étape
|
|
209
|
+
idx = max((i for i, m in enumerate(request) if m["role"] == "user"), default=None)
|
|
210
|
+
if idx is not None:
|
|
211
|
+
request[idx]["content"] += t.reminder
|
|
212
|
+
if t.pending:
|
|
213
|
+
if not t.pending["keep"]:
|
|
214
|
+
request.append({"role": "assistant", "content": t.pending["text"]})
|
|
215
|
+
request.append({"role": "user", "content": t.pending["nudge"]})
|
|
216
|
+
text, calls, turn_final, t.error = self._stream_turn(bubble, ([system] if system else []) + request, t.model, t.use_tools)
|
|
217
|
+
t.final = turn_final or t.final
|
|
218
|
+
return bubble, text, calls
|
|
219
|
+
|
|
220
|
+
def _settle_pending(self, t: EtatTour, bubble: AssistantMessage, text: str, calls: list[dict]) -> tuple[str, bool]:
|
|
221
|
+
"""Le modèle vient de répondre à une relance : que garde-t-on, à l'écran et dans l'historique ?
|
|
222
|
+
Renvoie (texte retenu, tour terminé)."""
|
|
223
|
+
first, t.pending = t.pending, None
|
|
224
|
+
if first is None:
|
|
225
|
+
return text, False
|
|
226
|
+
if first.get("replace"):
|
|
227
|
+
# Plan corrigé : il remplace le premier à l'écran et dans l'historique (sinon on garde le premier)
|
|
228
|
+
if text.strip() and not t.error and not self.cancel_event.is_set():
|
|
229
|
+
self.call_from_thread(first["bubble"].remove)
|
|
230
|
+
self.call_from_thread(self.add_notice, f"Plan corrigé automatiquement : {first['names']} n'existe pas sur ce PC.")
|
|
231
|
+
still = missing_cmdlets(text, self.cwd)
|
|
232
|
+
if still:
|
|
233
|
+
self.call_from_thread(self.add_notice, "À vérifier : " + ", ".join(f"`{n}`" for n in still)
|
|
234
|
+
+ " n'existe pas non plus sur ce PC.", YELLOW)
|
|
235
|
+
return text, False
|
|
236
|
+
self.call_from_thread(bubble.remove)
|
|
237
|
+
return first["text"], False
|
|
238
|
+
if calls:
|
|
239
|
+
if not first["keep"]:
|
|
240
|
+
# 2) Relance réussie : la réponse écrite disparaît, place à l'exécution
|
|
241
|
+
self.call_from_thread(first["bubble"].remove)
|
|
242
|
+
self.call_from_thread(self.add_notice, "Commandes écrites au lieu d'être exécutées : relancé automatiquement.")
|
|
243
|
+
return text, False
|
|
244
|
+
if first["keep"]:
|
|
245
|
+
return text, False # continuation sans appel d'outil : ce texte est sans doute le rapport final, on le garde
|
|
246
|
+
# Relance sans effet : on garde la première réponse… sauf si elle contient de faux appels d'outils
|
|
247
|
+
# (« executer_commande("…") » suivi de « [La sortie de …] ») et pas la seconde
|
|
248
|
+
fake = TOOL_TEXT.search(first["text"])
|
|
249
|
+
if fake and text.strip() and not t.error and not TOOL_TEXT.search(text):
|
|
250
|
+
self.call_from_thread(first["bubble"].remove)
|
|
251
|
+
kept = text
|
|
252
|
+
else:
|
|
253
|
+
self.call_from_thread(bubble.remove)
|
|
254
|
+
kept = first["text"]
|
|
255
|
+
t.msgs.append({"role": "assistant", "content": kept})
|
|
256
|
+
if TOOL_TEXT.search(kept):
|
|
257
|
+
self.call_from_thread(self.add_notice, "Prométhée a écrit ses appels d'outils au lieu de les "
|
|
258
|
+
"lancer : rien n'a été exécuté. Relance ta demande ou reformule-la.", YELLOW)
|
|
259
|
+
t.produced = True
|
|
260
|
+
return kept, True
|
|
261
|
+
|
|
262
|
+
def _next_nudge(self, t: EtatTour, bubble: AssistantMessage, text: str, calls: list[dict],
|
|
263
|
+
had_pending: bool, recheck: bool) -> dict | None:
|
|
264
|
+
"""Faut-il relancer le modèle au lieu d'accepter sa réponse ? Renvoie la relance, ou None.
|
|
265
|
+
Une relance : {"bubble", "text", "nudge", "keep"} ; keep = le texte reste affiché et dans l'historique.
|
|
266
|
+
Chaque cas vient d'un comportement observé sur le modèle ; leur ordre compte."""
|
|
267
|
+
idle = not calls and not t.error and not self.cancel_event.is_set() # il a répondu sans appeler d'outil
|
|
268
|
+
st = t.st
|
|
269
|
+
|
|
270
|
+
def kept(nudge: str) -> dict:
|
|
271
|
+
"""Relance qui garde la réponse : elle rejoint l'historique, la consigne s'y ajoute pour le tour suivant."""
|
|
272
|
+
t.produced = True
|
|
273
|
+
t.msgs.append({"role": "assistant", "content": text})
|
|
274
|
+
return {"bubble": bubble, "text": text, "nudge": nudge, "keep": True}
|
|
275
|
+
|
|
276
|
+
if t.code_turn and t.executed and idle and not text.strip() and not t.empty_asked:
|
|
277
|
+
# IA de code qui s'arrête sur une réponse vide (vu après un compactage) : on lui demande son rapport
|
|
278
|
+
t.empty_asked = True
|
|
279
|
+
self.call_from_thread(bubble.remove)
|
|
280
|
+
return {"bubble": bubble, "text": "", "nudge": EMPTY_REPORT, "keep": True}
|
|
281
|
+
if (recheck or not had_pending) and t.mode == "action" and idle and text.strip():
|
|
282
|
+
if not t.executed and not t.nudged and writes_commands(text) and not t.code_turn:
|
|
283
|
+
# Demande d'action mais le modèle a écrit les commandes : on le relance une fois
|
|
284
|
+
t.nudged = True
|
|
285
|
+
return {"bubble": bubble, "text": text, "nudge": NUDGE, "keep": False}
|
|
286
|
+
if t.code_turn and st.unverified and t.verifies < 2:
|
|
287
|
+
# IA de code : code modifié puis « c'est fait » sans rien relancer → vérifier d'abord
|
|
288
|
+
t.verifies += 1
|
|
289
|
+
return kept(verify_nudge(st.unverified, self.cwd))
|
|
290
|
+
if t.code_turn and st.last_fail and SUCCESS_CLAIM.search(text) and not t.honest:
|
|
291
|
+
t.honest = True
|
|
292
|
+
return kept(FAILED_CLAIM.format(cmd=st.last_fail[0], code=st.last_fail[1]))
|
|
293
|
+
if t.code_turn and st.compiled_only and not t.run_checked:
|
|
294
|
+
# Compilé mais jamais lancé : une fois, on lui demande de l'essayer sur un exemple
|
|
295
|
+
t.run_checked = True
|
|
296
|
+
return kept(RUN_CHECK)
|
|
297
|
+
if (t.code_turn and not t.executed and t.continues < 2 and len(text) < 800 and "```" not in text
|
|
298
|
+
and (ANNOUNCE.search(text) or CODE_START.search(text))):
|
|
299
|
+
# IA de code qui annonce ce qu'elle va faire mais n'a encore rien lancé : on la relance
|
|
300
|
+
t.continues += 1
|
|
301
|
+
return kept(CONTINUE)
|
|
302
|
+
n_ran = len(st.ran) // 2
|
|
303
|
+
if t.executed and t.continues < MAX_CONTINUES and (
|
|
304
|
+
ANNOUNCE.search(text) or (t.audit and n_ran < AUDIT_STEPS and not REPORT_RE.search(text))
|
|
305
|
+
or (self.plan_steps_left() > 0 and not REPORT_RE.search(text) and "bilan" not in text.lower())):
|
|
306
|
+
# Il annonce l'étape suivante (« Passons au scan… »), ou un audit / un plan accepté s'arrête
|
|
307
|
+
# avant la fin sans rapport : on le relance
|
|
308
|
+
t.continues += 1
|
|
309
|
+
return kept(NO_CONFIRM if ASK_CONFIRM.search(text) else CONTINUE)
|
|
310
|
+
if (t.mode == "action" and t.executed and idle and t.continues < MAX_CONTINUES and ASK_CONFIRM.search(text)
|
|
311
|
+
and (self.plan_steps_left() > 0 or (t.audit and len(st.ran) // 2 < AUDIT_STEPS))):
|
|
312
|
+
# Plan ou audit en cours et il demande la permission par écrit : on le relance
|
|
313
|
+
t.continues += 1
|
|
314
|
+
return kept(NO_CONFIRM)
|
|
315
|
+
if t.mode == "plan" and not t.plan_checked and text.strip() and not t.error and not self.cancel_event.is_set():
|
|
316
|
+
# Mode plan : les cmdlets citées existent-elles ? Sinon, une seule demande de correction.
|
|
317
|
+
t.plan_checked = True
|
|
318
|
+
self.call_from_thread(self._spinner, True, "check", "Vérification du plan")
|
|
319
|
+
bad = missing_cmdlets(text, self.cwd)
|
|
320
|
+
if bad:
|
|
321
|
+
names = ", ".join(f"`{n}`" for n in bad)
|
|
322
|
+
return {"bubble": bubble, "text": text, "nudge": PLAN_FIX.format(names=names), "keep": False,
|
|
323
|
+
"replace": True, "names": names}
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
def _final_checks(self, t: EtatTour, start: int) -> None:
|
|
327
|
+
"""Après la dernière étape : succès annoncé après un échec, valeurs citées absentes des sorties."""
|
|
328
|
+
st = t.st
|
|
329
|
+
last_text = next((m.get("content") or "" for m in reversed(t.msgs) if m["role"] == "assistant"), "")
|
|
330
|
+
if t.code_turn and st.last_fail and SUCCESS_CLAIM.search(last_text):
|
|
331
|
+
self.call_from_thread(self.add_notice, f"⚠ L'IA de code dit que ça marche, mais sa dernière commande "
|
|
332
|
+
f"(`{st.last_fail[0]}`) a échoué (code {st.last_fail[1]}) : vérifie avant de t'y fier.",
|
|
333
|
+
f"bold {YELLOW}")
|
|
334
|
+
if st.ran:
|
|
335
|
+
# 4) Contrôle de la réponse : valeurs citées absentes de toutes les sorties de commandes ?
|
|
336
|
+
answer = "\n".join(m.get("content") or "" for m in t.msgs[start:] if m["role"] == "assistant")
|
|
337
|
+
refs = "\n".join(st.ran + [m.get("content") or "" for m in t.msgs if m["role"] in ("user", "tool")]
|
|
338
|
+
+ [self.memory_text(), self.summary])
|
|
339
|
+
missing = unverified_values(answer, refs)
|
|
340
|
+
if missing:
|
|
341
|
+
shown = ", ".join(missing[:8]) + (f"… (+{len(missing) - 8})" if len(missing) > 8 else "")
|
|
342
|
+
self.call_from_thread(self.add_notice, "À vérifier : ces valeurs n'apparaissent dans aucune sortie de "
|
|
343
|
+
f"commande (le modèle a pu les inventer) : {shown}", YELLOW)
|
|
344
|
+
|
|
345
|
+
def _classify(self, msgs: list[dict], model: str) -> str:
|
|
346
|
+
"""Interpréteur : « action » ou « explication ». En cas de doute ou d'erreur : action."""
|
|
347
|
+
last_user, prev, prev_user = "", "", ""
|
|
348
|
+
for m in reversed(msgs):
|
|
349
|
+
if m["role"] == "user" and not last_user:
|
|
350
|
+
last_user = m.get("intent") or m.get("display") or m["content"]
|
|
351
|
+
elif last_user and m["role"] == "assistant" and m.get("content") and not prev:
|
|
352
|
+
prev = m["content"]
|
|
353
|
+
elif prev and m["role"] == "user":
|
|
354
|
+
# La demande d'avant : sans elle, « et en Rust ? » ou « explique la ligne 3 » n'étaient pas vus comme du code
|
|
355
|
+
prev_user = m.get("intent") or m.get("display") or m["content"]
|
|
356
|
+
break
|
|
357
|
+
content = (f"Demande précédente de l'utilisateur : « {prev_user[:200]} »\n" if prev_user else "") \
|
|
358
|
+
+ (f"Réponse précédente de l'assistant (extrait) : « {prev[-300:]} »\n\n" if prev else "") \
|
|
359
|
+
+ f"Dernière demande : « {last_user} »"
|
|
360
|
+
payload: dict = {"model": model, "stream": False, "format": ROUTER_SCHEMA,
|
|
361
|
+
"options": {"temperature": 0, "num_predict": 30},
|
|
362
|
+
"messages": [{"role": "system", "content": ROUTER_SYSTEM}, {"role": "user", "content": content}]}
|
|
363
|
+
if "thinking" in (self.model_caps(model) or []):
|
|
364
|
+
payload["think"] = False
|
|
365
|
+
try:
|
|
366
|
+
# La réponse est gardée le temps de l'appel : Esc la ferme, la classification s'arrête tout de suite
|
|
367
|
+
data = json.loads(api("/api/chat", payload, timeout=60, on_open=self._hold_response)["message"]["content"])
|
|
368
|
+
except Exception:
|
|
369
|
+
return "action"
|
|
370
|
+
finally:
|
|
371
|
+
self._resp = None
|
|
372
|
+
self._is_code = data.get("code") is True
|
|
373
|
+
kind = data.get("type")
|
|
374
|
+
return kind if kind in ("action", "explication") else "action"
|
|
375
|
+
|
|
376
|
+
def _hold_response(self, resp) -> None:
|
|
377
|
+
self._resp = resp
|
|
378
|
+
|
|
379
|
+
# ------------------------------------------------------------------ recherche web
|
|
380
|
+
def _start_search_plan(self, msgs: list[dict], model: str, plan: bool) -> Classement | None:
|
|
381
|
+
"""Lance le classement de la recherche web dans son propre fil, s'il a lieu d'être : recherche active dans les
|
|
382
|
+
réglages, hors mode plan, et demande qui n'impose pas l'action (/init). Sinon None : le tour se passe comme avant."""
|
|
383
|
+
forced = next((m.get("mode") for m in reversed(msgs) if m["role"] == "user"), None)
|
|
384
|
+
if plan or forced or not self.reglage_recherche()["enabled"]:
|
|
385
|
+
return None
|
|
386
|
+
snapshot = list(msgs)
|
|
387
|
+
return Classement(lambda hold: self._search_plan(snapshot, model, hold), self.cancel_event)
|
|
388
|
+
|
|
389
|
+
def _search_plan(self, msgs: list[dict], model: str, hold=None) -> dict | None:
|
|
390
|
+
"""Faut-il chercher sur internet avant de répondre ? Un second classement, à part de l'interpréteur ci-dessus qui
|
|
391
|
+
reste tel quel. Même appel que lui, avec le texte et la mise en forme mesurés dans la console
|
|
392
|
+
(.equipe/recherche/mesure-routeur.md). Renvoie {query, place}, ou None : pas de recherche (en cas d'erreur aussi)."""
|
|
393
|
+
last_user, prev, prev_user = dernier_echange(msgs)
|
|
394
|
+
if not last_user:
|
|
395
|
+
return None
|
|
396
|
+
payload: dict = {"model": model, "stream": False, "format": SEARCH_ROUTER_SCHEMA,
|
|
397
|
+
"options": {"temperature": 0, "num_predict": 120},
|
|
398
|
+
"messages": [{"role": "system", "content": SEARCH_ROUTER_SYSTEM},
|
|
399
|
+
{"role": "user", "content": search_router_input(last_user, prev, prev_user)}]}
|
|
400
|
+
if "thinking" in (self.model_caps(model) or []):
|
|
401
|
+
payload["think"] = False
|
|
402
|
+
try:
|
|
403
|
+
# La réponse est confiée à hold : Esc la ferme, le classement s'arrête tout de suite
|
|
404
|
+
data = json.loads(api("/api/chat", payload, timeout=60, on_open=hold)["message"]["content"])
|
|
405
|
+
except Exception:
|
|
406
|
+
return None
|
|
407
|
+
return plan_from(data, last_user)
|
|
408
|
+
|
|
409
|
+
def _search_turn(self, t: EtatTour) -> None:
|
|
410
|
+
"""Tour avec recherche web, comme dans la console : la recherche, visible (requête, sources, lieu) et notée au
|
|
411
|
+
journal, puis la réponse du modèle SANS outil. Un texte venu d'internet ne doit pas pouvoir lui faire proposer une
|
|
412
|
+
commande dans ce tour. Dans l'historique, la recherche est un appel d'outil « recherche_web » suivi de son
|
|
413
|
+
résultat. Le détail (sources, lieu) reste dans le message, sous la clé locale « search », pour le réafficher."""
|
|
414
|
+
plan, msgs, model = t.search, t.msgs, t.model
|
|
415
|
+
if self._last_code:
|
|
416
|
+
self.call_from_thread(self.add_notice, "⇄ Retour à Prométhée.", DIM)
|
|
417
|
+
self._last_code = False
|
|
418
|
+
self._turn_model = model
|
|
419
|
+
self.call_from_thread(self.update_status)
|
|
420
|
+
self.call_from_thread(self._spinner, True, "check", "Recherche web")
|
|
421
|
+
view = self.call_from_thread(self._new_search_view, plan["query"])
|
|
422
|
+
search = chercher(self.client_recherche(), plan, self.cancel_event)
|
|
423
|
+
self.call_from_thread(view.set_result, search)
|
|
424
|
+
self.noter_recherche(plan, search)
|
|
425
|
+
args = {"requete": plan["query"], **({"lieu": plan["place"]} if plan["place"] else {})}
|
|
426
|
+
msgs.append({"role": "assistant", "content": "", "tool_calls": [{"function": {"name": OUTIL, "arguments": args}}],
|
|
427
|
+
"search": search})
|
|
428
|
+
msgs.append({"role": "tool", "tool_name": OUTIL, "content": search_result_text(search)})
|
|
429
|
+
text, final, error = "", None, None
|
|
430
|
+
if not self.cancel_event.is_set():
|
|
431
|
+
self.call_from_thread(self._spinner, True, "model")
|
|
432
|
+
bubble = self.call_from_thread(self._new_bubble)
|
|
433
|
+
request = [{k: v for k, v in m.items() if k not in LOCAL_KEYS} for m in msgs]
|
|
434
|
+
# use_tools=False : aucun outil dans la requête, et un appel d'outil inventé n'est jamais exécuté
|
|
435
|
+
text, _, final, error = self._stream_turn(bubble, [self.search_system(search)] + request, model, False)
|
|
436
|
+
if not text.strip():
|
|
437
|
+
self.call_from_thread(bubble.remove)
|
|
438
|
+
if text:
|
|
439
|
+
msgs.append({"role": "assistant", "content": text})
|
|
440
|
+
self.call_from_thread(self.finish_agent, msgs, final, error, self.cancel_event.is_set(), False, bool(text))
|
|
441
|
+
|
|
442
|
+
def model_caps(self, model: str) -> list[str] | None:
|
|
443
|
+
"""Capacités d'un modèle (tools, thinking…), None s'il n'est pas installé. Gardées en cache."""
|
|
444
|
+
if model not in self._caps:
|
|
445
|
+
if model == self.model and self.num_ctx is not None:
|
|
446
|
+
self._caps[model] = (["tools"] if self.supports_tools else []) + \
|
|
447
|
+
(["thinking"] if self.supports_thinking else [])
|
|
448
|
+
else:
|
|
449
|
+
try:
|
|
450
|
+
self._caps[model] = api("/api/show", {"model": model}, timeout=15).get("capabilities") or []
|
|
451
|
+
except urllib.error.HTTPError:
|
|
452
|
+
self._caps[model] = None # Ollama répond : ce modèle n'est pas installé
|
|
453
|
+
except Exception:
|
|
454
|
+
return None # Ollama ne répond pas : rien n'est retenu, la prochaine demande réessaiera
|
|
455
|
+
return self._caps[model]
|
|
456
|
+
|
|
457
|
+
def _check_gpu(self, model: str) -> None:
|
|
458
|
+
"""Ollama ne détecte parfois pas la carte graphique au démarrage (après une mise en veille) et tourne alors
|
|
459
|
+
entièrement sur le processeur : 5 à 10 fois plus lent. On le signale."""
|
|
460
|
+
if not shutil.which("nvidia-smi"):
|
|
461
|
+
return
|
|
462
|
+
try:
|
|
463
|
+
loaded = api("/api/ps", timeout=10).get("models") or []
|
|
464
|
+
except Exception:
|
|
465
|
+
return
|
|
466
|
+
name = model if ":" in model else f"{model}:latest"
|
|
467
|
+
entry = next((m for m in loaded if m.get("name") in (model, name)), None)
|
|
468
|
+
if entry is not None and not entry.get("size_vram"):
|
|
469
|
+
self.call_from_thread(self.add_notice, "Ollama tourne sur le processeur : ta carte graphique n'a pas été "
|
|
470
|
+
"détectée (souvent après une mise en veille). Les réponses sont 5 à 10 fois plus lentes. "
|
|
471
|
+
"Quitte Ollama (icône près de l'horloge → Quit Ollama), relance-le, puis relance prmt.",
|
|
472
|
+
f"bold {YELLOW}")
|
|
473
|
+
|
|
474
|
+
def _stream_turn(self, bubble: AssistantMessage | None, messages: list[dict], model: str, use_tools: bool | list):
|
|
475
|
+
# bubble = None : rien ne s'affiche (travail d'un agent : seul son rapport compte)
|
|
476
|
+
# (la recherche du widget Markdown se fait dans le fil de l'interface, comme tout accès aux widgets)
|
|
477
|
+
stream = self.call_from_thread(lambda: Markdown.get_stream(bubble.markdown)) if bubble is not None else None
|
|
478
|
+
parts: list[str] = []
|
|
479
|
+
calls: list[dict] = []
|
|
480
|
+
final: dict | None = None
|
|
481
|
+
error: str | None = None
|
|
482
|
+
payload: dict = {"model": model, "messages": messages, "stream": True}
|
|
483
|
+
if model != self.model and "thinking" in (self.model_caps(model) or []):
|
|
484
|
+
payload["think"] = False # IA de code / modèle d'un agent : réponse directe, bien plus rapide
|
|
485
|
+
self._loaded_model = model
|
|
486
|
+
# use_tools : True (outils du modèle principal) ou la liste des outils d'un agent
|
|
487
|
+
tools = use_tools if isinstance(use_tools, list) else self.request_tools() if use_tools else None
|
|
488
|
+
if tools:
|
|
489
|
+
payload["tools"] = tools
|
|
490
|
+
req = urllib.request.Request(
|
|
491
|
+
OLLAMA + "/api/chat",
|
|
492
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
493
|
+
headers={"Content-Type": "application/json"},
|
|
494
|
+
)
|
|
495
|
+
try:
|
|
496
|
+
with urllib.request.urlopen(req, timeout=600) as resp:
|
|
497
|
+
self._resp = resp
|
|
498
|
+
for raw in resp:
|
|
499
|
+
if self.cancel_event.is_set():
|
|
500
|
+
break
|
|
501
|
+
if not raw.strip():
|
|
502
|
+
continue
|
|
503
|
+
chunk = json.loads(raw)
|
|
504
|
+
if "error" in chunk:
|
|
505
|
+
error = chunk["error"]
|
|
506
|
+
break
|
|
507
|
+
msg = chunk.get("message") or {}
|
|
508
|
+
if msg.get("thinking"):
|
|
509
|
+
self.spin_thinking = True
|
|
510
|
+
if msg.get("tool_calls"):
|
|
511
|
+
calls.extend(msg["tool_calls"])
|
|
512
|
+
piece = msg.get("content") or ""
|
|
513
|
+
if piece:
|
|
514
|
+
parts.append(piece)
|
|
515
|
+
self.spin_tokens += 1
|
|
516
|
+
if stream is not None:
|
|
517
|
+
self.call_from_thread(stream.write, piece)
|
|
518
|
+
if chunk.get("done"):
|
|
519
|
+
final = chunk
|
|
520
|
+
break
|
|
521
|
+
except urllib.error.HTTPError as err:
|
|
522
|
+
error = http_error_text(err)
|
|
523
|
+
except (OSError, http.client.HTTPException) as err: # connexion refusée, coupure volontaire (Esc), délai dépassé
|
|
524
|
+
if not self.cancel_event.is_set():
|
|
525
|
+
error = f"Impossible de joindre Ollama sur {OLLAMA} ({err}). Lance l'application Ollama puis réessaie."
|
|
526
|
+
except ValueError as err: # une ligne de la réponse n'est pas du JSON
|
|
527
|
+
if not self.cancel_event.is_set():
|
|
528
|
+
error = f"Réponse illisible d'Ollama ({err}). Réessaie ; si cela continue, relance Ollama."
|
|
529
|
+
except Exception as err:
|
|
530
|
+
if not self.cancel_event.is_set():
|
|
531
|
+
error = f"Erreur inattendue pendant la réponse ({type(err).__name__} : {err})."
|
|
532
|
+
finally:
|
|
533
|
+
self._resp = None
|
|
534
|
+
try:
|
|
535
|
+
if stream is not None:
|
|
536
|
+
self.call_from_thread(stream.stop)
|
|
537
|
+
except Exception:
|
|
538
|
+
pass
|
|
539
|
+
return "".join(parts), calls, final, error
|