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.
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.
---- the cat ---------------------------------------------------------------
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.
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.
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".
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.
---- the parents' view -----------------------------------------------------
Needs-a-grown-up first, then in order.
The service's address as [parse_service_address] returned it (ServiceAddress.text). The core
derives every URL from it; the shell never builds one.
---- the grown-ups' time limits -------------------------------------------
Every choice the grown-ups make. The same on every device: the service holds the master copy.
Quiet hours, in minutes since midnight; both or neither.
The thinking allowance: tokens in any rolling window of window_hours; None for no limit.
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. Grey is the default.
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.
The ages Whiskers is made for, inclusive.
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}
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.
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.
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 -------------------------------------------------------------
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.
Blocking: call off the main thread.
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.
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.
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.