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}