lib.rsannotatedlib.rssource590 lines · 19.3 KB · raw

The boundary the Kotlin shell sees. Every type here mirrors one in the Rust core and converts to and from it; the logic is not here. See whiskers-engine for what each call does.

5use std::path::PathBuf;
6use std::sync::Arc;
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 {}

Installs the logger, once. On Android the lines go to logcat under the tag whiskers-core; elsewhere to stderr, filtered by the WHISKERS_LOG environment variable (default info). 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}
39fn failed(message: String) -> EngineError {
40    warn!("a call to the core failed: {message}");
41    EngineError::Failed { reason: message }
42}

---- the cat ---------------------------------------------------------------

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}
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}

---- the conversation ------------------------------------------------------

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}
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}

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}
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,

"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>,

File names of the pictures that go with it, for picture_path.

207    pub pictures: Vec<String>,
208}
210impl From<core::Fact> for Fact {
211    fn from(f: core::Fact) -> Self {
212        Fact {
213            id: f.id,
214            text: f.text,
215            learned_at_ms: f.learned_at_ms,
216            kind: f.kind.word().to_owned(),
217            who: f.who,
218            place: f.place,
219            when: f.when,
220            pictures: f.pictures.into_iter().map(|p| p.0).collect(),
221        }
222    }
223}
224
225#[derive(Clone, Copy, PartialEq, Eq, uniffi::Enum)]
226pub enum VoiceChoice {
227    Natural,
228    OnDevice,

The natural voice has run out for today and this is the first time: say it is resting, then carry on.

230    OnDeviceAnnounce,
231}

---- the parents' view -----------------------------------------------------

235#[derive(Clone, uniffi::Record)]
236pub struct Exchange {
237    pub at_ms: u64,
238    pub heard: String,
239    pub pictures: Vec<String>,
240    pub model_wrote: Option<String>,
241    pub said: String,
242    pub outcome: Outcome,
243    pub needs_a_grown_up: bool,
244    pub notes: Vec<String>,
245}
247#[derive(Clone, uniffi::Record)]
248pub struct Digest {

Needs-a-grown-up first, then in order.

250    pub exchanges: Vec<Exchange>,
251    pub facts_learned: Vec<String>,
252    pub answered: u32,
253    pub stopped: u32,
254    pub unreadable_lines: u32,
255}
257#[derive(Clone, uniffi::Record)]
258pub struct EngineConfig {
259    pub data_dir: String,

The service's address as [parse_service_address] returned it (ServiceAddress.text). The core derives every URL from it; the shell never builds one.

262    pub service: String,
263    pub model: String,
264    pub system_prompt: Option<String>,
265}

---- the grown-ups' time limits -------------------------------------------

Every choice the grown-ups make. The same on every device: the service holds the master copy.

270#[derive(Clone, uniffi::Record)]
271pub struct HouseholdSettings {
272    pub daily_minutes: Option<u32>,

Quiet hours, in minutes since midnight; both or neither.

274    pub quiet_from: Option<u16>,
275    pub quiet_until: Option<u16>,

The thinking allowance: tokens in any rolling window of window_hours; None for no limit.

277    pub tokens_per_window: Option<u64>,
278    pub window_hours: u64,
279    pub keep_mic_open: bool,

Characters of the natural voice the child may hear in a day, across every device.

281    pub voice_daily_chars: u32,

The grown-up PIN as an opaque salted hash; None for the multiplication question.

283    pub pin_hash: Option<String>,

The child Whiskers talks to; None until the parents fill it in (the guard then assumes the youngest supported age).

286    pub child: Option<ChildProfile>,

How the cat looks on every device.

288    pub cat_theme: CatTheme,
289}

How the cat looks. Grey is the default.

292#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
293pub enum CatTheme {
294    Grey,
295    Ginger,
296}
298impl From<core::CatTheme> for CatTheme {
299    fn from(t: core::CatTheme) -> Self {
300        match t {
301            core::CatTheme::Grey => CatTheme::Grey,
302            core::CatTheme::Ginger => CatTheme::Ginger,
303        }
304    }
305}
306
307impl From<CatTheme> for core::CatTheme {
308    fn from(t: CatTheme) -> Self {
309        match t {
310            CatTheme::Grey => core::CatTheme::Grey,
311            CatTheme::Ginger => core::CatTheme::Ginger,
312        }
313    }
314}

The child's name and age as the parents typed them. Only [validate_child] and set_household_settings accept one, so an invalid profile never reaches the core.

318#[derive(Clone, uniffi::Record)]
319pub struct ChildProfile {
320    pub name: String,
321    pub age_years: u8,
322}

The ages Whiskers is made for, inclusive.

325#[derive(Clone, Copy, uniffi::Record)]
326pub struct ChildAgeRange {
327    pub youngest: u8,
328    pub oldest: u8,
329}
331#[uniffi::export]
332pub fn child_age_range() -> ChildAgeRange {
333    ChildAgeRange { youngest: core::Age::YOUNGEST.years(), oldest: core::Age::OLDEST.years() }
334}

Checks a name and age the parents typed and returns them as the core will keep them (the name trimmed), or says in words what is wrong. The error text names no value.

338#[uniffi::export]
339pub fn validate_child(name: String, age_years: u8) -> Result<ChildProfile, EngineError> {
340    debug!("ffi: validate_child: name of {} chars, age {age_years}", name.chars().count());
341    let child = to_core_child(&ChildProfile { name, age_years }).map_err(failed)?;
342    Ok(ChildProfile { name: child.name.as_str().to_owned(), age_years: child.age.years() })
343}
345fn to_core_child(c: &ChildProfile) -> Result<core::Child, String> {
346    Ok(core::Child {
347        name: core::ChildName::new(&c.name).map_err(|e| e.to_string())?,
348        age: core::Age::new(c.age_years).map_err(|e| e.to_string())?,
349    })
350}

A service address the core accepted: text is what to keep and show (it parses back to the same address), url is for display only; requests are made by the core from text.

354#[derive(Clone, uniffi::Record)]
355pub struct ServiceAddress {
356    pub text: String,
357    pub url: String,
358}

Reads what a grown-up typed as the service's address, or says in words what is wrong (the text names no part of what was typed).

362#[uniffi::export]
363pub fn parse_service_address(typed: String) -> Result<ServiceAddress, EngineError> {
364    debug!("ffi: parse_service_address: {} chars", typed.chars().count());
365    let a = core::ServiceAddress::parse(&typed).map_err(|e| EngineError::Failed { reason: e.to_string() })?;
366    Ok(ServiceAddress { text: a.text(), url: a.url() })
367}

What asking a service address found.

370#[derive(Clone, Copy, PartialEq, Eq, uniffi::Enum)]
371pub enum ServiceCheck {
372    Reachable,
373    NotWhiskers,
374    NotReachable,
375}

Asks the service at address (as returned by [parse_service_address]) whether it is there. Blocking, at most about twelve seconds: call off the main thread. An address the core does not accept is NotReachable.

380#[uniffi::export]
381pub fn check_service(address: String) -> ServiceCheck {
382    debug!("ffi: check_service");
383    match core::ServiceAddress::parse(&address) {
384        Err(_) => ServiceCheck::NotReachable,
385        Ok(a) => match whiskers_guard::probe_service(&a) {
386            whiskers_guard::ServiceProbe::Reachable => ServiceCheck::Reachable,
387            whiskers_guard::ServiceProbe::NotWhiskers => ServiceCheck::NotWhiskers,
388            whiskers_guard::ServiceProbe::NotReachable => ServiceCheck::NotReachable,
389        },
390    }
391}
393#[derive(Clone, Copy, uniffi::Enum)]
394pub enum TimeStatus {
395    Open { minutes_left: Option<u32> },
396    TodaysTimeIsUp,
397    QuietHours { until: u16 },
398}
399
400impl From<core::TimeStatus> for TimeStatus {
401    fn from(s: core::TimeStatus) -> Self {
402        match s {
403            core::TimeStatus::Open { minutes_left } => TimeStatus::Open { minutes_left },
404            core::TimeStatus::TodaysTimeIsUp => TimeStatus::TodaysTimeIsUp,
405            core::TimeStatus::QuietHours { until } => TimeStatus::QuietHours { until },
406        }
407    }
408}

---- the object -------------------------------------------------------------

412#[derive(uniffi::Object)]
413pub struct WhiskersEngine {
414    inner: eng::Engine,
415}
417#[uniffi::export]
418impl WhiskersEngine {
419    #[uniffi::constructor]
420    pub fn new(config: EngineConfig) -> Result<Arc<Self>, EngineError> {
421        info!("ffi: creating the engine, model {}, custom prompt = {}", config.model, config.system_prompt.is_some());
422        let url = core::ServiceAddress::parse(&config.service).map_err(|e| failed(e.to_string()))?.url();
423        let inner = eng::Engine::new(eng::EngineConfig {
424            data_dir: PathBuf::from(config.data_dir),
425            gateway_url: url.clone(),
426            guard_url: url,
427            model: config.model,
428            system_prompt: config.system_prompt,
429        })
430        .map_err(failed)?;
431        debug!("ffi: engine created");
432        Ok(Arc::new(Self { inner }))
433    }
434
435    pub fn touch(&self, touch: Touch) {
436        trace!("ffi: touch");
437        self.inner.touch(touch.into());
438    }
439
440    pub fn set_phase(&self, phase: Phase, now_ms: u64) {
441        trace!("ffi: set_phase");
442        self.inner.set_phase(phase.into(), now_ms);
443    }
444
445    pub fn tick(&self, now_ms: u64) -> PetView {
446        let v = self.inner.tick(now_ms);
447        PetView { mood: v.mood.into(), gaze_x: v.gaze.0, gaze_y: v.gaze.1, pokes: v.pokes, dizzy_spells: v.dizzy_spells }
448    }

Whether to say hello: the first time ever, or after half an hour of quiet. Folding or unfolding the phone, or coming back to the app, is not a new chat.

452    pub fn greeting_due(&self) -> bool {
453        trace!("ffi: greeting_due");
454        self.inner.greeting_due()
455    }

Blocking: call off the main thread.

458    pub fn greet(&self) -> Spoken {
459        debug!("ffi: greet");
460        self.inner.greet().into()
461    }

Blocking: call off the main thread.

464    pub fn hear(&self, text: String, pictures: Vec<Picture>) -> Spoken {
465        debug!("ffi: hear {} chars, {} picture(s) ({} bytes)", text.len(), pictures.len(), pictures.iter().map(|p| p.bytes.len()).sum::<usize>());
466        let pictures = pictures.into_iter().map(|p| core::Image { media_type: p.media_type, bytes: p.bytes }).collect();
467        self.inner.hear(&text, pictures).into()
468    }

The slow work after a turn (what to remember, folding the old chat into its summary). Blocking, but it does not hold the conversation: start it on its own thread and let her talk again at once. Returns what was learned.

473    pub fn reflect(&self) -> Vec<Fact> {
474        debug!("ffi: reflect");
475        self.inner.reflect().into_iter().map(Into::into).collect()
476    }
478    pub fn household_settings(&self) -> HouseholdSettings {
479        trace!("ffi: household_settings");
480        let c = self.inner.household_config();
481        HouseholdSettings {
482            daily_minutes: c.limits.daily_minutes(),
483            quiet_from: c.limits.quiet().map(|q| q.from()),
484            quiet_until: c.limits.quiet().map(|q| q.until()),
485            tokens_per_window: c.tokens.per_window(),
486            window_hours: c.tokens.window_hours(),
487            keep_mic_open: c.keep_mic_open,
488            voice_daily_chars: c.voice_daily_chars,
489            pin_hash: c.pin,
490            child: c.child.map(|c| ChildProfile { name: c.name.as_str().to_owned(), age_years: c.age.years() }),
491            cat_theme: c.cat_theme.into(),
492        }
493    }

Refuses a choice that cannot mean anything (zero minutes, quiet hours that start and end together or have only one end, an empty window).

497    pub fn set_household_settings(&self, s: HouseholdSettings) -> Result<(), EngineError> {
498        debug!(
499            "ffi: set_household_settings: daily minutes {:?}, quiet {:?}..{:?}, tokens {:?}/{} h, voice {} chars, pin set = {}, child profile set = {}, cat theme {:?}",
500            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
501        );
502        let quiet = match (s.quiet_from, s.quiet_until) {
503            (Some(f), Some(u)) => Some(core::Quiet::new(f, u).map_err(failed)?),
504            (None, None) => None,
505            _ => {
506                error!("ffi: quiet hours given with only one end");
507                return Err(failed("quiet hours need both a start and an end".into()));
508            }
509        };
510        self.inner.set_household_config(core::HouseholdConfig {
511            limits: core::Limits::new(s.daily_minutes, quiet).map_err(failed)?,
512            tokens: core::TokenLimit::new(s.tokens_per_window, s.window_hours).map_err(failed)?,
513            keep_mic_open: s.keep_mic_open,
514            voice_daily_chars: s.voice_daily_chars,
515            pin: s.pin_hash,
516            child: s.child.as_ref().map(to_core_child).transpose().map_err(failed)?,
517            cat_theme: s.cat_theme.into(),
518        });
519        Ok(())
520    }

Brings this device level with the service. Blocking; returns how many things changed here.

523    pub fn sync(&self) -> Result<u32, EngineError> {
524        debug!("ffi: sync");
525        self.inner.sync().map_err(failed)
526    }
528    pub fn tick_time(&self, day: u32, delta_ms: u64) {
529        trace!("ffi: tick_time");
530        self.inner.tick_time(day, delta_ms)
531    }
532
533    pub fn time_status(&self, day: u32, minute_of_day: u16) -> TimeStatus {
534        self.inner.time_status(day, minute_of_day).into()
535    }
536
537    pub fn grant_time(&self, day: u32, minutes: u32) {
538        self.inner.grant_time(day, minutes)
539    }
540
541    pub fn time_used_minutes(&self, day: u32) -> u32 {
542        self.inner.time_used_minutes(day)
543    }
544
545    pub fn digest(&self, from_ms: u64, to_ms: u64) -> Result<Digest, EngineError> {
546        debug!("ffi: digest");
547        let d = self.inner.digest(from_ms, to_ms).map_err(failed)?;
548        Ok(Digest {
549            exchanges: d
550                .exchanges
551                .into_iter()
552                .map(|e| Exchange {
553                    at_ms: e.at_ms,
554                    heard: e.heard,
555                    pictures: e.pictures,
556                    model_wrote: e.model_wrote,
557                    said: e.said,
558                    outcome: e.outcome.into(),
559                    needs_a_grown_up: e.needs_a_grown_up,
560                    notes: e.notes,
561                })
562                .collect(),
563            facts_learned: d.facts_learned,
564            answered: d.answered,
565            stopped: d.stopped,
566            unreadable_lines: d.unreadable_lines,
567        })
568    }

Blocking: asks the model to write the parents' note.

571    pub fn summarize(&self, from_ms: u64, to_ms: u64) -> Result<String, EngineError> {
572        debug!("ffi: summarize");
573        self.inner.summarize(from_ms, to_ms).map_err(failed)
574    }
576    pub fn facts(&self) -> Vec<Fact> {
577        trace!("ffi: facts");
578        self.inner.facts().into_iter().map(Into::into).collect()
579    }
580
581    pub fn forget(&self, id: u64) -> bool {
582        debug!("ffi: forget {id}");
583        self.inner.forget(id)
584    }

The file a picture id points at, for the parents' view to show.

587    pub fn picture_path(&self, id: String) -> String {
588        self.inner.picture_path(&id).to_string_lossy().into_owned()
589    }
590}