lib.rsannotatedlib.rssource626 lines · 20.7 KB · raw
1//! The boundary the Kotlin shell sees. Every type here mirrors one in the Rust
2//! core and converts to and from it; the logic is not here. See
3//! `whiskers-engine` for what each call does.
4
5use std::path::PathBuf;
6use std::sync::Arc;
7
8use log::{debug, error, info, trace, warn};
9
10use whiskers_core as core;
11use whiskers_engine as eng;
12
13uniffi::setup_scaffolding!();
14
15#[derive(Debug, uniffi::Error)]
16pub enum EngineError {
17    Failed { reason: String },
18}
19
20impl std::fmt::Display for EngineError {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        match self {
23            EngineError::Failed { reason } => f.write_str(reason),
24        }
25    }
26}
27
28impl std::error::Error for EngineError {}
29
30/// Installs the logger, once. On Android the lines go to logcat under the tag `whiskers-core`;
31/// elsewhere to stderr, filtered by the `WHISKERS_LOG` environment variable (default `info`).
32/// Call it first, before anything else, so that nothing the core does goes unlogged.
33#[uniffi::export]
34pub fn init_logging() {
35    let level = core::logging::init("whiskers-core");
36    info!("whiskers-core logging installed at {level}");
37}
38
39fn failed(message: String) -> EngineError {
40    warn!("a call to the core failed: {message}");
41    EngineError::Failed { reason: message }
42}
43
44// ---- the cat ---------------------------------------------------------------
45
46#[derive(Clone, Copy, uniffi::Enum)]
47pub enum Mood {
48    Idle,
49    Listening,
50    Thinking,
51    Working,
52    Speaking,
53    Tangled,
54    Busy,
55    Offline,
56    Dizzy,
57    Tickled,
58    Sleeping,
59}
60
61impl From<eng::Mood> for Mood {
62    fn from(m: eng::Mood) -> Self {
63        match m {
64            eng::Mood::Idle => Mood::Idle,
65            eng::Mood::Listening => Mood::Listening,
66            eng::Mood::Thinking => Mood::Thinking,
67            eng::Mood::Working => Mood::Working,
68            eng::Mood::Speaking => Mood::Speaking,
69            eng::Mood::Tangled => Mood::Tangled,
70            eng::Mood::Busy => Mood::Busy,
71            eng::Mood::Offline => Mood::Offline,
72            eng::Mood::Dizzy => Mood::Dizzy,
73            eng::Mood::Tickled => Mood::Tickled,
74            eng::Mood::Sleeping => Mood::Sleeping,
75        }
76    }
77}
78
79#[derive(Clone, Copy, uniffi::Enum)]
80pub enum Phase {
81    Resting,
82    Listening,
83    Thinking,
84    Working,
85    Speaking,
86    Tangled,
87    Busy,
88    Offline,
89}
90
91impl From<Phase> for eng::Phase {
92    fn from(p: Phase) -> Self {
93        match p {
94            Phase::Resting => eng::Phase::Resting,
95            Phase::Listening => eng::Phase::Listening,
96            Phase::Thinking => eng::Phase::Thinking,
97            Phase::Working => eng::Phase::Working,
98            Phase::Speaking => eng::Phase::Speaking,
99            Phase::Tangled => eng::Phase::Tangled,
100            Phase::Busy => eng::Phase::Busy,
101            Phase::Offline => eng::Phase::Offline,
102        }
103    }
104}
105
106#[derive(Clone, Copy, uniffi::Enum)]
107pub enum TouchKind {
108    Down,
109    Move,
110    Up,
111}
112
113#[derive(Clone, Copy, uniffi::Record)]
114pub struct Touch {
115    pub kind: TouchKind,
116    pub x: f32,
117    pub y: f32,
118    pub at_ms: u64,
119}
120
121impl From<Touch> for eng::Touch {
122    fn from(t: Touch) -> Self {
123        eng::Touch {
124            kind: match t.kind {
125                TouchKind::Down => eng::TouchKind::Down,
126                TouchKind::Move => eng::TouchKind::Move,
127                TouchKind::Up => eng::TouchKind::Up,
128            },
129            x: t.x,
130            y: t.y,
131            at_ms: t.at_ms,
132        }
133    }
134}
135
136#[derive(Clone, Copy, uniffi::Record)]
137pub struct PetView {
138    pub mood: Mood,
139    pub gaze_x: f32,
140    pub gaze_y: f32,
141    pub pokes: u32,
142    pub dizzy_spells: u32,
143}
144
145// ---- the conversation ------------------------------------------------------
146
147/// How a turn ended, flattened for Kotlin.
148#[derive(Clone, Copy, PartialEq, Eq, uniffi::Enum)]
149pub enum Outcome {
150    Answered,
151    ChildRefused,
152    NeedsAGrownUp,
153    AnswerRefused,
154    GuardUnavailable,
155    ModelUnavailable,
156    Resting,
157    LogUnavailable,
158}
159
160impl From<core::Outcome> for Outcome {
161    fn from(o: core::Outcome) -> Self {
162        match o {
163            core::Outcome::Answered => Outcome::Answered,
164            core::Outcome::Fallback(f) => match f {
165                core::Fallback::ChildRefused => Outcome::ChildRefused,
166                core::Fallback::NeedsAGrownUp => Outcome::NeedsAGrownUp,
167                core::Fallback::AnswerRefused => Outcome::AnswerRefused,
168                core::Fallback::GuardUnavailable => Outcome::GuardUnavailable,
169                core::Fallback::ModelUnavailable => Outcome::ModelUnavailable,
170                core::Fallback::Resting => Outcome::Resting,
171                core::Fallback::LogUnavailable => Outcome::LogUnavailable,
172            },
173        }
174    }
175}
176
177/// What Whiskers says. `text` is the only thing the shell may speak.
178#[derive(Clone, uniffi::Record)]
179pub struct Spoken {
180    pub text: String,
181    pub outcome: Outcome,
182}
183
184impl From<eng::Spoken> for Spoken {
185    fn from(s: eng::Spoken) -> Self {
186        Spoken { text: s.text, outcome: s.outcome.into() }
187    }
188}
189
190#[derive(Clone, uniffi::Record)]
191pub struct Picture {
192    pub media_type: String,
193    pub bytes: Vec<u8>,
194}
195
196#[derive(Clone, uniffi::Record)]
197pub struct Fact {
198    pub id: u64,
199    pub text: String,
200    pub learned_at_ms: u64,
201    /// "person", "pet", "toy", "place", "event", "thing" or "other".
202    pub kind: String,
203    pub who: Vec<String>,
204    pub place: Option<String>,
205    pub when: Option<String>,
206    /// File names of the pictures that go with it, for `picture_path`.
207    pub pictures: Vec<String>,
208    /// She put it away: Whiskers does not use it and she does not see it. The parents' list shows it
209    /// under "removed by her" and can restore it.
210    pub hidden_by_child: bool,
211    /// The name of the pixel icon chosen for it (an entry of the allowlist), if one has been; the app draws
212    /// it from `PixelIconSet`, and shows a generic star when there is none.
213    pub icon: Option<String>,
214    /// The fact as it is read out to her (the core words it for her profile); the parents' list uses `text`.
215    pub aloud: String,
216}
217
218impl From<core::Fact> for Fact {
219    fn from(f: core::Fact) -> Self {
220        Fact {
221            id: f.id,
222            text: f.text,
223            learned_at_ms: f.learned_at_ms,
224            kind: f.kind.word().to_owned(),
225            who: f.who,
226            place: f.place,
227            when: f.when,
228            hidden_by_child: f.visibility.is_hidden(),
229            icon: f.icon.map(|i| i.as_str().to_owned()),
230            aloud: String::new(),
231            pictures: f.pictures.into_iter().map(|p| p.0).collect(),
232        }
233    }
234}
235
236#[derive(Clone, Copy, PartialEq, Eq, uniffi::Enum)]
237pub enum VoiceChoice {
238    Natural,
239    OnDevice,
240    /// The natural voice has run out for today and this is the first time: say it is resting, then carry on.
241    OnDeviceAnnounce,
242}
243
244// ---- the parents' view -----------------------------------------------------
245
246#[derive(Clone, uniffi::Record)]
247pub struct Exchange {
248    pub at_ms: u64,
249    pub heard: String,
250    pub pictures: Vec<String>,
251    pub model_wrote: Option<String>,
252    pub said: String,
253    pub outcome: Outcome,
254    pub needs_a_grown_up: bool,
255    pub notes: Vec<String>,
256}
257
258#[derive(Clone, uniffi::Record)]
259pub struct Digest {
260    /// Needs-a-grown-up first, then in order.
261    pub exchanges: Vec<Exchange>,
262    pub facts_learned: Vec<String>,
263    pub answered: u32,
264    pub stopped: u32,
265    pub unreadable_lines: u32,
266}
267
268#[derive(Clone, uniffi::Record)]
269pub struct EngineConfig {
270    pub data_dir: String,
271    /// The service's address as [`parse_service_address`] returned it (`ServiceAddress.text`). The core
272    /// derives every URL from it; the shell never builds one.
273    pub service: String,
274    pub model: String,
275    pub system_prompt: Option<String>,
276}
277
278// ---- the grown-ups' time limits -------------------------------------------
279
280/// Every choice the grown-ups make. The same on every device: the service holds the master copy.
281#[derive(Clone, uniffi::Record)]
282pub struct HouseholdSettings {
283    pub daily_minutes: Option<u32>,
284    /// Quiet hours, in minutes since midnight; both or neither.
285    pub quiet_from: Option<u16>,
286    pub quiet_until: Option<u16>,
287    /// The thinking allowance: tokens in any rolling window of `window_hours`; `None` for no limit.
288    pub tokens_per_window: Option<u64>,
289    pub window_hours: u64,
290    pub keep_mic_open: bool,
291    /// Characters of the natural voice the child may hear in a day, across every device.
292    pub voice_daily_chars: u32,
293    /// The grown-up PIN as an opaque salted hash; `None` for the multiplication question.
294    pub pin_hash: Option<String>,
295    /// The child Whiskers talks to; `None` until the parents fill it in (the guard then assumes the
296    /// youngest supported age).
297    pub child: Option<ChildProfile>,
298    /// How the cat looks on every device.
299    pub cat_theme: CatTheme,
300}
301
302/// How the cat looks. Grey is the default.
303#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
304pub enum CatTheme {
305    Grey,
306    Ginger,
307}
308
309impl From<core::CatTheme> for CatTheme {
310    fn from(t: core::CatTheme) -> Self {
311        match t {
312            core::CatTheme::Grey => CatTheme::Grey,
313            core::CatTheme::Ginger => CatTheme::Ginger,
314        }
315    }
316}
317
318impl From<CatTheme> for core::CatTheme {
319    fn from(t: CatTheme) -> Self {
320        match t {
321            CatTheme::Grey => core::CatTheme::Grey,
322            CatTheme::Ginger => core::CatTheme::Ginger,
323        }
324    }
325}
326
327/// The child's name and age as the parents typed them. Only [`validate_child`] and
328/// `set_household_settings` accept one, so an invalid profile never reaches the core.
329#[derive(Clone, uniffi::Record)]
330pub struct ChildProfile {
331    pub name: String,
332    pub age_years: u8,
333}
334
335/// The ages Whiskers is made for, inclusive.
336#[derive(Clone, Copy, uniffi::Record)]
337pub struct ChildAgeRange {
338    pub youngest: u8,
339    pub oldest: u8,
340}
341
342#[uniffi::export]
343pub fn child_age_range() -> ChildAgeRange {
344    ChildAgeRange { youngest: core::Age::YOUNGEST.years(), oldest: core::Age::OLDEST.years() }
345}
346
347/// Checks a name and age the parents typed and returns them as the core will keep them (the
348/// name trimmed), or says in words what is wrong. The error text names no value.
349#[uniffi::export]
350pub fn validate_child(name: String, age_years: u8) -> Result<ChildProfile, EngineError> {
351    debug!("ffi: validate_child: name of {} chars, age {age_years}", name.chars().count());
352    let child = to_core_child(&ChildProfile { name, age_years }).map_err(failed)?;
353    Ok(ChildProfile { name: child.name.as_str().to_owned(), age_years: child.age.years() })
354}
355
356fn to_core_child(c: &ChildProfile) -> Result<core::Child, String> {
357    Ok(core::Child {
358        name: core::ChildName::new(&c.name).map_err(|e| e.to_string())?,
359        age: core::Age::new(c.age_years).map_err(|e| e.to_string())?,
360    })
361}
362
363/// A service address the core accepted: `text` is what to keep and show (it parses back to the same
364/// address), `url` is for display only; requests are made by the core from `text`.
365#[derive(Clone, uniffi::Record)]
366pub struct ServiceAddress {
367    pub text: String,
368    pub url: String,
369}
370
371/// Reads what a grown-up typed as the service's address, or says in words what is wrong (the text names
372/// no part of what was typed).
373#[uniffi::export]
374pub fn parse_service_address(typed: String) -> Result<ServiceAddress, EngineError> {
375    debug!("ffi: parse_service_address: {} chars", typed.chars().count());
376    let a = core::ServiceAddress::parse(&typed).map_err(|e| EngineError::Failed { reason: e.to_string() })?;
377    Ok(ServiceAddress { text: a.text(), url: a.url() })
378}
379
380/// What asking a service address found.
381#[derive(Clone, Copy, PartialEq, Eq, uniffi::Enum)]
382pub enum ServiceCheck {
383    Reachable,
384    NotWhiskers,
385    NotReachable,
386}
387
388/// Asks the service at `address` (as returned by [`parse_service_address`]) whether it is there. Blocking,
389/// at most about twelve seconds: call off the main thread. An address the core does not accept is
390/// `NotReachable`.
391#[uniffi::export]
392pub fn check_service(address: String) -> ServiceCheck {
393    debug!("ffi: check_service");
394    match core::ServiceAddress::parse(&address) {
395        Err(_) => ServiceCheck::NotReachable,
396        Ok(a) => match whiskers_guard::probe_service(&a) {
397            whiskers_guard::ServiceProbe::Reachable => ServiceCheck::Reachable,
398            whiskers_guard::ServiceProbe::NotWhiskers => ServiceCheck::NotWhiskers,
399            whiskers_guard::ServiceProbe::NotReachable => ServiceCheck::NotReachable,
400        },
401    }
402}
403
404#[derive(Clone, Copy, uniffi::Enum)]
405pub enum TimeStatus {
406    Open { minutes_left: Option<u32> },
407    TodaysTimeIsUp,
408    QuietHours { until: u16 },
409}
410
411impl From<core::TimeStatus> for TimeStatus {
412    fn from(s: core::TimeStatus) -> Self {
413        match s {
414            core::TimeStatus::Open { minutes_left } => TimeStatus::Open { minutes_left },
415            core::TimeStatus::TodaysTimeIsUp => TimeStatus::TodaysTimeIsUp,
416            core::TimeStatus::QuietHours { until } => TimeStatus::QuietHours { until },
417        }
418    }
419}
420
421// ---- the object -------------------------------------------------------------
422
423#[derive(uniffi::Object)]
424pub struct WhiskersEngine {
425    inner: eng::Engine,
426}
427
428#[uniffi::export]
429impl WhiskersEngine {
430    #[uniffi::constructor]
431    pub fn new(config: EngineConfig) -> Result<Arc<Self>, EngineError> {
432        info!("ffi: creating the engine, model {}, custom prompt = {}", config.model, config.system_prompt.is_some());
433        let url = core::ServiceAddress::parse(&config.service).map_err(|e| failed(e.to_string()))?.url();
434        let inner = eng::Engine::new(eng::EngineConfig {
435            data_dir: PathBuf::from(config.data_dir),
436            gateway_url: url.clone(),
437            guard_url: url,
438            model: config.model,
439            system_prompt: config.system_prompt,
440        })
441        .map_err(failed)?;
442        debug!("ffi: engine created");
443        Ok(Arc::new(Self { inner }))
444    }
445
446    pub fn touch(&self, touch: Touch) {
447        trace!("ffi: touch");
448        self.inner.touch(touch.into());
449    }
450
451    pub fn set_phase(&self, phase: Phase, now_ms: u64) {
452        trace!("ffi: set_phase");
453        self.inner.set_phase(phase.into(), now_ms);
454    }
455
456    pub fn tick(&self, now_ms: u64) -> PetView {
457        let v = self.inner.tick(now_ms);
458        PetView { mood: v.mood.into(), gaze_x: v.gaze.0, gaze_y: v.gaze.1, pokes: v.pokes, dizzy_spells: v.dizzy_spells }
459    }
460
461    /// Whether to say hello: the first time ever, or after half an hour of quiet. Folding or
462    /// unfolding the phone, or coming back to the app, is not a new chat.
463    pub fn greeting_due(&self) -> bool {
464        trace!("ffi: greeting_due");
465        self.inner.greeting_due()
466    }
467
468    /// Blocking: call off the main thread.
469    pub fn greet(&self) -> Spoken {
470        debug!("ffi: greet");
471        self.inner.greet().into()
472    }
473
474    /// Blocking: call off the main thread.
475    pub fn hear(&self, text: String, pictures: Vec<Picture>) -> Spoken {
476        debug!("ffi: hear {} chars, {} picture(s) ({} bytes)", text.len(), pictures.len(), pictures.iter().map(|p| p.bytes.len()).sum::<usize>());
477        let pictures = pictures.into_iter().map(|p| core::Image { media_type: p.media_type, bytes: p.bytes }).collect();
478        self.inner.hear(&text, pictures).into()
479    }
480
481    /// The slow work after a turn (what to remember, folding the old chat into its summary).
482    /// Blocking, but it does not hold the conversation: start it on its own thread and let her
483    /// talk again at once. Returns what was learned.
484    pub fn reflect(&self) -> Vec<Fact> {
485        debug!("ffi: reflect");
486        self.inner.reflect().into_iter().map(Into::into).collect()
487    }
488
489    pub fn household_settings(&self) -> HouseholdSettings {
490        trace!("ffi: household_settings");
491        let c = self.inner.household_config();
492        HouseholdSettings {
493            daily_minutes: c.limits.daily_minutes(),
494            quiet_from: c.limits.quiet().map(|q| q.from()),
495            quiet_until: c.limits.quiet().map(|q| q.until()),
496            tokens_per_window: c.tokens.per_window(),
497            window_hours: c.tokens.window_hours(),
498            keep_mic_open: c.keep_mic_open,
499            voice_daily_chars: c.voice_daily_chars,
500            pin_hash: c.pin,
501            child: c.child.map(|c| ChildProfile { name: c.name.as_str().to_owned(), age_years: c.age.years() }),
502            cat_theme: c.cat_theme.into(),
503        }
504    }
505
506    /// Refuses a choice that cannot mean anything (zero minutes, quiet hours that start and end
507    /// together or have only one end, an empty window).
508    pub fn set_household_settings(&self, s: HouseholdSettings) -> Result<(), EngineError> {
509        debug!(
510            "ffi: set_household_settings: daily minutes {:?}, quiet {:?}..{:?}, tokens {:?}/{} h, voice {} chars, pin set = {}, child profile set = {}, cat theme {:?}",
511            s.daily_minutes, s.quiet_from, s.quiet_until, s.tokens_per_window, s.window_hours, s.voice_daily_chars, s.pin_hash.is_some(), s.child.is_some(), s.cat_theme
512        );
513        let quiet = match (s.quiet_from, s.quiet_until) {
514            (Some(f), Some(u)) => Some(core::Quiet::new(f, u).map_err(failed)?),
515            (None, None) => None,
516            _ => {
517                error!("ffi: quiet hours given with only one end");
518                return Err(failed("quiet hours need both a start and an end".into()));
519            }
520        };
521        self.inner.set_household_config(core::HouseholdConfig {
522            limits: core::Limits::new(s.daily_minutes, quiet).map_err(failed)?,
523            tokens: core::TokenLimit::new(s.tokens_per_window, s.window_hours).map_err(failed)?,
524            keep_mic_open: s.keep_mic_open,
525            voice_daily_chars: s.voice_daily_chars,
526            pin: s.pin_hash,
527            child: s.child.as_ref().map(to_core_child).transpose().map_err(failed)?,
528            cat_theme: s.cat_theme.into(),
529        });
530        Ok(())
531    }
532
533    /// Brings this device level with the service. Blocking; returns how many things changed here.
534    pub fn sync(&self) -> Result<u32, EngineError> {
535        debug!("ffi: sync");
536        self.inner.sync().map_err(failed)
537    }
538
539    pub fn tick_time(&self, day: u32, delta_ms: u64) {
540        trace!("ffi: tick_time");
541        self.inner.tick_time(day, delta_ms)
542    }
543
544    pub fn time_status(&self, day: u32, minute_of_day: u16) -> TimeStatus {
545        self.inner.time_status(day, minute_of_day).into()
546    }
547
548    pub fn grant_time(&self, day: u32, minutes: u32) {
549        self.inner.grant_time(day, minutes)
550    }
551
552    pub fn time_used_minutes(&self, day: u32) -> u32 {
553        self.inner.time_used_minutes(day)
554    }
555
556    pub fn digest(&self, from_ms: u64, to_ms: u64) -> Result<Digest, EngineError> {
557        debug!("ffi: digest");
558        let d = self.inner.digest(from_ms, to_ms).map_err(failed)?;
559        Ok(Digest {
560            exchanges: d
561                .exchanges
562                .into_iter()
563                .map(|e| Exchange {
564                    at_ms: e.at_ms,
565                    heard: e.heard,
566                    pictures: e.pictures,
567                    model_wrote: e.model_wrote,
568                    said: e.said,
569                    outcome: e.outcome.into(),
570                    needs_a_grown_up: e.needs_a_grown_up,
571                    notes: e.notes,
572                })
573                .collect(),
574            facts_learned: d.facts_learned,
575            answered: d.answered,
576            stopped: d.stopped,
577            unreadable_lines: d.unreadable_lines,
578        })
579    }
580
581    /// Blocking: asks the model to write the parents' note.
582    pub fn summarize(&self, from_ms: u64, to_ms: u64) -> Result<String, EngineError> {
583        debug!("ffi: summarize");
584        self.inner.summarize(from_ms, to_ms).map_err(failed)
585    }
586
587    pub fn facts(&self) -> Vec<Fact> {
588        trace!("ffi: facts");
589        self.inner.facts().into_iter().map(Into::into).collect()
590    }
591
592    pub fn forget(&self, id: u64) -> bool {
593        debug!("ffi: forget {id}");
594        self.inner.forget(id)
595    }
596
597    /// What she may see of what Whiskers remembers (what she has not put away).
598    pub fn facts_she_sees(&self) -> Vec<Fact> {
599        trace!("ffi: facts she sees");
600        self.inner
601            .facts_she_sees()
602            .into_iter()
603            .map(|c| {
604                let aloud = self.inner.aloud(&c);
605                Fact { aloud, ..Fact::from(c) }
606            })
607            .collect()
608    }
609
610    /// She puts a fact away: it stays for the parents, Whiskers stops using it. Not `forget`.
611    pub fn put_away(&self, id: u64) -> bool {
612        debug!("ffi: put away {id}");
613        self.inner.put_away(id)
614    }
615
616    /// A parent restores a fact she put away. Behind the grown-up lock.
617    pub fn restore(&self, id: u64) -> bool {
618        debug!("ffi: restore {id}");
619        self.inner.restore(id)
620    }
621
622    /// The file a picture id points at, for the parents' view to show.
623    pub fn picture_path(&self, id: String) -> String {
624        self.inner.picture_path(&id).to_string_lossy().into_owned()
625    }
626}