Skip to main content

ravenrs_movie/
chat.rs

1//! The training films as a conversation: each answer is a card with the moments.
2
3use std::rc::Rc;
4use std::sync::{Arc, Mutex};
5
6use ravenrs_app_i18n::{Language, Texts};
7use ravenrs_reactive::Binding;
8use ravenrs_widgets::{BuildCx, Chat, ChatMessage, List, ListItem, NodeId, UiWaker, View};
9
10use crate::assistant::{short_moment_row, whole_answers};
11use crate::search::{Answer, Moment, MovieLibrary};
12
13/// Writes an answer about the moments that a question found. It gets the question,
14/// the moments, where to write the text, what to do when the text moves, and what
15/// to write if it fails. See [`MovieChat::writer`].
16pub type MovieWriter = Rc<dyn Fn(&str, &[Moment], Arc<Mutex<String>>, UiWaker, String)>;
17
18/// How many moments an answer offers.
19const MOMENTS: usize = 3;
20
21/// The words a [`MovieChat`] shows. English by default.
22#[derive(Clone, Debug)]
23#[non_exhaustive]
24#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
25pub struct MovieChatLabels {
26    /// Inside the empty field.
27    pub placeholder: String,
28    /// What it says before the first question.
29    pub intro: String,
30    /// Over the moments that answer.
31    pub found: String,
32    /// When no film answers the question.
33    pub nothing_found: String,
34}
35
36impl Default for MovieChatLabels {
37    fn default() -> Self {
38        Self::from_texts(&Texts::new(Language::English))
39    }
40}
41
42#[cfg_attr(feature = "live", ravenrs_live::expose)]
43impl MovieChatLabels {
44    /// The texts of a catalog, in its language. What it does not have comes from English.
45    pub fn from_texts(texts: &Texts) -> Self {
46        Self {
47            placeholder: texts.get("movie.chat.placeholder"),
48            intro: texts.get("movie.chat.intro"),
49            found: texts.get("movie.chat.found"),
50            nothing_found: texts.get("movie.chat.nothing_found"),
51        }
52    }
53
54    /// The words in Brazilian Portuguese.
55    pub fn pt_br() -> Self {
56        Self::from_texts(&Texts::new(Language::BrazilianPortuguese))
57    }
58}
59
60/// A [`Chat`] that answers from the films of a [`MovieLibrary`]. It looks only at
61/// the words the films hold, on this machine, with no model.
62///
63/// The messages are the app's, so that they outlive a closed panel, and `on_open`
64/// says what opening a moment means there, usually going to the film with a
65/// [`MovieCue`](crate::MovieCue).
66///
67/// ```
68/// use ravenrs_movie::{MovieChat, MovieLibrary};
69/// use ravenrs_reactive::State;
70/// use ravenrs_widgets::ChatMessage;
71///
72/// let messages = State::new(Vec::<ChatMessage>::new());
73/// let chat = MovieChat::new(MovieLibrary::new(), messages.binding(), |moment| {
74///     println!("open {}", moment.title);
75/// });
76/// ```
77#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
78pub struct MovieChat {
79    library: MovieLibrary,
80    messages: Binding<Vec<ChatMessage>>,
81    on_open: Rc<dyn Fn(&Moment)>,
82    labels: MovieChatLabels,
83    examples: Vec<String>,
84    writer: Option<MovieWriter>,
85}
86
87impl std::fmt::Debug for MovieChat {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.debug_struct("MovieChat").field("library", &self.library).finish_non_exhaustive()
90    }
91}
92
93#[cfg_attr(feature = "live", ravenrs_live::expose)]
94impl MovieChat {
95    /// A conversation over `library`, keeping `messages`.
96    pub fn new(
97        library: MovieLibrary,
98        messages: Binding<Vec<ChatMessage>>,
99        on_open: impl Fn(&Moment) + 'static,
100    ) -> Self {
101        Self {
102            library,
103            messages,
104            on_open: Rc::new(on_open),
105            labels: MovieChatLabels::default(),
106            examples: Vec::new(),
107            writer: None,
108        }
109    }
110
111    /// Something that writes the answer in its own words, such as a local model,
112    /// instead of a fixed sentence. Finding the moments stays with the search, which
113    /// answered better than a small model when measured, and a question that finds
114    /// nothing is not passed on, so it cannot make something up.
115    #[must_use]
116    pub fn writer(mut self, writer: Option<MovieWriter>) -> Self {
117        self.writer = writer;
118        self
119    }
120
121    /// The words it shows.
122    #[must_use]
123    pub fn labels(mut self, labels: MovieChatLabels) -> Self {
124        self.labels = labels;
125        self
126    }
127
128    /// Things to ask, offered before the first question.
129    #[must_use]
130    pub fn examples(mut self, examples: impl IntoIterator<Item = impl Into<String>>) -> Self {
131        self.examples = examples.into_iter().map(Into::into).collect();
132        self
133    }
134}
135
136#[cfg_attr(feature = "live", ravenrs_live::expose)]
137impl MovieAnswers {
138    /// What to say to a question: the moments that answer it, or that nothing does.
139    /// For an app whose chat does more than talk about films.
140    pub fn answer(&self, question: &str, waker: &UiWaker) -> ChatMessage {
141        let answers: Vec<Answer> = whole_answers(self.library.ask(question, MOMENTS));
142        if answers.is_empty() {
143            return ChatMessage::assistant(self.labels.nothing_found.clone());
144        }
145        let message = if let Some(writer) = &self.writer {
146            let moments: Vec<Moment> = answers.iter().map(|answer| answer.moment.clone()).collect();
147            let text = Arc::new(Mutex::new(String::new()));
148            writer(question, &moments, text.clone(), waker.clone(), self.labels.found.clone());
149            ChatMessage::assistant_writing(text)
150        } else {
151            // The answer itself, in words; the film is there to see it done.
152            ChatMessage::assistant(answers[0].moment.text.clone())
153        };
154        let (library, on_open) = (self.library.clone(), self.on_open.clone());
155        message.card(move || {
156            let rows: Vec<ListItem> = answers
157                .iter()
158                .map(|answer| {
159                    let on_open = on_open.clone();
160                    short_moment_row(&library, answer, move |moment| on_open(moment))
161                })
162                .collect();
163            Box::new(List::new(rows))
164        })
165    }
166}
167
168/// What answering a question about the films takes. See [`MovieAnswers::answer`].
169#[derive(Clone)]
170#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
171pub struct MovieAnswers {
172    /// The films.
173    pub library: MovieLibrary,
174    /// The words of the answers.
175    pub labels: MovieChatLabels,
176    /// What opening a moment means in the app.
177    pub on_open: Rc<dyn Fn(&Moment)>,
178    /// Who writes the answer in its own words, if anyone.
179    pub writer: Option<MovieWriter>,
180}
181
182impl std::fmt::Debug for MovieAnswers {
183    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184        f.debug_struct("MovieAnswers").field("library", &self.library).finish_non_exhaustive()
185    }
186}
187
188impl View for MovieChat {
189    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
190        let answering = MovieAnswers {
191            library: self.library.clone(),
192            labels: self.labels.clone(),
193            on_open: self.on_open.clone(),
194            writer: self.writer.clone(),
195        };
196        let (messages, waker) = (self.messages.clone(), cx.ui_waker());
197        let chat = Chat::new(self.messages.clone())
198            .placeholder(self.labels.placeholder.clone())
199            .intro(self.labels.intro.clone())
200            .examples(self.examples.clone())
201            .on_send(move |question| {
202                let reply = answering.answer(&question, &waker);
203                messages.update(|all| all.push(reply));
204            });
205        chat.build(cx)
206    }
207}