//! [`ReadConversationPage`] — lecture **humaine** paginée d'une conversation (lot LS6). //! //! Lecture archive-aware (segments d'archive + actif) exposée comme un **DTO humain //! riche** : contrairement au handoff (résumé borné) ou au read-model work-state //! (aperçus tronqués), la page humaine porte le **texte complet, non borné** de chaque //! tour — c'est la vue « transcript » destinée à l'utilisateur (le React arrive en LS7). //! //! Hors chemin chaud : composé du seul port [`ConversationArchive`] (via un provider par //! root), jamais d'`append`/de rotation ici. use std::sync::Arc; use domain::project::ProjectPath; use domain::{AgentId, ConversationArchive, ConversationId, PageCursor, TurnId, TurnRole}; use crate::error::AppError; /// Fournit le [`ConversationArchive`] **lié au project root** courant (lot LS6), /// calqué sur [`crate::HandoffProvider`]/[`crate::RecordTurnProvider`]. /// /// Les use cases d'archivage/pagination ([`crate::RotateConversationLog`], /// [`ReadConversationPage`]) sont **uniques** pour tous les projets, alors que les logs /// sont **par project root** (`/.ideai/conversations/`) et l'adapter `Fs*` fixe sa /// racine à la construction. Ce port matérialise un archive ciblant le **bon** dossier à /// chaque appel. `None` ⇒ pas d'archivage/pagination (best-effort absent). Implémenté /// dans `app-tauri` (seul détenteur des adapters `Fs*`). pub trait ConversationArchiveProvider: Send + Sync { /// Construit le [`ConversationArchive`] dont la persistance cible `root`, ou `None`. fn conversation_archive_for(&self, root: &ProjectPath) -> Option>; } /// Origine d'un tour, pour la vue humaine paginée (miroir de [`domain::input::InputSource`]). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TurnSource { /// L'opérateur humain. Human, /// Un autre agent (délégation via `idea_ask_agent`). Agent { /// L'agent à l'origine du tour. agent_id: AgentId, }, } impl From for TurnSource { fn from(source: domain::input::InputSource) -> Self { match source { domain::input::InputSource::Human => Self::Human, domain::input::InputSource::Agent { agent_id } => Self::Agent { agent_id }, } } } /// Un tour projeté pour la vue humaine (lot LS6) — **texte complet, non borné**. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TurnView { /// Identifiant stable du tour (sert aussi d'ancre de pagination). pub id: TurnId, /// Horodatage (epoch millisecondes). pub at_ms: u64, /// Nature du tour (invite, réponse, activité outillée). pub role: TurnRole, /// Origine (humain ou agent délégant). pub source: TurnSource, /// Le **texte intégral** du tour (jamais tronqué — c'est la vue transcript). pub text: String, /// Longueur (en caractères) du texte, pour l'UI. pub text_len: usize, } impl From for TurnView { fn from(turn: domain::ConversationTurn) -> Self { let text_len = turn.text.chars().count(); Self { id: turn.id, at_ms: turn.at_ms, role: turn.role, source: turn.source.into(), text: turn.text, text_len, } } } /// Une page de tours pour la vue humaine (lot LS6). #[derive(Debug, Clone, PartialEq, Eq)] pub struct TurnPage { /// Les tours de la page, **du plus ancien au plus récent**. pub turns: Vec, /// `true` s'il existe d'autres tours au-delà de la page dans le sens de progression. pub has_more: bool, /// L'id du **dernier** tour de la page (ancre pour la requête suivante) ; `None` si /// la page est vide. pub next_anchor: Option, } /// Entrée de [`ReadConversationPage::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReadConversationPageInput { /// Le project root dont dériver l'archive. pub project_root: ProjectPath, /// La conversation (paire) à lire. pub conversation: ConversationId, /// Le curseur (ancre + sens) de pagination. pub cursor: PageCursor, /// La taille de page demandée (`0` ⇒ défaut ; clampée par le domaine). pub limit: usize, } /// Use case de lecture humaine paginée (lot LS6) — consomme le seul /// [`ConversationArchive`] (résolu par root via le provider). pub struct ReadConversationPage { archives: Arc, } impl ReadConversationPage { /// Construit le use case à partir du provider d'archive par root. #[must_use] pub fn new(archives: Arc) -> Self { Self { archives } } /// Lit une page de `conversation`, mappée vers le DTO humain riche. /// /// Provider d'archive absent pour ce root ⇒ page **vide** (best-effort, jamais une /// erreur dure). Le texte de chaque tour est renvoyé **intégralement**. /// /// # Errors /// [`AppError::Store`] si la lecture du log échoue. pub async fn execute(&self, input: ReadConversationPageInput) -> Result { let Some(archive) = self.archives.conversation_archive_for(&input.project_root) else { return Ok(TurnPage { turns: Vec::new(), has_more: false, next_anchor: None, }); }; let slice = archive .page(input.conversation, input.cursor, input.limit) .await?; let next_anchor = slice.turns.last().map(|t| t.id); let turns = slice.turns.into_iter().map(TurnView::from).collect(); Ok(TurnPage { turns, has_more: slice.has_more, next_anchor, }) } }