Lot 6 : routes du chat servies par la façade (LOKI_CHAT=rust)

Avec LOKI_CHAT=rust, la façade charge la discussion active au démarrage
et sert elle-même /api/chat (flux SSE : rejeu puis direct, battement de
cœur toutes les 4 s, trames regroupées), /api/chat/send, stop, reset,
state, compact, tool-result et /api/conversations{,/new,/switch,/rename,
/delete}, avec les réponses des handlers Go. /api/chat/mode et
/api/chat/criteria restent au Go ; la façade relaie puis journalise
l'état rendu, pour que tous les appareils suivent. Pièces jointes,
fichiers, images et export restent au Go, qui suit la discussion active.

Le Go reçoit LOKI_CHAT=rust et l'adresse de la façade (étape suivante :
lui remettre le trafic de chat du tunnel). Sans la variable, rien ne
change : tout part au Go comme avant.

ChatService gagne reset (fichiers effacés par le Go), la compaction
manuelle, le résultat complet d'un outil et le relais d'un événement ;
les critères changés par l'outil criteria sont journalisés pendant le
tour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-15 11:11:22 +00:00
1 parent e96ffe319c
commit 846b561efb
11 files changed
+817 -22

No files matched your search

+160 -13
View File
@@ -154,6 +154,16 @@ fn obj(k: &str, v: impl Into<Value>) -> Delta {
type BoxFut = Pin<Box<dyn Future<Output = ()> + Send>>;
/// Ce qu'un tour lit une fois au départ.
struct TurnEnv {
cfg: std::collections::HashMap<String, String>,
ep: super::wire::ChatEndpoint,
echo: super::wire::EchoPolicy,
ccfg: CompactConfig,
port: u16,
summarizer: EngineSummarizer,
}
/// Le chat tenu par la façade : la discussion ouverte, ses tours et sa file.
pub struct ChatService {
pub sessions: Arc<Sessions<GoSessionHooks>>,
@@ -446,6 +456,142 @@ impl ChatService {
Some(compacted)
}
/// Configuration, point de chat et résumeur d'un tour (lus une fois).
async fn turn_env(&self) -> TurnEnv {
let store = &self.shared.store;
let cfg = store.config().await;
let local_key = store.get_str(BK_STATE, "api_key").await;
let ep = resolve_chat_endpoint(&cfg, &local_key);
let echo = echo_policy_for(&cfg, &self.shared.echo_refusals, None);
let ccfg = CompactConfig::from_config(&cfg, echo.clone());
let port = llm_port(&cfg);
let summarizer = EngineSummarizer {
http: self.shared.http.clone(),
ep: ep.clone(),
cfg: cfg.clone(),
patterns: self.shared.patterns.clone(),
port,
};
TurnEnv {
cfg,
ep,
echo,
ccfg,
port,
summarizer,
}
}
/// `/api/chat/reset` : fil vide pour tous les appareils, tour arrêté et
/// verrou rendu, file vidée, fichiers de la discussion effacés.
pub async fn reset(&self) {
let conv = self.sessions.conv();
conv.reset();
self.queue().items.clear();
let body = json!({ "id": conv.id(), "files_only": true });
if let Err(e) = self
.shared
.store
.internal_json(
hyper::Method::POST,
"/internal/v1/conv/dropped",
Some(&body),
)
.await
{
eprintln!("[chat] fichiers de la discussion non effacés : {e}");
}
self.sessions.persist().await;
}
/// `/api/chat/compact` (`CompactNow`) : compaction manuelle, en tâche de
/// fond ; la progression passe par le journal.
pub async fn compact_now(self: &Arc<Self>) -> Result<(), Refused> {
if !self.engine_ready().await {
return Err(Refused {
status: 503,
error: ERR_MODEL_LOADING.into(),
});
}
let conv = self.sessions.conv().clone();
let epoch = conv.begin_turn(Box::new(|| {})).map_err(|_| Refused {
status: 409,
error: ERR_BUSY.into(),
})?;
let lease = match EngineLease::acquire(self.shared.store.clone(), self.lease_ttl).await {
Ok(l) => l,
Err(e) => {
conv.release_turn(epoch);
return Err(match e {
LeaseError::Busy { error, .. } => Refused { status: 409, error },
LeaseError::Other(error) => Refused { status: 503, error },
});
}
};
let me = self.clone();
tokio::spawn(async move {
let env = me.turn_env().await;
let local = !env.ep.external;
let snap = conv.with_state_if(epoch, |st| {
let msgs = st.messages.clone();
let used = if local {
ctx_pending(st.ctx_used, st.ctx_used_len, &msgs, &env.echo)
} else {
st.ctx_used
};
(msgs, used)
});
if let Some((msgs, used)) = snap {
let changed = me
.compact_and_publish(
&env.summarizer,
&env.ccfg,
CompactCaps::default(),
true,
epoch,
"manuel",
&msgs,
used,
&CompactOpts::default(),
)
.await
.is_some();
if changed {
me.sessions.persist().await;
}
}
conv.release_turn(epoch);
lease.release().await;
});
Ok(())
}
/// `/api/chat/tool-result` (`loadToolResult`) : le résultat complet d'un
/// outil — en mémoire tant qu'il n'est pas écrit, sinon en base.
pub async fn tool_result(&self, id: &str) -> Option<String> {
if id.contains(['/', '\\']) {
return None;
}
if let Some(s) = self.shared.persister.tool_res_pending(id) {
return Some(s.to_string());
}
let b = self
.shared
.store
.get_open(super::persist::BK_TOOL_RES, id)
.await
.ok()
.flatten()?;
Some(String::from_utf8_lossy(&b).into_owned())
}
/// Un événement d'affichage né hors d'un tour (bascule du mode Code,
/// critères édités), sur le fil courant.
pub fn push_delta(&self, d: Delta) {
let conv = self.sessions.conv();
conv.append_delta(conv.epoch(), d);
}
/// `generate` : un tour complet, détaché — seul l'arrêt l'interrompt.
async fn generate(
self: Arc<Self>,
@@ -458,13 +604,15 @@ impl ChatService {
let turn_start = Instant::now();
let conv = self.sessions.conv().clone();
let store = self.shared.store.clone();
let cfg = store.config().await;
let local_key = store.get_str(BK_STATE, "api_key").await;
let ep = resolve_chat_endpoint(&cfg, &local_key);
let TurnEnv {
cfg,
ep,
echo,
ccfg,
port,
summarizer,
} = self.turn_env().await;
let local = !ep.external;
let echo = echo_policy_for(&cfg, &self.shared.echo_refusals, None);
let ccfg = CompactConfig::from_config(&cfg, echo.clone());
let port = llm_port(&cfg);
let conv_id = conv.id();
let agent = prep.cap("Agent");
let mem_off = prep.turn_caps.get("Mem").and_then(Value::as_str) == Some("off");
@@ -472,13 +620,6 @@ impl ChatService {
agent,
code: prep.cap("Code"),
};
let summarizer = EngineSummarizer {
http: self.shared.http.clone(),
ep: ep.clone(),
cfg: cfg.clone(),
patterns: self.shared.patterns.clone(),
port,
};
let ctx_now = |st: &super::persist::ConvState, msgs: &[Message]| {
if local {
ctx_pending(st.ctx_used, st.ctx_used_len, msgs, &echo)
@@ -611,6 +752,12 @@ impl ChatService {
kwargs: reasoning.kwargs.clone().unwrap_or_default(),
},
queued: Box::new(move || me.drain_queued(epoch)),
notice: {
let conv = conv.clone();
Box::new(move |d| {
conv.append_delta(epoch, d);
})
},
};
// Chaque événement devient un delta du journal.
+16 -1
View File
@@ -312,6 +312,9 @@ pub struct GoTurnHost {
pub compact: TurnCompact,
/// Messages mis en file par l'utilisateur pendant le tour.
pub queued: Box<dyn Fn() -> Vec<Message> + Send + Sync>,
/// Événement d'affichage né d'un outil hors du flux du modèle (critères
/// changés par l'outil `criteria`), à journaliser.
pub notice: Box<dyn Fn(Map<String, Value>) + Send + Sync>,
}
/// `toolResID` : « <discussion>.<24 hex> ».
@@ -396,7 +399,19 @@ impl TurnHost for GoTurnHost {
.tool_exec(name, &Value::Object(args.clone()), &self.caps)
.await
{
Ok(v) => tool_outcome(&v, label),
Ok(v) => {
if let Some(list) = v.get("criteria").filter(|l| l.is_array()) {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
let mut d = Map::new();
d.insert("criteria".into(), list.clone());
d.insert("ts".into(), ts.into());
(self.notice)(d);
}
tool_outcome(&v, label)
}
Err(e) => ToolOutcome {
result: format!("[erreur] exécution de l'outil impossible : {e}"),
..Default::default()