diff --git a/rust/loki-rs/src/chat/compact.rs b/rust/loki-rs/src/chat/compact.rs new file mode 100644 index 0000000..2677102 --- /dev/null +++ b/rust/loki-rs/src/chat/compact.rs @@ -0,0 +1 @@ +//! Lot 6 : module en cours de portage. diff --git a/rust/loki-rs/src/chat/journal.rs b/rust/loki-rs/src/chat/journal.rs new file mode 100644 index 0000000..2677102 --- /dev/null +++ b/rust/loki-rs/src/chat/journal.rs @@ -0,0 +1 @@ +//! Lot 6 : module en cours de portage. diff --git a/rust/loki-rs/src/chat/mod.rs b/rust/loki-rs/src/chat/mod.rs new file mode 100644 index 0000000..8b990ef --- /dev/null +++ b/rust/loki-rs/src/chat/mod.rs @@ -0,0 +1,15 @@ +//! Cœur chat en Rust (lot 6) — bibliothèque, pas encore branchée. +//! +//! Port de `llm_client.go`, `chat_conversation.go`, `chat_compact*.go` : +//! - [`types`] : types partagés, JSON identique au Go ; +//! - [`wire`] : ce qui part vers le moteur (messages, charge utile) ; +//! - [`stream`] : analyse du flux SSE du moteur (texte, raisonnement, appels +//! d'outils, statistiques) ; +//! - [`compact`] : estimation du contexte et compactage de l'historique ; +//! - [`journal`] : journal d'affichage (compactage, rejeu, pagination). + +pub mod compact; +pub mod journal; +pub mod stream; +pub mod types; +pub mod wire; diff --git a/rust/loki-rs/src/chat/stream.rs b/rust/loki-rs/src/chat/stream.rs new file mode 100644 index 0000000..2677102 --- /dev/null +++ b/rust/loki-rs/src/chat/stream.rs @@ -0,0 +1 @@ +//! Lot 6 : module en cours de portage. diff --git a/rust/loki-rs/src/chat/types.rs b/rust/loki-rs/src/chat/types.rs new file mode 100644 index 0000000..4298af2 --- /dev/null +++ b/rust/loki-rs/src/chat/types.rs @@ -0,0 +1,257 @@ +//! Types partagés du chat — sérialisation JSON identique aux types Go +//! (`llm_client.go`, `chat_conversation.go`, `chat_diff.go`). +//! +//! Règles de fidélité : `omitempty` Go → `skip_serializing_if` ; un champ Go +//! `any` (`Content`) → `Option` (nil omis, "" gardé : en Go, omitempty +//! n'omet une interface que si elle est nil). + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +fn is_false(b: &bool) -> bool { + !*b +} + +/// Un message de l'historique (vue du modèle). +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct Message { + pub role: String, + /// Texte, ou tableau de parties (texte + image_url) pour un message multimodal. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tool_calls: Vec, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub tool_call_id: String, + /// Raisonnement séparé gardé avec REASONING_ECHO=on. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub reasoning_content: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub reasoning_model: String, + /// Relais d'image d'un outil (KEEP_TURN_IMAGES) ; retiré à l'envoi. + #[serde(default, skip_serializing_if = "is_false")] + pub img_relay: bool, + /// Bloc posé par Loki (PROJ_SNAPSHOT) ; retiré à l'envoi. + #[serde(default, skip_serializing_if = "is_false")] + pub ctx_upd: bool, + /// Coût mesuré d'une image gardée (KEEP_TURN_IMAGES) — en mémoire seulement. + #[serde(skip)] + pub img_tokens: i64, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct ToolCall { + /// Position dans un flux (delta OpenAI) ; absente de l'historique. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub index: Option, + #[serde(default)] + pub id: String, + #[serde(default, rename = "type")] + pub kind: String, + #[serde(default)] + pub function: ToolCallFunc, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct ToolCallFunc { + #[serde(default)] + pub name: String, + #[serde(default)] + pub arguments: String, +} + +/// Définition d'un outil annoncée au modèle (schéma OpenAI). +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct Tool { + #[serde(rename = "type")] + pub kind: String, + pub function: ToolFunction, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct ToolFunction { + pub name: String, + pub description: String, + /// `any` en Go : `null` si absent. + pub parameters: Value, +} + +/// Question structurée posée à l'utilisateur (outil `ask`). +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct AskEvent { + pub question: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub options: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct DiffLine { + pub op: String, + pub text: String, +} + +/// Appel d'outil annoncé, en cours d'écriture, ou terminé (`ToolUsedEvent`). +/// Pas de JSON propre en Go : le journal en construit un objet à la main. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ToolUsedEvent { + pub name: String, + pub label: String, + pub result: String, + pub done: bool, + pub typing: bool, + pub body: String, + pub body_tail: bool, + pub body_lines: i64, + pub diff: Vec, + pub added: i64, + pub removed: i64, + pub image: String, + pub shot: String, + pub result_chars: i64, + pub result_id: String, +} + +/// Mesures llama.cpp d'une complétion (`StatsEvent`). +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct StatsEvent { + #[serde(default, skip_serializing_if = "is_zero_i64")] + pub prompt_tokens: i64, + #[serde(default, skip_serializing_if = "is_zero_f64")] + pub prompt_per_second: f64, + #[serde(default, skip_serializing_if = "is_zero_f64")] + pub prompt_ms: f64, + #[serde(default, skip_serializing_if = "is_zero_i64")] + pub gen_tokens: i64, + #[serde(default, skip_serializing_if = "is_zero_f64")] + pub gen_per_second: f64, + #[serde(default, skip_serializing_if = "is_zero_f64")] + pub gen_ms: f64, + #[serde(default, skip_serializing_if = "is_zero_i64")] + pub prompt_tokens_total: i64, + #[serde(default, skip_serializing_if = "is_zero_i64")] + pub reasoning_tokens: i64, + /// Ce raisonnement repart dans la requête suivante (REASONING_ECHO). + #[serde(skip)] + pub echoed: bool, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub cache_hint: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub draft_n: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub draft_accepted: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ttft_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lost: Option, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub lost_after: String, + #[serde(default, skip_serializing_if = "is_false")] + pub lost_alert: bool, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub kind: String, +} + +fn is_zero_i64(v: &i64) -> bool { + *v == 0 +} +fn is_zero_f64(v: &f64) -> bool { + *v == 0.0 +} + +impl StatsEvent { + /// `ctxAfter` : contexte que la requête SUIVANTE renverra au moteur — le + /// prompt plus ce qui a été généré, moins le raisonnement jamais renvoyé. + pub fn ctx_after(&self) -> i64 { + if self.echoed { + return self.prompt_tokens_total + self.gen_tokens; + } + self.prompt_tokens_total + self.gen_tokens - self.gen_tokens.min(self.reasoning_tokens) + } +} + +/// Raisonnement de la complétion finale, à ranger avec la réponse. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ReasoningEcho { + pub text: String, + pub model: String, +} + +/// Ce que reçoit le rappel d'une boucle de chat (`StreamEvent` Go, dont un +/// seul champ est posé par appel). +#[derive(Debug, Clone, PartialEq)] +pub enum StreamEvent { + Content(String), + Reasoning(String), + ToolUsed(ToolUsedEvent), + Stats(StatsEvent), + Ask(AskEvent), + Err(String), + /// Retirer la dernière bulle de raisonnement (tour relancé). + DropReasoning, + /// Compactage en cours de tour : début (true) / fin (false). + Compacting(bool), + /// Vue modèle APRÈS un compactage en cours de tour — à substituer. + NewHistory(Vec), + Echo(ReasoningEcho), +} + +/// Événement du journal d'affichage (`LogEvent`). +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct LogEvent { + pub seq: i64, + pub ts: i64, + /// Objet JSON (ordre des clés libre : l'UI les lit par nom). + pub delta: Map, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn message_json_comme_le_go() { + let m = Message { + role: "assistant".into(), + tool_calls: vec![ToolCall { + id: "c1".into(), + kind: "function".into(), + function: ToolCallFunc { + name: "bash".into(), + arguments: "{}".into(), + }, + ..Default::default() + }], + ..Default::default() + }; + assert_eq!( + serde_json::to_value(&m).unwrap(), + json!({"role":"assistant","tool_calls":[{"id":"c1","type":"function","function":{"name":"bash","arguments":"{}"}}]}) + ); + // Contenu vide mais présent : gardé (interface non nil en Go). + let u = Message { + role: "user".into(), + content: Some(json!("")), + ..Default::default() + }; + assert_eq!( + serde_json::to_string(&u).unwrap(), + r#"{"role":"user","content":""}"# + ); + } + + #[test] + fn contexte_apres() { + let s = StatsEvent { + prompt_tokens_total: 1000, + gen_tokens: 300, + reasoning_tokens: 500, + ..Default::default() + }; + assert_eq!(s.ctx_after(), 1000); + let e = StatsEvent { echoed: true, ..s }; + assert_eq!(e.ctx_after(), 1300); + } +} diff --git a/rust/loki-rs/src/chat/wire.rs b/rust/loki-rs/src/chat/wire.rs new file mode 100644 index 0000000..2677102 --- /dev/null +++ b/rust/loki-rs/src/chat/wire.rs @@ -0,0 +1 @@ +//! Lot 6 : module en cours de portage. diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index a73d88d..dbc8d2c 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -6,10 +6,12 @@ //! - `ui` : UI web statique embarquée (lot 2) ; //! - `auth` : authentification de l'API de pilotage (lot 2) ; //! - `oai` : surface compatible OpenAI `/v1` relayée au moteur (lot 5) ; +//! - `chat` : cœur chat (lot 6, bibliothèque en cours de portage) ; //! - `update` : mise à jour des binaires au démarrage du conteneur. pub mod api; pub mod auth; +pub mod chat; pub mod child; pub mod engine; pub mod engstatus;