Apps ask the hub for a recognizer or a voice and get one; where it runs is the hub's decision. AiHub::start_stt / start_tts return poll-driven sessions shaped like the chat session. The Auto ladder is Whisper/Kokoro in this process (weights present, machine election), on the machine node over loopback, on a LAN node, else the OS engine; SpeechReach::Local is the "don't reach out" knob. Audio always comes back as PCM: the app owns the device. Three layers: - makepad-ai-speech is the whole speech model family, engines only. libs/voice (Whisper + Silero VAD) folds in as the `whisper` and `vad` modules next to kokoro and indextts, each a cargo feature; the Apple bridges and the Speaker/VoiceTranscriber selection leave it. - makepad-system-speech (new) is the OS speech services as blocking fns: Apple SpeechAnalyzer/AVSpeechSynthesizer via Swift, Windows.Media.Speech* on the vendored bindings, Android SpeechRecognizer/TextToSpeech through MakepadSpeech.java (API 26 floor), espeak-ng on Linux. It models the two STT shapes honestly: PCM in (Whisper, Apple) versus an engine that owns the microphone (Android, Windows), with capabilities the caller reads. - the hub grows speech sessions, in-process Whisper/Kokoro workers with the residency election, a `whisper` wire backend (stt domain, registry entry pinned to ggerganov/whisper.cpp) so a Mac can serve a Quest, and a `language` field on the generate request. Consumers: the Window voice input runs on an STT session and switches to engine-mic mode when the recognizer owns the microphone; converse's SpeechOutput is a lazily started TTS session plus a pump thread; route drops its private speech copy for converse; vj's lyrics fallback and the alignment bakes call the engines directly. Verified here: speech-roundtrip through the real sessions (Apple voice in, in-process Whisper on Metal out, 4.3% WER); system-speech-test TTS->STT verbatim; hub/converse/system-speech unit tests; msvc, aarch64-android and linux-gnu cross-checks; Java against android-34. Windows, Android and Linux bridges are compile-checked only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2537 lines
108 KiB
Rust
2537 lines
108 KiB
Rust
//! AI route/trip planner — voice-driven navigation copilot.
|
|
//!
|
|
//! M1: tool broker + typed chat, cloud brain first (route.md §6). A Claude
|
|
//! backend drives the tool loop end-to-end: geo_search, route_plan/along,
|
|
//! map camera/markers, weather_now radar nowcast. TripModel is the source
|
|
//! of truth; every agent action is mirrored on the map. Voice, the local
|
|
//! Qwen dispatcher and EV charge planning land in M2+.
|
|
//!
|
|
//! Needs: `local/maps/*` nav data (see examples/map), ANTHROPIC_API_KEY as
|
|
//! env var or a file of that name at the repo root. Run from the repo root.
|
|
|
|
pub use ::makepad_widgets;
|
|
|
|
use makepad_converse::agent_seam::*;
|
|
use makepad_widgets::*;
|
|
|
|
mod broker;
|
|
mod claude_agent;
|
|
mod ddg;
|
|
mod history;
|
|
mod layers;
|
|
mod local_agent;
|
|
mod nav;
|
|
mod nav_data;
|
|
mod testmap;
|
|
mod tools;
|
|
mod trip;
|
|
mod voice;
|
|
|
|
use broker::{MarkerLegend, ToolCtx};
|
|
use ddg::{DdgEvent, DdgState};
|
|
use history::DriveLog;
|
|
use layers::{LayerState, TerrainUpdate, WindUpdate};
|
|
use nav::{ActiveNav, NavAction, NavTick};
|
|
use nav_data::{NavData, NavLoad, RadarData};
|
|
use makepad_converse::SpeechOutput;
|
|
use testmap::{Stage as TestMapStage, TestMapBuild};
|
|
use trip::TripModel;
|
|
use voice::{GateResult, VoiceGate};
|
|
|
|
app_main!(App);
|
|
|
|
/// Dam square, the point the map opens on and where a fresh test map
|
|
/// lands.
|
|
const AMSTERDAM_CENTER: (f64, f64) = (4.8952, 52.3702);
|
|
|
|
const SYSTEM_PROMPT: &str = "\
|
|
You are the route assistant inside a live map app (Netherlands detail, Europe-wide places), \
|
|
a conversational replacement for a car GPS. The user sees a full-screen map; you act only \
|
|
through tools and short replies.
|
|
|
|
Rules:
|
|
- Mirror everything on the map: plan trips with route_plan, drop markers for candidates you \
|
|
mention, fly the camera to places you talk about. The user must always see what you did.
|
|
- Replies are 1-3 short sentences, conversational, no coordinate dumps, no markdown. Tool \
|
|
digests are already shown in the transcript — summarize outcomes, don't repeat them.
|
|
- Stops and legs have stable ids (stop_2, leg_1); use them for changes and references.
|
|
- Coordinates are always lon,lat (WGS84). Waypoints accept place names, 'lon,lat', or 'here'.
|
|
- Each user message ends with an [app state] block (map center, trip digest) — trust it.
|
|
- If a tool reports data still loading or out of coverage, say so briefly; don't guess.";
|
|
|
|
script_mod! {
|
|
use mod.prelude.widgets.*
|
|
use mod.widgets.*
|
|
|
|
mod.widgets.TranscriptListBase = #(TranscriptList::register_widget(vm))
|
|
mod.widgets.TiltShiftLayerBase = #(TiltShiftLayer::register_widget(vm))
|
|
|
|
let PanelText = Label{
|
|
width: Fill
|
|
draw_text +: {
|
|
color: #x22303c
|
|
text_style: theme.font_regular{font_size: 9}
|
|
}
|
|
}
|
|
|
|
// Light panel + touch sizing: the desktop theme's label text is white,
|
|
// so pin dark colors; bigger text/padding for finger targets.
|
|
let LayerCheck = CheckBox{
|
|
padding: Inset{top: 8, bottom: 8, left: 4, right: 10}
|
|
label_walk: Walk{
|
|
width: Fit
|
|
height: Fit
|
|
margin: Inset{left: 22}
|
|
}
|
|
draw_text +: {
|
|
color: #x223038
|
|
color_hover: #x000000
|
|
color_down: #x000000
|
|
color_active: #x223038
|
|
color_focus: #x223038
|
|
text_style: theme.font_regular{font_size: 11}
|
|
}
|
|
}
|
|
|
|
let AppButton = Button{
|
|
draw_text +: {
|
|
color: #x223038
|
|
color_hover: #x000000
|
|
color_focus: #x223038
|
|
color_down: #x000000
|
|
text_style: theme.font_regular{font_size: 12}
|
|
}
|
|
}
|
|
|
|
startup() do #(App::script_component(vm)){
|
|
ui: Root{
|
|
main_window := Window{
|
|
window.inner_size: vec2(3400, 2050)
|
|
pass.clear_color: vec4(0.08, 0.10, 0.12, 1.0)
|
|
body +: {
|
|
View{
|
|
width: Fill
|
|
height: Fill
|
|
flow: Overlay
|
|
|
|
map := MapView{
|
|
width: Fill
|
|
height: Fill
|
|
// GPU-opt benchmark scene: AMS side view at the
|
|
// lowest zoom that shows 3D geometry.
|
|
center_lon: 4.8952
|
|
center_lat: 52.3702
|
|
zoom: 15.6
|
|
tilt: 60.0
|
|
min_zoom: 3.0
|
|
mbtiles_path: "local/maps/world.mkmap"
|
|
detail_mbtiles_path: "local/maps/world.mkmap"
|
|
bridge_dz_mbtiles_path: "local/maps/nl-bridge-dz.mbtiles"
|
|
overlay_mbtiles_paths: "local/maps/ocean-low.mbtiles;local/maps/ocean-high.mbtiles"
|
|
buildings_3d: true
|
|
}
|
|
|
|
// --- Tilt-shift blur (above the map, below all UI) ---
|
|
tilt_shift := mod.widgets.TiltShiftLayerBase{
|
|
draw_bg +: {
|
|
scene_texture: texture_2d(float)
|
|
mip0_texture: texture_2d(float)
|
|
mip1_texture: texture_2d(float)
|
|
mip2_texture: texture_2d(float)
|
|
mip3_texture: texture_2d(float)
|
|
mip4_texture: texture_2d(float)
|
|
mip5_texture: texture_2d(float)
|
|
has_gauss: uniform(0.0)
|
|
source_y_flip: uniform(0.0)
|
|
strength: uniform(0.0)
|
|
focus_y: uniform(0.55)
|
|
band: uniform(0.13)
|
|
|
|
// Bicubic B-spline reconstruction (4 bilinear taps) — same as
|
|
// GaussRoundedView. Single-tap bilinear of a low-res mip shows
|
|
// its texel lattice on the map's thin high-contrast lines.
|
|
bicubic_h: fn(uv: vec2, size: vec2) -> vec4 {
|
|
let tc = uv * size - 0.5
|
|
let f = fract(tc)
|
|
let tc0 = floor(tc)
|
|
let f2 = f * f
|
|
let f3 = f2 * f
|
|
let omf = 1.0 - f
|
|
let w1 = (f3 * 3.0 - f2 * 6.0 + 4.0) / 6.0
|
|
let g0 = omf * omf * omf / 6.0 + w1
|
|
let h0 = clamp((tc0 - 0.5 + w1 / g0) / size, vec2(0.0, 0.0), vec2(1.0, 1.0))
|
|
let h1 = clamp((tc0 + 1.5 + (f3 / 6.0) / (1.0 - g0)) / size, vec2(0.0, 0.0), vec2(1.0, 1.0))
|
|
return vec4(h0.x, h0.y, h1.x, h1.y)
|
|
}
|
|
|
|
bicubic_g0: fn(uv: vec2, size: vec2) -> vec2 {
|
|
let f = fract(uv * size - 0.5)
|
|
let f2 = f * f
|
|
let omf = 1.0 - f
|
|
return omf * omf * omf / 6.0 + (f2 * f * 3.0 - f2 * 6.0 + 4.0) / 6.0
|
|
}
|
|
|
|
sample_at: fn(uv: vec2, idx: float) -> vec4 {
|
|
if idx < 0.5 {
|
|
return self.scene_texture.sample_as_bgra(uv)
|
|
}
|
|
if idx < 1.5 {
|
|
let size = max(self.mip0_texture.size(), vec2(1.0, 1.0))
|
|
let h = self.bicubic_h(uv, size)
|
|
let g0 = self.bicubic_g0(uv, size)
|
|
let g1 = 1.0 - g0
|
|
return self.mip0_texture.sample_as_bgra(vec2(h.x, h.y)) * (g0.x * g0.y)
|
|
+ self.mip0_texture.sample_as_bgra(vec2(h.z, h.y)) * (g1.x * g0.y)
|
|
+ self.mip0_texture.sample_as_bgra(vec2(h.x, h.w)) * (g0.x * g1.y)
|
|
+ self.mip0_texture.sample_as_bgra(vec2(h.z, h.w)) * (g1.x * g1.y)
|
|
}
|
|
if idx < 2.5 {
|
|
let size = max(self.mip1_texture.size(), vec2(1.0, 1.0))
|
|
let h = self.bicubic_h(uv, size)
|
|
let g0 = self.bicubic_g0(uv, size)
|
|
let g1 = 1.0 - g0
|
|
return self.mip1_texture.sample_as_bgra(vec2(h.x, h.y)) * (g0.x * g0.y)
|
|
+ self.mip1_texture.sample_as_bgra(vec2(h.z, h.y)) * (g1.x * g0.y)
|
|
+ self.mip1_texture.sample_as_bgra(vec2(h.x, h.w)) * (g0.x * g1.y)
|
|
+ self.mip1_texture.sample_as_bgra(vec2(h.z, h.w)) * (g1.x * g1.y)
|
|
}
|
|
if idx < 3.5 {
|
|
let size = max(self.mip2_texture.size(), vec2(1.0, 1.0))
|
|
let h = self.bicubic_h(uv, size)
|
|
let g0 = self.bicubic_g0(uv, size)
|
|
let g1 = 1.0 - g0
|
|
return self.mip2_texture.sample_as_bgra(vec2(h.x, h.y)) * (g0.x * g0.y)
|
|
+ self.mip2_texture.sample_as_bgra(vec2(h.z, h.y)) * (g1.x * g0.y)
|
|
+ self.mip2_texture.sample_as_bgra(vec2(h.x, h.w)) * (g0.x * g1.y)
|
|
+ self.mip2_texture.sample_as_bgra(vec2(h.z, h.w)) * (g1.x * g1.y)
|
|
}
|
|
if idx < 4.5 {
|
|
let size = max(self.mip3_texture.size(), vec2(1.0, 1.0))
|
|
let h = self.bicubic_h(uv, size)
|
|
let g0 = self.bicubic_g0(uv, size)
|
|
let g1 = 1.0 - g0
|
|
return self.mip3_texture.sample_as_bgra(vec2(h.x, h.y)) * (g0.x * g0.y)
|
|
+ self.mip3_texture.sample_as_bgra(vec2(h.z, h.y)) * (g1.x * g0.y)
|
|
+ self.mip3_texture.sample_as_bgra(vec2(h.x, h.w)) * (g0.x * g1.y)
|
|
+ self.mip3_texture.sample_as_bgra(vec2(h.z, h.w)) * (g1.x * g1.y)
|
|
}
|
|
if idx < 5.5 {
|
|
let size = max(self.mip4_texture.size(), vec2(1.0, 1.0))
|
|
let h = self.bicubic_h(uv, size)
|
|
let g0 = self.bicubic_g0(uv, size)
|
|
let g1 = 1.0 - g0
|
|
return self.mip4_texture.sample_as_bgra(vec2(h.x, h.y)) * (g0.x * g0.y)
|
|
+ self.mip4_texture.sample_as_bgra(vec2(h.z, h.y)) * (g1.x * g0.y)
|
|
+ self.mip4_texture.sample_as_bgra(vec2(h.x, h.w)) * (g0.x * g1.y)
|
|
+ self.mip4_texture.sample_as_bgra(vec2(h.z, h.w)) * (g1.x * g1.y)
|
|
}
|
|
let size = max(self.mip5_texture.size(), vec2(1.0, 1.0))
|
|
let h = self.bicubic_h(uv, size)
|
|
let g0 = self.bicubic_g0(uv, size)
|
|
let g1 = 1.0 - g0
|
|
return self.mip5_texture.sample_as_bgra(vec2(h.x, h.y)) * (g0.x * g0.y)
|
|
+ self.mip5_texture.sample_as_bgra(vec2(h.z, h.y)) * (g1.x * g0.y)
|
|
+ self.mip5_texture.sample_as_bgra(vec2(h.x, h.w)) * (g0.x * g1.y)
|
|
+ self.mip5_texture.sample_as_bgra(vec2(h.z, h.w)) * (g1.x * g1.y)
|
|
}
|
|
|
|
pixel: fn() {
|
|
if self.has_gauss < 0.5 {
|
|
return vec4(0.0, 0.0, 0.0, 0.0)
|
|
}
|
|
let uv = self.pos
|
|
let source_uv = vec2(uv.x, mix(uv.y, 1.0 - uv.y, self.source_y_flip))
|
|
let safe_uv = clamp(source_uv, vec2(0.0, 0.0), vec2(1.0, 1.0))
|
|
// Distance out of the focus band, 0..1 to the edge.
|
|
let d = abs(uv.y - self.focus_y)
|
|
let t = clamp((d - self.band) / 0.45, 0.0, 1.0)
|
|
// Linear circle-of-confusion: blur RADIUS grows linearly with
|
|
// distance (like a real lens), so the pyramid level is its log2.
|
|
// A linear LEVEL ramp doubles the radius per step and packs half
|
|
// the total blur growth into the last stretch before the screen
|
|
// edge — it reads as a hard blur "band" at the top.
|
|
//
|
|
// The growth RATE is a constant: tilt strength only raises the
|
|
// level ceiling. Scaling the rate with strength (radius factor
|
|
// exp2(6*strength)) slams full tilt into deep levels within a few
|
|
// percent of screen past the focus band — a hard band again.
|
|
let level = clamp(log2(1.0 + t * 21.0), 0.0, 6.0 * self.strength)
|
|
let base_idx = floor(level)
|
|
let frac = level - base_idx
|
|
let a = self.sample_at(safe_uv, base_idx)
|
|
let b = self.sample_at(safe_uv, base_idx + 1.0)
|
|
let c = a.mix(b, frac)
|
|
// Slight saturation lift sells the miniature look.
|
|
let gray = (c.x + c.y + c.z) / 3.0
|
|
let sat = vec3(gray, gray, gray).mix(c.xyz, 1.12)
|
|
return vec4(sat, 1.0)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- All UI hoists above the tilt-shift layer ---
|
|
ui_layer := mod.widgets.glass.Layer{
|
|
|
|
// --- Turn banner (top-center) ---
|
|
View{
|
|
width: Fill
|
|
height: Fit
|
|
align: Align{x: 0.5 y: 0.0}
|
|
banner := RoundedView{
|
|
visible: false
|
|
flow: Down
|
|
width: Fit
|
|
height: Fit
|
|
margin: Inset{top: 12}
|
|
padding: Inset{left: 18, right: 18, top: 10, bottom: 10}
|
|
align: Align{x: 0.5 y: 0.0}
|
|
draw_bg +: {
|
|
color: #x1a7a3cf0
|
|
border_radius: 9.0
|
|
}
|
|
banner_text := Label{
|
|
draw_text +: {
|
|
color: #xffffff
|
|
text_style: theme.font_bold{font_size: 13}
|
|
}
|
|
}
|
|
banner_dist := Label{
|
|
draw_text +: {
|
|
color: #xd8f2e2
|
|
text_style: theme.font_regular{font_size: 10}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Layers popover (bottom-left, touch) ---
|
|
View{
|
|
width: Fill
|
|
height: Fill
|
|
flow: Down
|
|
align: Align{x: 0.0 y: 1.0}
|
|
layers_panel := mod.widgets.glass.Panel{
|
|
visible: false
|
|
flow: Down
|
|
width: Fit
|
|
height: Fit
|
|
margin: Inset{left: 14, bottom: 6}
|
|
padding: Inset{left: 18, right: 22, top: 14, bottom: 14}
|
|
spacing: 2
|
|
draw_bg +: {
|
|
corner_radius: 12.0
|
|
tint_color: #xf8fbff
|
|
tint_alpha: 0.30
|
|
}
|
|
// View-effects group first: these two act
|
|
// on the CAMERA/rendering, not on map data
|
|
// — the divider separates them from the
|
|
// content layers below (same unlabeled
|
|
// hairline idiom as the theme group).
|
|
tilt_check := LayerCheck{text: "Tilt-shift"}
|
|
// Grayed (not hidden) outside the
|
|
// near-first-person regime — the stock
|
|
// disabled label washes out on the glass
|
|
// popover, so keep it readable.
|
|
warp_check := LayerCheck{
|
|
text: "Space warp"
|
|
draw_text +: { color_disabled: #x8a8f98 }
|
|
}
|
|
Hr{
|
|
height: 16
|
|
}
|
|
layer_rain := LayerCheck{text: "Rain radar"}
|
|
layer_wind := LayerCheck{text: "Wind"}
|
|
layer_terrain := LayerCheck{text: "Terrain"}
|
|
layer_chargers := LayerCheck{text: "EV chargers"}
|
|
layer_transit := LayerCheck{text: "Transit"}
|
|
layer_nature := LayerCheck{text: "Nature"}
|
|
layer_districts := LayerCheck{text: "Districts"}
|
|
layer_buildings := LayerCheck{text: "Building age"}
|
|
layer_demographics := LayerCheck{text: "Population"}
|
|
Hr{
|
|
height: 16
|
|
}
|
|
theme_night := LayerCheck{text: "Night theme"}
|
|
theme_circuit := LayerCheck{text: "Circuit City"}
|
|
}
|
|
layers_button := AppButton{
|
|
margin: Inset{left: 14, bottom: 16}
|
|
padding: Inset{left: 16, right: 16, top: 12, bottom: 12}
|
|
text: "▤"
|
|
}
|
|
}
|
|
|
|
// --- Assistant panel (bottom-right popover, closed by default) ---
|
|
View{
|
|
width: Fill
|
|
height: Fill
|
|
flow: Down
|
|
align: Align{x: 1.0 y: 1.0}
|
|
assistant_panel := mod.widgets.glass.Panel{
|
|
visible: false
|
|
flow: Down
|
|
width: 380
|
|
height: 620
|
|
margin: Inset{right: 14, bottom: 6}
|
|
padding: 10
|
|
spacing: 0
|
|
draw_bg +: {
|
|
corner_radius: 9.0
|
|
tint_color: #xf8fbff
|
|
tint_alpha: 0.30
|
|
}
|
|
header_label := Label{
|
|
draw_text +: {
|
|
color: #x223038
|
|
text_style: theme.font_bold{font_size: 11}
|
|
}
|
|
text: "Route Assistant"
|
|
}
|
|
intro_label := PanelText{
|
|
height: Fit
|
|
margin: Inset{top: 8}
|
|
text: "Ask about a trip: destinations, stops, sights and chargers along the way, rain at a point. Type /help for direct tool commands."
|
|
}
|
|
transcript_list := mod.widgets.TranscriptListBase{
|
|
width: Fill
|
|
height: Fill
|
|
margin: Inset{top: 8, bottom: 8}
|
|
list := PortalList{
|
|
width: Fill
|
|
height: Fill
|
|
UserLine := View{
|
|
width: Fill
|
|
height: Fit
|
|
margin: Inset{top: 8}
|
|
line_label := Label{
|
|
width: Fill
|
|
draw_text +: {
|
|
color: #x101820
|
|
text_style: theme.font_bold{font_size: 9.5}
|
|
}
|
|
}
|
|
}
|
|
AssistantLine := View{
|
|
width: Fill
|
|
height: Fit
|
|
margin: Inset{top: 4}
|
|
line_label := Label{
|
|
width: Fill
|
|
draw_text +: {
|
|
color: #x2a3540
|
|
text_style: theme.font_regular{font_size: 9.5}
|
|
}
|
|
}
|
|
}
|
|
ToolLine := View{
|
|
width: Fill
|
|
height: Fit
|
|
margin: Inset{top: 3, left: 8}
|
|
line_label := Label{
|
|
width: Fill
|
|
draw_text +: {
|
|
color: #x7a8794
|
|
text_style: theme.font_regular{font_size: 8.5}
|
|
}
|
|
}
|
|
}
|
|
InfoLine := View{
|
|
width: Fill
|
|
height: Fit
|
|
margin: Inset{top: 3, left: 8}
|
|
line_label := Label{
|
|
width: Fill
|
|
draw_text +: {
|
|
color: #x93a0ad
|
|
text_style: theme.font_regular{font_size: 8.5}
|
|
}
|
|
}
|
|
}
|
|
TripLine := View{
|
|
width: Fill
|
|
height: Fit
|
|
flow: Right
|
|
spacing: 6
|
|
margin: Inset{top: 8}
|
|
apply_btn := Button{
|
|
text: ">"
|
|
}
|
|
line_label := Label{
|
|
width: Fill
|
|
margin: Inset{top: 4}
|
|
draw_text +: {
|
|
color: #x1d4ed8
|
|
text_style: theme.font_bold{font_size: 9.5}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
images_row := View{
|
|
visible: false
|
|
width: Fill
|
|
height: Fit
|
|
flow: Right
|
|
spacing: 4
|
|
margin: Inset{bottom: 6}
|
|
img_0 := Image{ width: 86, height: 64 }
|
|
img_1 := Image{ width: 86, height: 64 }
|
|
img_2 := Image{ width: 86, height: 64 }
|
|
img_3 := Image{ width: 86, height: 64 }
|
|
}
|
|
input_row := View{
|
|
width: Fill
|
|
height: Fit
|
|
flow: Right
|
|
spacing: 6
|
|
align: Align{x: 0.0 y: 0.5}
|
|
mic_button := AppButton{
|
|
padding: Inset{left: 12, right: 12, top: 8, bottom: 8}
|
|
text: "🎤"
|
|
}
|
|
speaker_button := AppButton{
|
|
padding: Inset{left: 12, right: 12, top: 8, bottom: 8}
|
|
text: "🔊"
|
|
}
|
|
// Kept visible (events must flow) but
|
|
// zero-sized: the mic button is the only
|
|
// recording indicator.
|
|
mic_wave := VoiceWave{
|
|
width: 0
|
|
height: 0
|
|
}
|
|
prompt_input := TextInput{
|
|
width: Fill
|
|
empty_text: "Plan a trip…"
|
|
// The desktop theme's text colors are light
|
|
// (for dark insets); the panel is light, so
|
|
// pin dark text + medium placeholder.
|
|
draw_text +: {
|
|
color: #x16202a
|
|
color_hover: #x000000
|
|
color_focus: #x0b1218
|
|
color_down: #x000000
|
|
color_empty: #x6b7784
|
|
color_empty_hover: #x57626e
|
|
color_empty_focus: #x6b7784
|
|
}
|
|
}
|
|
} // input_row
|
|
status_label := PanelText{
|
|
margin: Inset{top: 6, left: 2}
|
|
text: "starting…"
|
|
}
|
|
}
|
|
assistant_button := AppButton{
|
|
margin: Inset{right: 14, bottom: 16}
|
|
padding: Inset{left: 16, right: 16, top: 12, bottom: 12}
|
|
spacing: 0
|
|
text: ""
|
|
icon_walk: Walk{width: 18, height: 18}
|
|
draw_icon +: {
|
|
svg: crate_resource("self://resources/icons/assistant.svg")
|
|
color: #x223038
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- First-run test map (centered, over everything) ---
|
|
View{
|
|
width: Fill
|
|
height: Fill
|
|
align: Align{x: 0.5 y: 0.5}
|
|
testmap_panel := mod.widgets.glass.Panel{
|
|
visible: false
|
|
flow: Down
|
|
width: 520
|
|
height: Fit
|
|
padding: Inset{left: 24, right: 24, top: 20, bottom: 20}
|
|
spacing: 8
|
|
draw_bg +: {
|
|
corner_radius: 14.0
|
|
tint_color: #xf8fbff
|
|
tint_alpha: 0.36
|
|
}
|
|
Label{
|
|
draw_text +: {
|
|
color: #x223038
|
|
text_style: theme.font_bold{font_size: 13}
|
|
}
|
|
text: "Amsterdam test map"
|
|
}
|
|
testmap_headline := Label{
|
|
width: Fill
|
|
draw_text +: {
|
|
color: #x223038
|
|
text_style: theme.font_bold{font_size: 10.5}
|
|
}
|
|
}
|
|
// Track and fill: the fill's width is set
|
|
// from the bake fraction each frame.
|
|
RoundedView{
|
|
width: 470
|
|
height: 10
|
|
margin: Inset{top: 2, bottom: 2}
|
|
draw_bg +: {
|
|
color: #x22303820
|
|
border_radius: 5.0
|
|
}
|
|
testmap_bar := RoundedView{
|
|
width: 0
|
|
height: Fill
|
|
draw_bg +: {
|
|
color: #x1d4ed8
|
|
border_radius: 5.0
|
|
}
|
|
}
|
|
}
|
|
testmap_status := PanelText{}
|
|
testmap_log := Label{
|
|
width: Fill
|
|
draw_text +: {
|
|
color: #x6b7784
|
|
text_style: theme.font_regular{font_size: 8.5}
|
|
}
|
|
}
|
|
View{
|
|
width: Fill
|
|
height: Fit
|
|
flow: Right
|
|
spacing: 8
|
|
margin: Inset{top: 6}
|
|
testmap_start := AppButton{
|
|
padding: Inset{left: 14, right: 14, top: 8, bottom: 8}
|
|
text: "Build test map"
|
|
}
|
|
testmap_dismiss := AppButton{
|
|
padding: Inset{left: 14, right: 14, top: 8, bottom: 8}
|
|
text: "Not now"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
} // ui_layer
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq)]
|
|
pub enum EntryKind {
|
|
User,
|
|
Assistant,
|
|
Tool,
|
|
Info,
|
|
}
|
|
|
|
/// One transcript row; `trip` indexes into `ChatState::trips` and renders
|
|
/// with a `>` re-apply button.
|
|
pub struct ChatEntry {
|
|
pub kind: EntryKind,
|
|
pub text: String,
|
|
pub trip: Option<usize>,
|
|
}
|
|
|
|
/// Shared with `TranscriptList`/`TiltShiftLayer` via `Scope::with_data`.
|
|
#[derive(Default)]
|
|
pub struct ChatState {
|
|
pub entries: Vec<ChatEntry>,
|
|
pub trips: Vec<TripModel>,
|
|
/// Streaming assistant text of the in-flight turn.
|
|
pub pending: String,
|
|
/// UI follows the map theme (night/circuit = dark panels).
|
|
pub dark: bool,
|
|
/// Tilt-shift enabled (settings checkbox) — the layer still gates on
|
|
/// the map actually being tilted via `tilt_strength`.
|
|
pub tilt_shift_on: bool,
|
|
/// 0..1 from the current map tilt (0 = flat, no blur).
|
|
pub tilt_strength: f32,
|
|
}
|
|
|
|
/// Full-window tilt-shift blur over the base scene: samples the window
|
|
/// gauss pyramid per pixel — sharp focus band, mip level rising towards
|
|
/// the top/bottom edges. Hoists into the overlay draw list (like glass),
|
|
/// so panels drawn as glass stay sharp above it.
|
|
#[derive(Script, ScriptHook, Widget)]
|
|
pub struct TiltShiftLayer {
|
|
#[uid]
|
|
uid: WidgetUid,
|
|
#[source]
|
|
source: ScriptObjectRef,
|
|
#[rust]
|
|
draw_list: Option<DrawList2d>,
|
|
#[walk]
|
|
walk: Walk,
|
|
#[layout]
|
|
layout: Layout,
|
|
#[redraw]
|
|
#[live]
|
|
draw_bg: DrawQuad,
|
|
}
|
|
|
|
impl TiltShiftLayer {
|
|
fn bind_snapshot(&mut self, cx: &mut Cx2d, snapshot: Option<GaussBlurSnapshot>) {
|
|
let draw = &mut self.draw_bg.draw_vars;
|
|
if let Some(snapshot) = snapshot {
|
|
draw.set_texture(0, &snapshot.scene_texture);
|
|
for slot in 1..=GAUSS_VIEW_LEVELS {
|
|
if let Some(texture) = snapshot.mip_textures.get(slot - 1) {
|
|
draw.set_texture(slot, texture);
|
|
} else {
|
|
draw.empty_texture(slot);
|
|
}
|
|
}
|
|
draw.set_uniform(cx, live_id!(source_y_flip), &[snapshot.source_y_flip]);
|
|
draw.set_uniform(cx, live_id!(has_gauss), &[1.0]);
|
|
} else {
|
|
for slot in 0..=GAUSS_VIEW_LEVELS {
|
|
draw.empty_texture(slot);
|
|
}
|
|
draw.set_uniform(cx, live_id!(has_gauss), &[0.0]);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Widget for TiltShiftLayer {
|
|
fn handle_event(&mut self, _cx: &mut Cx, _event: &Event, _scope: &mut Scope) {}
|
|
|
|
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, _walk: Walk) -> DrawStep {
|
|
let (on, strength) = scope
|
|
.data
|
|
.get_mut::<ChatState>()
|
|
.map(|chat| (chat.tilt_shift_on, chat.tilt_strength))
|
|
.unwrap_or((false, 0.0));
|
|
if self.draw_list.is_none() {
|
|
self.draw_list = Some(DrawList2d::new(cx));
|
|
}
|
|
// ALWAYS claim the overlay slot, even when inactive: overlay lists
|
|
// keep their first-appended position, so activating later would
|
|
// put the blur ABOVE UI layers that registered earlier.
|
|
let draw_list = self.draw_list.as_mut().unwrap();
|
|
draw_list.begin_overlay_reuse(cx);
|
|
let size = cx.current_pass_size();
|
|
cx.begin_root_turtle(size, self.layout);
|
|
if on && strength > 0.01 {
|
|
let snapshot = request_window_gauss(cx);
|
|
self.bind_snapshot(cx, snapshot);
|
|
self.draw_bg
|
|
.draw_vars
|
|
.set_uniform(cx, live_id!(strength), &[strength]);
|
|
self.draw_bg.draw_abs(
|
|
cx,
|
|
Rect {
|
|
pos: dvec2(0.0, 0.0),
|
|
size,
|
|
},
|
|
);
|
|
}
|
|
cx.end_pass_sized_turtle();
|
|
self.draw_list.as_mut().unwrap().end(cx);
|
|
DrawStep::done()
|
|
}
|
|
}
|
|
|
|
/// Per-kind transcript text color for the current UI theme.
|
|
fn entry_color(kind: EntryKind, is_trip: bool, dark: bool) -> Vec4 {
|
|
if is_trip {
|
|
return if dark { vec4(0.38, 0.65, 0.98, 1.0) } else { vec4(0.11, 0.31, 0.85, 1.0) };
|
|
}
|
|
match (kind, dark) {
|
|
(EntryKind::User, false) => vec4(0.06, 0.09, 0.13, 1.0),
|
|
(EntryKind::User, true) => vec4(0.91, 0.93, 0.96, 1.0),
|
|
(EntryKind::Assistant, false) => vec4(0.16, 0.21, 0.25, 1.0),
|
|
(EntryKind::Assistant, true) => vec4(0.77, 0.81, 0.85, 1.0),
|
|
(EntryKind::Tool, false) => vec4(0.48, 0.53, 0.58, 1.0),
|
|
(EntryKind::Tool, true) => vec4(0.47, 0.51, 0.55, 1.0),
|
|
(EntryKind::Info, false) => vec4(0.58, 0.63, 0.68, 1.0),
|
|
(EntryKind::Info, true) => vec4(0.42, 0.46, 0.51, 1.0),
|
|
}
|
|
}
|
|
|
|
/// PortalList-backed chat transcript; rows come from the `ChatState` in
|
|
/// the event/draw scope.
|
|
#[derive(Script, ScriptHook, Widget)]
|
|
pub struct TranscriptList {
|
|
#[source]
|
|
source: ScriptObjectRef,
|
|
#[deref]
|
|
view: View,
|
|
}
|
|
|
|
impl Widget for TranscriptList {
|
|
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
|
|
while let Some(item) = self.view.draw_walk(cx, scope, walk).step() {
|
|
if let Some(mut list) = item.borrow_mut::<PortalList>() {
|
|
let Some(chat) = scope.data.get_mut::<ChatState>() else {
|
|
continue;
|
|
};
|
|
let extra = if chat.pending.is_empty() { 0 } else { 1 };
|
|
let total = chat.entries.len() + extra;
|
|
list.set_item_range(cx, 0, total);
|
|
while let Some(idx) = list.next_visible_item(cx) {
|
|
// next_visible_item fills the viewport past the range —
|
|
// out-of-range ids must draw nothing.
|
|
if idx >= total {
|
|
continue;
|
|
}
|
|
let (text, template) = if let Some(entry) = chat.entries.get(idx) {
|
|
let template = if entry.trip.is_some() {
|
|
id!(TripLine)
|
|
} else {
|
|
match entry.kind {
|
|
EntryKind::User => id!(UserLine),
|
|
EntryKind::Assistant => id!(AssistantLine),
|
|
EntryKind::Tool => id!(ToolLine),
|
|
EntryKind::Info => id!(InfoLine),
|
|
}
|
|
};
|
|
(entry.text.clone(), template)
|
|
} else {
|
|
(chat.pending.clone(), id!(AssistantLine))
|
|
};
|
|
let is_trip = chat
|
|
.entries
|
|
.get(idx)
|
|
.map(|e| e.trip.is_some())
|
|
.unwrap_or(false);
|
|
let kind = chat
|
|
.entries
|
|
.get(idx)
|
|
.map(|e| e.kind)
|
|
.unwrap_or(EntryKind::Assistant);
|
|
let color = entry_color(kind, is_trip, chat.dark);
|
|
let item = list.item(cx, idx, template);
|
|
let mut label = item.label(cx, ids!(line_label));
|
|
label.set_text(cx, &text);
|
|
script_apply_eval!(cx, label, {
|
|
draw_text +: {
|
|
color: #(color)
|
|
}
|
|
});
|
|
item.draw_all(cx, &mut Scope::empty());
|
|
}
|
|
}
|
|
}
|
|
DrawStep::done()
|
|
}
|
|
|
|
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
|
|
self.view.handle_event(cx, event, scope);
|
|
}
|
|
}
|
|
|
|
#[derive(Script, ScriptHook)]
|
|
pub struct App {
|
|
#[live]
|
|
ui: WidgetRef,
|
|
#[rust]
|
|
started: bool,
|
|
/// Last pushed disabled-state of the Space-warp row (None = never
|
|
/// pushed): the row grays out whenever the camera leaves the
|
|
/// near-first-person regime and re-enables when it returns.
|
|
#[rust]
|
|
warp_check_disabled: Option<bool>,
|
|
#[rust]
|
|
chat: ChatState,
|
|
#[rust]
|
|
drive_log: DriveLog,
|
|
#[rust]
|
|
agent: Option<Box<dyn Agent>>,
|
|
#[rust]
|
|
session: Option<SessionId>,
|
|
/// Claude escalation agent behind the cloud_ask tool (None = offline).
|
|
#[rust]
|
|
cloud_agent: Option<Box<dyn Agent>>,
|
|
#[rust]
|
|
cloud_session: Option<SessionId>,
|
|
/// In-flight cloud_ask: (local tool_use_id, accumulated answer).
|
|
#[rust]
|
|
pending_cloud: Option<(String, String)>,
|
|
/// Turn timing shared with the LocalAgent worker.
|
|
#[rust]
|
|
local_timing: Option<std::sync::Arc<std::sync::Mutex<String>>>,
|
|
#[rust]
|
|
busy: bool,
|
|
#[rust]
|
|
nav_rx: ToUIReceiver<NavLoad>,
|
|
#[rust]
|
|
radar_rx: ToUIReceiver<RadarData>,
|
|
#[rust]
|
|
nav: Option<NavData>,
|
|
#[rust]
|
|
radar: Option<RadarData>,
|
|
#[rust]
|
|
trip: TripModel,
|
|
#[rust]
|
|
markers: MarkerLegend,
|
|
/// Latest GPS fix from the platform geo service.
|
|
#[rust]
|
|
position: Option<LocationUpdateEvent>,
|
|
#[rust]
|
|
had_first_fix: bool,
|
|
#[rust]
|
|
layers: LayerState,
|
|
#[rust]
|
|
layers_panel_open: bool,
|
|
/// First-run map acquisition (download + bake). Idle on a machine that
|
|
/// already has map data.
|
|
#[rust]
|
|
testmap: TestMapBuild,
|
|
/// The finished test map is adopted once, not on every poll.
|
|
#[rust]
|
|
testmap_adopted: bool,
|
|
/// Route assistant popover (bottom-right button). Closed on every
|
|
/// launch — nothing persisted.
|
|
#[rust]
|
|
assistant_panel_open: bool,
|
|
/// Routed legs with maneuvers (for turn-by-turn).
|
|
#[rust]
|
|
leg_routes: Vec<makepad_map_nav::graph::Route>,
|
|
#[rust]
|
|
active_nav: Option<ActiveNav>,
|
|
#[rust]
|
|
nav_frame: NextFrame,
|
|
#[rust]
|
|
ddg: DdgState,
|
|
/// Continuous voice loop active (mic button).
|
|
#[rust]
|
|
mic_on: bool,
|
|
#[rust]
|
|
whisper_warmed: bool,
|
|
#[rust]
|
|
ai_llm_ready: bool,
|
|
#[rust]
|
|
ai_gate_ready: bool,
|
|
#[rust]
|
|
voice_gate: Option<VoiceGate>,
|
|
/// SEND instructions that arrived while the agent was busy.
|
|
#[rust]
|
|
voice_queue: Vec<String>,
|
|
#[rust]
|
|
wind_rx: ToUIReceiver<WindUpdate>,
|
|
#[rust]
|
|
terrain_rx: ToUIReceiver<TerrainUpdate>,
|
|
/// Kokoro voice output (🔊 button). None until first startup.
|
|
#[rust]
|
|
speech: Option<SpeechOutput>,
|
|
/// Last nav banner instruction spoken, so each maneuver is announced once.
|
|
#[rust]
|
|
last_spoken_banner: String,
|
|
/// In-flight dispatcher prompt, for user-override cancellation.
|
|
#[rust]
|
|
current_prompt: Option<PromptId>,
|
|
/// Tool calls executed for the current prompt (loop budget).
|
|
#[rust]
|
|
tool_rounds: usize,
|
|
/// Previous (name, args) this turn — breaks identical-call loops.
|
|
#[rust]
|
|
last_tool_call: Option<(String, String)>,
|
|
}
|
|
|
|
/// The machine's UTC offset in seconds, read once. The platform has no
|
|
/// timezone database, so we ask the system's own `date` — which knows about
|
|
/// DST — instead of guessing. Same house pattern as
|
|
/// `apps/mpfiles/src/model.rs::local_utc_offset_secs`.
|
|
fn local_utc_offset_secs() -> i64 {
|
|
static OFFSET: std::sync::OnceLock<i64> = std::sync::OnceLock::new();
|
|
*OFFSET.get_or_init(|| {
|
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
{
|
|
let Ok(out) = std::process::Command::new("date").arg("+%z").output() else {
|
|
return 0;
|
|
};
|
|
return parse_utc_offset(String::from_utf8_lossy(&out.stdout).trim());
|
|
}
|
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
|
0
|
|
})
|
|
}
|
|
|
|
/// `+0200` / `-0730` -> seconds east of UTC.
|
|
fn parse_utc_offset(text: &str) -> i64 {
|
|
let bytes = text.as_bytes();
|
|
if bytes.len() < 5 || (bytes[0] != b'+' && bytes[0] != b'-') {
|
|
return 0;
|
|
}
|
|
let Ok(hours) = text[1..3].parse::<i64>() else {
|
|
return 0;
|
|
};
|
|
let Ok(minutes) = text[3..5].parse::<i64>() else {
|
|
return 0;
|
|
};
|
|
let magnitude = hours * 3600 + minutes * 60;
|
|
if bytes[0] == b'-' {
|
|
-magnitude
|
|
} else {
|
|
magnitude
|
|
}
|
|
}
|
|
|
|
/// The current local hour-of-day (0..24), wall clock + system UTC offset.
|
|
fn local_hour_now() -> u32 {
|
|
let epoch_secs = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0);
|
|
let local_secs = epoch_secs + local_utc_offset_secs();
|
|
(local_secs.rem_euclid(86_400) / 3600) as u32
|
|
}
|
|
|
|
/// Civil-twilight approximation for the startup theme, no location lookup:
|
|
/// night from 19:00 through 06:59, light from 07:00 through 18:59.
|
|
fn theme_name_for_hour(hour: u32) -> &'static str {
|
|
if hour >= 19 || hour < 7 {
|
|
"night"
|
|
} else {
|
|
"light"
|
|
}
|
|
}
|
|
|
|
/// Pull "ctx USED/MAX" out of the local timing status line.
|
|
fn parse_ctx_usage(timing: &str) -> Option<(usize, usize)> {
|
|
let at = timing.rfind("ctx ")?;
|
|
let rest = &timing[at + 4..];
|
|
let (used, max) = rest.trim().split_once('/')?;
|
|
Some((
|
|
used.trim().parse().ok()?,
|
|
max.trim()
|
|
.split(|c: char| !c.is_ascii_digit())
|
|
.next()?
|
|
.parse()
|
|
.ok()?,
|
|
))
|
|
}
|
|
|
|
fn read_secret(name: &str) -> Option<String> {
|
|
if let Ok(v) = std::env::var(name) {
|
|
let v = v.trim().to_string();
|
|
if !v.is_empty() {
|
|
return Some(v);
|
|
}
|
|
}
|
|
if let Ok(v) = std::fs::read_to_string(name) {
|
|
let v = v.trim().to_string();
|
|
if !v.is_empty() {
|
|
return Some(v);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
impl App {
|
|
/// Idempotent init; also re-runs after a script hot-reload wipes
|
|
/// `#[rust]` state (same guard pattern as examples/map).
|
|
fn ensure_started(&mut self, cx: &mut Cx) {
|
|
if self.started {
|
|
return;
|
|
}
|
|
self.started = true;
|
|
start_memory_watchdog(None);
|
|
// Civil-twilight default (route.md follow-up): night 19:00-06:59,
|
|
// light 07:00-18:59, local wall clock, no location lookup. Same
|
|
// set_theme_name + apply_layers path the "Night theme" checkbox
|
|
// uses, so the checkbox, the chrome and the map all agree; the
|
|
// user can still flip it manually afterwards.
|
|
let _ = self.layers.set_theme_name(theme_name_for_hour(local_hour_now()));
|
|
self.layers.dirty = false;
|
|
// Applies the theme above (chrome + map + checkboxes) and reflects
|
|
// the rest of the LayerState defaults (e.g. tilt-shift on) in the
|
|
// layers popover.
|
|
self.apply_layers(cx);
|
|
self.adopt_map_source(cx);
|
|
nav_data::start_radar_worker(self.radar_rx.sender());
|
|
cx.start_location_updates();
|
|
// Kokoro af_heart when weights are in reach (this process, the machine
|
|
// node, a LAN box), else the OS voice — the hub decides.
|
|
let speech = SpeechOutput::new("af_heart");
|
|
speech.install_audio_output(cx, 0);
|
|
self.speech = Some(speech);
|
|
self.init_agent(cx);
|
|
self.update_ai_status(cx);
|
|
}
|
|
|
|
/// Point the map and the nav plane at whatever this machine has:
|
|
/// the production archives, else a baked test map, else nothing — in
|
|
/// which case the first-run popup offers to build one.
|
|
fn adopt_map_source(&mut self, cx: &mut Cx) {
|
|
if let Some(basename) = nav_data::nav_basename() {
|
|
nav_data::start_nav_load(self.nav_rx.sender(), basename);
|
|
}
|
|
if testmap::production_archive_present() {
|
|
return;
|
|
}
|
|
let paths = self.testmap.paths.clone();
|
|
if paths.archive.is_file() {
|
|
// A test map is all we have: the ocean and bridge-elevation
|
|
// overlays belong to the production set and are not part of it.
|
|
let archive = paths.archive.to_string_lossy().into_owned();
|
|
let map = self.ui.map_view(cx, ids!(map));
|
|
map.set_source_paths(cx, &archive, &archive, "");
|
|
map.set_overlay_paths(cx, "");
|
|
return;
|
|
}
|
|
self.testmap.offer_if_no_map(false);
|
|
if self.testmap.is_offered() {
|
|
self.testmap.start(cx);
|
|
}
|
|
self.refresh_testmap_ui(cx);
|
|
}
|
|
|
|
/// Mirror the build state into the popup.
|
|
fn refresh_testmap_ui(&mut self, cx: &mut Cx) {
|
|
let active = self.testmap.is_active();
|
|
self.ui
|
|
.widget(cx, ids!(testmap_panel))
|
|
.set_visible(cx, active);
|
|
if !active {
|
|
return;
|
|
}
|
|
self.ui
|
|
.label(cx, ids!(testmap_headline))
|
|
.set_text(cx, &self.testmap.headline);
|
|
self.ui
|
|
.label(cx, ids!(testmap_status))
|
|
.set_text(cx, &self.testmap.status_line());
|
|
self.ui
|
|
.label(cx, ids!(testmap_log))
|
|
.set_text(cx, &self.testmap.log.join("\n"));
|
|
// The bar is a plain fill inside a fixed 470px track.
|
|
let width = (470.0 * self.testmap.fraction.clamp(0.0, 1.0) as f64).round();
|
|
let mut bar = self.ui.widget(cx, ids!(testmap_bar));
|
|
script_apply_eval!(cx, bar, {
|
|
width: #(width)
|
|
});
|
|
let running = self.testmap.is_running();
|
|
let done = matches!(self.testmap.stage, TestMapStage::Done);
|
|
self.ui
|
|
.button(cx, ids!(testmap_start))
|
|
.set_visible(cx, !running && !done);
|
|
self.ui.button(cx, ids!(testmap_start)).set_text(
|
|
cx,
|
|
if matches!(self.testmap.stage, TestMapStage::Failed(_)) {
|
|
"Try again"
|
|
} else {
|
|
"Build test map"
|
|
},
|
|
);
|
|
self.ui.button(cx, ids!(testmap_dismiss)).set_text(
|
|
cx,
|
|
match self.testmap.stage {
|
|
TestMapStage::Fetching { .. } => "Cancel",
|
|
TestMapStage::Done => "Start driving",
|
|
_ => "Not now",
|
|
},
|
|
);
|
|
// The bake owns the machine for a minute and has no cancel; the
|
|
// popup stays put rather than offering a button that does nothing.
|
|
self.ui
|
|
.button(cx, ids!(testmap_dismiss))
|
|
.set_visible(cx, !matches!(self.testmap.stage, TestMapStage::Baking));
|
|
self.ui.redraw(cx);
|
|
}
|
|
|
|
/// The bake finished: adopt what it built without a restart.
|
|
fn testmap_finished(&mut self, cx: &mut Cx) {
|
|
let paths = self.testmap.paths.clone();
|
|
let archive = paths.archive.to_string_lossy().into_owned();
|
|
let map = self.ui.map_view(cx, ids!(map));
|
|
map.set_source_paths(cx, &archive, &archive, "");
|
|
map.set_overlay_paths(cx, "");
|
|
map.set_center(cx, AMSTERDAM_CENTER.0, AMSTERDAM_CENTER.1);
|
|
nav_data::start_nav_load(
|
|
self.nav_rx.sender(),
|
|
paths.nav_basename.to_string_lossy().into_owned(),
|
|
);
|
|
self.push_entry(
|
|
cx,
|
|
EntryKind::Info,
|
|
"test map ready: Amsterdam tiles, routing graph and search index",
|
|
);
|
|
}
|
|
|
|
fn make_claude(api_key: String) -> Box<dyn Agent> {
|
|
let model = std::env::var("MAKEPAD_ROUTE_MODEL")
|
|
.unwrap_or_else(|_| "claude-sonnet-5".to_string());
|
|
Box::new(claude_agent::ClaudeAgent::new(model, api_key))
|
|
}
|
|
|
|
/// Dispatcher: the in-process local model by default (pure-Rust ggml,
|
|
/// no external processes); MAKEPAD_ROUTE_CLOUD=1 keeps Claude as the
|
|
/// dispatcher instead. Claude, when a key exists, otherwise serves as
|
|
/// the cloud_ask escalation tool.
|
|
fn init_agent(&mut self, cx: &mut Cx) {
|
|
// AI on by default again (perf campaign over); MAKEPAD_NO_AI=1
|
|
// keeps the GPU clear of the 9B/4B/whisper chain when profiling
|
|
// the map renderer.
|
|
if std::env::var_os("MAKEPAD_NO_AI").is_some() {
|
|
return;
|
|
}
|
|
let api_key = read_secret("ANTHROPIC_API_KEY");
|
|
let cloud_dispatch = std::env::var("MAKEPAD_ROUTE_CLOUD").is_ok() && api_key.is_some();
|
|
|
|
let mut agent: Box<dyn Agent> = if cloud_dispatch {
|
|
Self::make_claude(api_key.clone().unwrap())
|
|
} else {
|
|
let model_path = std::env::var("MAKEPAD_ROUTE_LOCAL_MODEL")
|
|
.unwrap_or_else(|_| local_agent::DEFAULT_LOCAL_MODEL.to_string());
|
|
let timing = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
|
|
self.local_timing = Some(timing.clone());
|
|
Box::new(local_agent::LocalAgent::new(model_path, timing))
|
|
};
|
|
let session = agent.create_session(
|
|
cx,
|
|
SessionConfig {
|
|
system_prompt: Some(SYSTEM_PROMPT.to_string()),
|
|
tools: broker::tool_definitions(),
|
|
..Default::default()
|
|
},
|
|
);
|
|
self.session = Some(session);
|
|
self.agent = Some(agent);
|
|
|
|
// Escalation valve: only when a key exists and Claude isn't
|
|
// already the dispatcher.
|
|
if !cloud_dispatch {
|
|
if let Some(api_key) = api_key {
|
|
let mut cloud = Self::make_claude(api_key);
|
|
let cloud_session = cloud.create_session(
|
|
cx,
|
|
SessionConfig {
|
|
system_prompt: Some(
|
|
"You answer knowledge questions for a navigation assistant \
|
|
(sights, reviews, rankings, world knowledge). Be concise: a short \
|
|
paragraph or compact list, no markdown."
|
|
.to_string(),
|
|
),
|
|
..Default::default()
|
|
},
|
|
);
|
|
self.cloud_session = Some(cloud_session);
|
|
self.cloud_agent = Some(cloud);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Aggregate boot status: the whole AI pipeline loads at startup
|
|
/// (9B dispatcher, 4B voice gate, whisper via the voice worker).
|
|
fn update_ai_status(&mut self, cx: &mut Cx) {
|
|
if self.local_timing.is_none() {
|
|
self.set_status(cx, "AI loading — cloud dispatcher · voice gate…");
|
|
return;
|
|
}
|
|
let text = if self.ai_llm_ready && self.ai_gate_ready {
|
|
"AI ready — dispatcher 9B ✓ · voice gate 4B ✓ · whisper warm".to_string()
|
|
} else {
|
|
format!(
|
|
"AI loading — dispatcher 9B {} · voice gate 4B {} · whisper…",
|
|
if self.ai_llm_ready { "✓" } else { "…" },
|
|
if self.ai_gate_ready { "✓" } else { "…" },
|
|
)
|
|
};
|
|
self.set_status(cx, &text);
|
|
}
|
|
|
|
fn set_status(&mut self, cx: &mut Cx, text: &str) {
|
|
self.ui.label(cx, ids!(status_label)).set_text(cx, text);
|
|
}
|
|
|
|
fn render_transcript(&mut self, cx: &mut Cx) {
|
|
let list = self.ui.portal_list(cx, ids!(list));
|
|
list.set_tail_range(true);
|
|
list.redraw(cx);
|
|
}
|
|
|
|
fn push_entry(&mut self, cx: &mut Cx, kind: EntryKind, text: &str) {
|
|
self.chat.entries.push(ChatEntry {
|
|
kind,
|
|
text: text.to_string(),
|
|
trip: None,
|
|
});
|
|
self.render_transcript(cx);
|
|
}
|
|
|
|
fn push_line(&mut self, cx: &mut Cx, line: &str) {
|
|
self.push_entry(cx, EntryKind::Info, line);
|
|
}
|
|
|
|
/// Push a snapshot of the current trip as a re-applyable chat row.
|
|
fn push_trip_entry(&mut self, cx: &mut Cx) {
|
|
if !self.trip.is_routed() {
|
|
return;
|
|
}
|
|
let first = self.trip.stops.first().map(|s| s.name.clone()).unwrap_or_default();
|
|
let last = self.trip.stops.last().map(|s| s.name.clone()).unwrap_or_default();
|
|
let vias = self.trip.stops.len().saturating_sub(2);
|
|
let text = format!(
|
|
"{first} → {last}{} — {:.1} km, {}",
|
|
if vias > 0 { format!(" (+{vias} stop{})", if vias > 1 { "s" } else { "" }) } else { String::new() },
|
|
self.trip.total_distance_m() / 1000.0,
|
|
trip::fmt_duration(self.trip.total_duration_s()),
|
|
);
|
|
let index = self.chat.trips.len();
|
|
self.chat.trips.push(self.trip.clone());
|
|
self.chat.entries.push(ChatEntry {
|
|
kind: EntryKind::Assistant,
|
|
text,
|
|
trip: Some(index),
|
|
});
|
|
self.drive_log.log_trip(&self.trip.digest());
|
|
self.render_transcript(cx);
|
|
}
|
|
|
|
fn commit_pending(&mut self, cx: &mut Cx) {
|
|
let text = std::mem::take(&mut self.chat.pending);
|
|
if text.trim().is_empty() {
|
|
return;
|
|
}
|
|
let text = text.trim().to_string();
|
|
self.push_entry(cx, EntryKind::Assistant, &text);
|
|
}
|
|
|
|
/// `/tool_name {json args}` — direct tool console, no LLM involved.
|
|
/// Works without an API key; also the manual test path for the broker.
|
|
fn run_local_command(&mut self, cx: &mut Cx, text: &str) {
|
|
let body = &text[1..];
|
|
let (name, args) = match body.split_once(char::is_whitespace) {
|
|
Some((name, rest)) => (name.trim(), rest.trim()),
|
|
None => (body.trim(), ""),
|
|
};
|
|
if name.is_empty() || name == "help" {
|
|
let names: Vec<String> = broker::tool_definitions()
|
|
.iter()
|
|
.map(|d| d.name.clone())
|
|
.collect();
|
|
self.push_line(cx, &format!("tools: {}", names.join(", ")));
|
|
return;
|
|
}
|
|
self.push_entry(cx, EntryKind::Tool, &format!("⚙ {name} {args}"));
|
|
if name == "images_search" {
|
|
let query = broker::parse_field(args, "query").unwrap_or_default();
|
|
match self.ddg.start(cx, &query, None) {
|
|
Ok(()) => self.push_entry(cx, EntryKind::Info, &format!("🔎 images: {query}")),
|
|
Err(error) => self.push_entry(cx, EntryKind::Tool, &format!("⚠ {error}")),
|
|
}
|
|
return;
|
|
}
|
|
match self.execute_tool(cx, name, args) {
|
|
Ok(text) => {
|
|
self.push_entry(cx, EntryKind::Assistant, &text);
|
|
if matches!(name, "route_plan" | "route_add_stop" | "route_remove_stop") {
|
|
self.push_trip_entry(cx);
|
|
}
|
|
}
|
|
Err(error) => self.push_entry(cx, EntryKind::Tool, &format!("⚠ {error}")),
|
|
}
|
|
}
|
|
|
|
fn send_user_prompt(&mut self, cx: &mut Cx, text: &str) {
|
|
if text.starts_with('/') {
|
|
self.run_local_command(cx, text);
|
|
return;
|
|
}
|
|
if self.agent.is_none() {
|
|
self.push_line(cx, "⚠ no agent available");
|
|
return;
|
|
}
|
|
// A new turn obsoletes whatever the voice was still saying — and a
|
|
// typed command while the dispatcher runs is always an override.
|
|
if self.busy {
|
|
self.interrupt_turn(cx);
|
|
}
|
|
if let Some(speech) = &mut self.speech {
|
|
speech.stop();
|
|
}
|
|
if self.busy {
|
|
self.set_status(cx, "still thinking — wait for the current answer");
|
|
return;
|
|
}
|
|
self.push_entry(cx, EntryKind::User, text);
|
|
let map = self.ui.map_view(cx, ids!(map));
|
|
let (lon, lat) = map.center().unwrap_or((4.8952, 52.3702));
|
|
let zoom = map.map_zoom().unwrap_or(13.0);
|
|
let gps = match &self.position {
|
|
Some(p) => format!("{:.5},{:.5} (±{:.0}m)", p.lon, p.lat, p.accuracy_m),
|
|
None => "no fix — 'here' falls back to map center".to_string(),
|
|
};
|
|
let prompt = format!(
|
|
"{text}\n\n[app state]\ngps: {gps}\nmap_center: {lon:.5},{lat:.5} zoom {zoom:.1}\nnav_data: {}\ntrip:\n{}",
|
|
if self.nav.is_some() { "ready" } else { "loading" },
|
|
self.trip.digest()
|
|
);
|
|
let (agent, session) = (self.agent.as_mut().unwrap(), self.session.unwrap());
|
|
let prompt_id = agent.send_prompt(cx, session, &prompt);
|
|
self.current_prompt = Some(prompt_id);
|
|
self.tool_rounds = 0;
|
|
self.last_tool_call = None;
|
|
self.busy = true;
|
|
self.set_status(cx, "thinking…");
|
|
}
|
|
|
|
/// User override: cancel the in-flight dispatcher turn NOW. The worker
|
|
/// stops per-token, closes the assistant turn and drops its tool calls.
|
|
fn interrupt_turn(&mut self, cx: &mut Cx) {
|
|
if !self.busy {
|
|
return;
|
|
}
|
|
if let (Some(agent), Some(prompt_id)) = (self.agent.as_mut(), self.current_prompt) {
|
|
agent.cancel_prompt(cx, prompt_id);
|
|
}
|
|
self.busy = false;
|
|
self.voice_queue.clear();
|
|
if let Some(speech) = &mut self.speech {
|
|
speech.stop();
|
|
}
|
|
self.push_entry(cx, EntryKind::Info, "⏹ interrupted");
|
|
self.set_status(cx, "ready");
|
|
}
|
|
|
|
/// Does this utterance override the current activity? Only the LEADING
|
|
/// words count — "add a charging stop" must not match on "stop".
|
|
fn is_override_command(text: &str) -> bool {
|
|
let lower = text.to_lowercase();
|
|
lower
|
|
.split(|c: char| !c.is_alphanumeric())
|
|
.filter(|w| !w.is_empty())
|
|
.take(2)
|
|
.any(|w| {
|
|
matches!(
|
|
w,
|
|
"stop" | "cancel" | "nevermind" | "never" | "forget" | "wait" | "actually"
|
|
)
|
|
})
|
|
}
|
|
|
|
/// "stop" / "nevermind" with no follow-up command: just halt.
|
|
fn is_pure_stop(text: &str) -> bool {
|
|
let words: Vec<&str> = text
|
|
.split(|c: char| !c.is_alphanumeric())
|
|
.filter(|w| !w.is_empty())
|
|
.collect();
|
|
words.len() <= 3
|
|
&& words.iter().all(|w| {
|
|
matches!(
|
|
w.to_lowercase().as_str(),
|
|
"ok" | "okay" | "no" | "stop" | "cancel" | "nevermind" | "never" | "mind"
|
|
| "it" | "that" | "forget" | "computer"
|
|
)
|
|
})
|
|
}
|
|
|
|
fn on_agent_event(&mut self, cx: &mut Cx, event: AgentEvent) {
|
|
match event {
|
|
AgentEvent::SessionReady { .. } => {
|
|
self.ai_llm_ready = true;
|
|
// Eager but SERIALIZED: the gate's 4B loads after the 9B is
|
|
// resident so startup peaks don't stack (iPad jetsam kills
|
|
// on peak footprint, not steady state).
|
|
if self.voice_gate.is_none() {
|
|
self.voice_gate = Some(VoiceGate::new());
|
|
}
|
|
// Chain whisper after the LLMs (eager but serialized).
|
|
self.ui.voice_wave(cx, ids!(mic_wave)).prewarm(cx);
|
|
self.update_ai_status(cx);
|
|
}
|
|
AgentEvent::SessionError { error, .. } => {
|
|
self.busy = false;
|
|
self.push_line(cx, &format!("⚠ session error: {error}"));
|
|
}
|
|
AgentEvent::TextDelta { text, .. } => {
|
|
self.chat.pending.push_str(&text);
|
|
if let Some(speech) = &mut self.speech {
|
|
speech.feed(&text);
|
|
}
|
|
self.render_transcript(cx);
|
|
}
|
|
AgentEvent::ToolRequest {
|
|
tool_use_id,
|
|
tool_name,
|
|
tool_input,
|
|
..
|
|
} => {
|
|
self.commit_pending(cx);
|
|
self.run_tool(cx, &tool_use_id, &tool_name, &tool_input);
|
|
}
|
|
AgentEvent::TurnComplete { .. } => {
|
|
self.commit_pending(cx);
|
|
if let Some(speech) = &mut self.speech {
|
|
speech.flush();
|
|
}
|
|
self.busy = false;
|
|
self.current_prompt = None;
|
|
let timing = self
|
|
.local_timing
|
|
.as_ref()
|
|
.and_then(|t| t.lock().ok().map(|s| s.clone()))
|
|
.filter(|s| !s.is_empty())
|
|
.unwrap_or_else(|| "ready".to_string());
|
|
// The session is append-only: once the context is nearly
|
|
// full it cannot recover — restart with a fresh session
|
|
// (mmap makes the reload cheap; chat history stays in the
|
|
// transcript, the model just loses conversational memory).
|
|
if let Some((used, max)) = parse_ctx_usage(&timing) {
|
|
if used * 10 > max * 9 {
|
|
self.push_entry(
|
|
cx,
|
|
EntryKind::Info,
|
|
&format!("⚠ context {used}/{max} — restarting local session"),
|
|
);
|
|
self.agent = None;
|
|
self.session = None;
|
|
self.init_agent(cx);
|
|
}
|
|
}
|
|
self.set_status(cx, &timing);
|
|
}
|
|
AgentEvent::PromptError { error, .. } => {
|
|
self.commit_pending(cx);
|
|
self.busy = false;
|
|
self.current_prompt = None;
|
|
self.push_line(cx, &format!("⚠ {error}"));
|
|
self.set_status(cx, "error — try again");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Execute one broker tool and apply any layer/nav changes it made.
|
|
fn execute_tool(&mut self, cx: &mut Cx, name: &str, input: &str) -> Result<String, String> {
|
|
let map = self.ui.map_view(cx, ids!(map));
|
|
let mut nav_action = None;
|
|
let result = {
|
|
let mut tool_ctx = ToolCtx {
|
|
cx,
|
|
map: &map,
|
|
trip: &mut self.trip,
|
|
nav: self.nav.as_mut(),
|
|
radar: self.radar.as_ref(),
|
|
markers: &mut self.markers,
|
|
position: self.position.as_ref().map(|p| (p.lon, p.lat)),
|
|
layers: &mut self.layers,
|
|
leg_routes: &mut self.leg_routes,
|
|
nav_action: &mut nav_action,
|
|
};
|
|
broker::execute(&mut tool_ctx, name, input)
|
|
};
|
|
if self.layers.dirty {
|
|
self.layers.dirty = false;
|
|
self.apply_layers(cx);
|
|
}
|
|
match nav_action {
|
|
Some(NavAction::Start { simulate }) => self.start_nav(cx, simulate),
|
|
Some(NavAction::Stop) => self.stop_nav(cx),
|
|
None => {}
|
|
}
|
|
result
|
|
}
|
|
|
|
// --- Turn-by-turn navigation -------------------------------------------
|
|
|
|
fn start_nav(&mut self, cx: &mut Cx, simulate: bool) {
|
|
let Some(mut nav) = ActiveNav::new(self.leg_routes.clone(), simulate) else {
|
|
self.push_line(cx, "⚠ nav: no routed legs");
|
|
return;
|
|
};
|
|
let map = self.ui.map_view(cx, ids!(map));
|
|
if let Some(start) = nav.start_point() {
|
|
map.fly_to(cx, start.lon, start.lat, 17.0);
|
|
}
|
|
// Driving view: tilted follow camera (also arms tilt-shift).
|
|
map.set_tilt(cx, 42.0);
|
|
self.ui.view(cx, ids!(banner)).set_visible(cx, true);
|
|
self.ui
|
|
.label(cx, ids!(banner_text))
|
|
.set_text(cx, "Starting navigation…");
|
|
self.ui.label(cx, ids!(banner_dist)).set_text(cx, "");
|
|
if simulate {
|
|
nav.sim_last_tick = Some(std::time::Instant::now());
|
|
self.nav_frame = cx.new_next_frame();
|
|
}
|
|
self.drive_log.log_trip(&format!(
|
|
"nav_start ({})\n{}",
|
|
if simulate { "sim" } else { "gps" },
|
|
self.trip.digest()
|
|
));
|
|
self.active_nav = Some(nav);
|
|
self.push_line(
|
|
cx,
|
|
if simulate { "▶ navigating (simulated drive)" } else { "▶ navigating (live GPS)" },
|
|
);
|
|
}
|
|
|
|
fn stop_nav(&mut self, cx: &mut Cx) {
|
|
if self.active_nav.take().is_none() {
|
|
return;
|
|
}
|
|
let map = self.ui.map_view(cx, ids!(map));
|
|
map.set_rotation(cx, 0.0);
|
|
map.set_tilt(cx, 0.0);
|
|
self.ui.view(cx, ids!(banner)).set_visible(cx, false);
|
|
self.push_line(cx, "■ navigation ended");
|
|
}
|
|
|
|
fn apply_nav_tick(&mut self, cx: &mut Cx, tick: NavTick) {
|
|
let map = self.ui.map_view(cx, ids!(map));
|
|
map.set_puck(
|
|
cx,
|
|
Some(MapPuck::new(
|
|
tick.position.lon,
|
|
tick.position.lat,
|
|
tick.heading,
|
|
12.0,
|
|
)),
|
|
);
|
|
map.set_center(cx, tick.position.lon, tick.position.lat);
|
|
map.set_rotation(cx, tick.rotation);
|
|
map.set_route_progress(cx, tick.progress_index);
|
|
if !tick.banner.is_empty() {
|
|
self.ui.label(cx, ids!(banner_text)).set_text(cx, &tick.banner);
|
|
self.ui
|
|
.label(cx, ids!(banner_dist))
|
|
.set_text(cx, &tick.banner_dist);
|
|
// Announce each maneuver once, when it becomes the current banner.
|
|
if tick.banner != self.last_spoken_banner {
|
|
self.last_spoken_banner = tick.banner.clone();
|
|
if let Some(speech) = &self.speech {
|
|
speech.enqueue(&tick.banner);
|
|
}
|
|
}
|
|
}
|
|
|
|
if tick.arrived {
|
|
let dest = self
|
|
.trip
|
|
.stops
|
|
.last()
|
|
.map(|s| s.name.clone())
|
|
.unwrap_or_default();
|
|
self.push_line(cx, &format!("🏁 arrived at {dest}"));
|
|
if let Some(speech) = &self.speech {
|
|
speech.enqueue(&format!("You have arrived at {dest}."));
|
|
}
|
|
self.active_nav = None;
|
|
let map = self.ui.map_view(cx, ids!(map));
|
|
map.set_rotation(cx, 0.0);
|
|
return;
|
|
}
|
|
if tick.finished_leg {
|
|
if let Some(nav) = &mut self.active_nav {
|
|
let reached = self
|
|
.trip
|
|
.stops
|
|
.get(nav.leg_index + 1)
|
|
.map(|s| s.name.clone())
|
|
.unwrap_or_default();
|
|
nav.advance_leg();
|
|
self.push_line(cx, &format!("● reached {reached} — continuing"));
|
|
}
|
|
}
|
|
if tick.needs_reroute {
|
|
self.reroute_nav(cx);
|
|
}
|
|
}
|
|
|
|
/// Off-route with real GPS: recompute the current leg from here.
|
|
fn reroute_nav(&mut self, cx: &mut Cx) {
|
|
let (Some(nav_data), Some(nav)) = (self.nav.as_mut(), self.active_nav.as_mut()) else {
|
|
return;
|
|
};
|
|
if nav.simulate {
|
|
return;
|
|
}
|
|
let (Some(pos), Some(next_stop)) = (nav.position, self.trip.stops.get(nav.leg_index + 1))
|
|
else {
|
|
return;
|
|
};
|
|
let to = makepad_map_nav::geo::LonLat {
|
|
lon: next_stop.lon,
|
|
lat: next_stop.lat,
|
|
};
|
|
let mode = match self.trip.mode {
|
|
trip::TripMode::Car => makepad_map_nav::graph::TravelMode::Car,
|
|
trip::TripMode::Bike => makepad_map_nav::graph::TravelMode::Bike,
|
|
trip::TripMode::Foot => makepad_map_nav::graph::TravelMode::Foot,
|
|
};
|
|
if let Some(route) = nav_data.route_pair(pos, to, mode) {
|
|
nav.routes[nav.leg_index] = route.clone();
|
|
nav.session = makepad_map_nav::nav::NavSession::new(route);
|
|
// Redraw the route line: completed legs + fresh current + later.
|
|
let points: Vec<(f64, f64)> = nav
|
|
.routes
|
|
.iter()
|
|
.flat_map(|r| r.points.iter().map(|p| (p.lon, p.lat)))
|
|
.collect();
|
|
self.ui.map_view(cx, ids!(map)).set_route(cx, &points);
|
|
self.push_line(cx, "↻ rerouting");
|
|
}
|
|
}
|
|
|
|
fn tick_nav_sim(&mut self, cx: &mut Cx) {
|
|
let Some(nav) = &mut self.active_nav else {
|
|
return;
|
|
};
|
|
let Some(tick) = nav.tick_sim() else {
|
|
return;
|
|
};
|
|
self.apply_nav_tick(cx, tick);
|
|
if self.active_nav.is_some() {
|
|
self.nav_frame = cx.new_next_frame();
|
|
}
|
|
}
|
|
|
|
/// Events from the Claude escalation agent: stream into the pending
|
|
/// cloud_ask, then hand the answer back to the dispatcher as the tool
|
|
/// result (visible in the transcript per route.md — cloud is explicit).
|
|
fn on_cloud_event(&mut self, cx: &mut Cx, event: AgentEvent) {
|
|
match event {
|
|
AgentEvent::TextDelta { text, .. } => {
|
|
if let Some((_, accum)) = &mut self.pending_cloud {
|
|
accum.push_str(&text);
|
|
}
|
|
}
|
|
AgentEvent::TurnComplete { .. } => {
|
|
if let Some((tool_use_id, answer)) = self.pending_cloud.take() {
|
|
let preview: String = answer.chars().take(240).collect();
|
|
self.push_line(cx, &format!("☁ cloud: {}", preview.trim()));
|
|
if let (Some(agent), Some(session)) = (self.agent.as_mut(), self.session) {
|
|
agent.send_tool_result(cx, session, &tool_use_id, answer.trim(), false);
|
|
}
|
|
}
|
|
}
|
|
AgentEvent::PromptError { error, .. } | AgentEvent::SessionError { error, .. } => {
|
|
if let Some((tool_use_id, _)) = self.pending_cloud.take() {
|
|
self.push_line(cx, &format!("☁ cloud error: {error}"));
|
|
if let (Some(agent), Some(session)) = (self.agent.as_mut(), self.session) {
|
|
agent.send_tool_result(
|
|
cx,
|
|
session,
|
|
&tool_use_id,
|
|
&format!("cloud unavailable: {error}"),
|
|
true,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Mic toggle: starts/stops the continuous voice loop (VAD → STT →
|
|
/// attention gate → dispatcher). Pipeline wiring lands with voice.rs.
|
|
fn toggle_mic(&mut self, cx: &mut Cx) {
|
|
self.mic_on = !self.mic_on;
|
|
let mut button = self.ui.button(cx, ids!(mic_button));
|
|
if self.mic_on {
|
|
button.set_text(cx, "🔴");
|
|
script_apply_eval!(cx, button, {
|
|
draw_text +: {
|
|
color: #(vec4(0.86, 0.20, 0.20, 1.0))
|
|
}
|
|
});
|
|
self.push_line(
|
|
cx,
|
|
if self.whisper_warmed {
|
|
"🎤 listening — say 'computer, …' or address me directly"
|
|
} else {
|
|
"🎤 arming — loading whisper (first time takes a few seconds), then say 'computer, …'"
|
|
},
|
|
);
|
|
self.whisper_warmed = true;
|
|
} else {
|
|
button.set_text(cx, "🎤");
|
|
self.push_line(cx, "🎤 mic off");
|
|
}
|
|
self.sync_voice_state(cx);
|
|
}
|
|
|
|
/// Start/stop the VoiceWave capture and lazily spawn the gate worker.
|
|
fn sync_voice_state(&mut self, cx: &mut Cx) {
|
|
if self.mic_on && self.voice_gate.is_none() {
|
|
self.voice_gate = Some(VoiceGate::new());
|
|
}
|
|
let wave = self.ui.voice_wave(cx, ids!(mic_wave));
|
|
wave.set_enabled(cx, self.mic_on);
|
|
log!(
|
|
"voice: mic toggle requested={} widget_enabled={}",
|
|
self.mic_on,
|
|
wave.is_enabled()
|
|
);
|
|
}
|
|
|
|
/// Last N user/assistant lines for the gate's in-context judgement.
|
|
fn recent_dialog(&self) -> Vec<String> {
|
|
self.chat
|
|
.entries
|
|
.iter()
|
|
.filter_map(|e| match e.kind {
|
|
EntryKind::User => Some(format!("user: {}", e.text)),
|
|
EntryKind::Assistant if e.trip.is_none() => {
|
|
Some(format!("assistant: {}", e.text))
|
|
}
|
|
_ => None,
|
|
})
|
|
.rev()
|
|
.take(voice::RECENT_DIALOG_LINES)
|
|
.collect::<Vec<_>>()
|
|
.into_iter()
|
|
.rev()
|
|
.collect()
|
|
}
|
|
|
|
fn on_gate_result(&mut self, cx: &mut Cx, result: GateResult) {
|
|
match result {
|
|
GateResult::Ready { secs } => {
|
|
self.push_entry(
|
|
cx,
|
|
EntryKind::Info,
|
|
&format!("voice gate ready (4B loaded + warmed in {secs:.1}s)"),
|
|
);
|
|
self.ai_gate_ready = true;
|
|
self.update_ai_status(cx);
|
|
return;
|
|
}
|
|
GateResult::Send { raw, instruction } => {
|
|
let _ = &raw;
|
|
self.push_entry(cx, EntryKind::Info, "→ directed");
|
|
if self.busy && Self::is_override_command(&instruction) {
|
|
// "ok nevermind, stop, go do X now" — cancel the turn;
|
|
// send the rest unless it was a bare stop.
|
|
self.interrupt_turn(cx);
|
|
if !Self::is_pure_stop(&instruction) {
|
|
self.send_user_prompt(cx, &instruction);
|
|
}
|
|
} else if self.busy {
|
|
self.push_entry(cx, EntryKind::Info, "(queued until current turn ends)");
|
|
self.voice_queue.push(instruction);
|
|
} else if Self::is_pure_stop(&instruction) {
|
|
// Nothing running — just quiet the voice.
|
|
if let Some(speech) = &mut self.speech {
|
|
speech.stop();
|
|
}
|
|
} else {
|
|
self.send_user_prompt(cx, &instruction);
|
|
}
|
|
}
|
|
GateResult::Skip { raw, reason } => {
|
|
let _ = &raw;
|
|
self.push_entry(cx, EntryKind::Info, &format!("— skipped ({})", reason.trim()));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn on_ddg_event(&mut self, cx: &mut Cx, event: DdgEvent) {
|
|
match event {
|
|
DdgEvent::Thumb(slot, data) => {
|
|
let image_ids: [&[LiveId]; ddg::MAX_CARDS] =
|
|
[ids!(img_0), ids!(img_1), ids!(img_2), ids!(img_3)];
|
|
if let Some(id) = image_ids.get(slot) {
|
|
let image = self.ui.image(cx, id);
|
|
if image.load_image_from_data(cx, &data).is_ok() {
|
|
self.ui.view(cx, ids!(images_row)).set_visible(cx, true);
|
|
self.ui.view(cx, ids!(images_row)).redraw(cx);
|
|
}
|
|
}
|
|
}
|
|
DdgEvent::Done(tool_use_id, digest, is_error) => {
|
|
self.push_entry(
|
|
cx,
|
|
if is_error { EntryKind::Tool } else { EntryKind::Assistant },
|
|
&if is_error { format!("⚠ {digest}") } else { digest.clone() },
|
|
);
|
|
if let Some(tool_use_id) = tool_use_id {
|
|
if let (Some(agent), Some(session)) = (self.agent.as_mut(), self.session) {
|
|
agent.send_tool_result(cx, session, &tool_use_id, &digest, is_error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_tool(&mut self, cx: &mut Cx, tool_use_id: &str, name: &str, input: &str) {
|
|
let compact: String = input.chars().take(120).collect();
|
|
self.push_entry(cx, EntryKind::Tool, &format!("⚙ {name} {compact}"));
|
|
// Loop breakers: greedy decoding can wedge the dispatcher into
|
|
// re-issuing the same call forever (seen: identical geo_search
|
|
// spam). Repeats and over-budget turns get a corrective tool
|
|
// result instead of execution.
|
|
self.tool_rounds += 1;
|
|
let this_call = (name.to_string(), input.to_string());
|
|
let repeated = self.last_tool_call.as_ref() == Some(&this_call);
|
|
self.last_tool_call = Some(this_call);
|
|
if repeated || self.tool_rounds > 10 {
|
|
let nudge = if repeated {
|
|
"Error: identical tool call repeated — you already have this result. \
|
|
Do NOT call this tool again; answer the user now with what you know."
|
|
} else {
|
|
"Error: tool budget for this request is exhausted. \
|
|
Stop calling tools and answer the user now with what you have."
|
|
};
|
|
self.push_entry(cx, EntryKind::Info, "⛔ tool loop broken");
|
|
if let (Some(agent), Some(session)) = (self.agent.as_mut(), self.session) {
|
|
agent.send_tool_result(cx, session, tool_use_id, nudge, true);
|
|
}
|
|
return;
|
|
}
|
|
// images_search runs async over cx.http_request; the tool result is
|
|
// sent when the thumbnails land.
|
|
if name == "images_search" {
|
|
let query = broker::parse_field(input, "query").unwrap_or_default();
|
|
match self.ddg.start(cx, &query, Some(tool_use_id.to_string())) {
|
|
Ok(()) => {
|
|
self.push_entry(cx, EntryKind::Info, &format!("🔎 images: {query}"));
|
|
}
|
|
Err(error) => {
|
|
if let (Some(agent), Some(session)) = (self.agent.as_mut(), self.session) {
|
|
agent.send_tool_result(cx, session, tool_use_id, &error, true);
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
// cloud_ask escalates asynchronously through the Claude side-agent;
|
|
// the tool result is sent when that turn completes.
|
|
if name == "cloud_ask" && self.cloud_agent.is_some() && self.pending_cloud.is_none() {
|
|
let question = broker::parse_question(input);
|
|
self.push_line(cx, "☁ asking cloud…");
|
|
let (cloud, cloud_session) = (
|
|
self.cloud_agent.as_mut().unwrap(),
|
|
self.cloud_session.unwrap(),
|
|
);
|
|
cloud.send_prompt(cx, cloud_session, &question);
|
|
self.pending_cloud = Some((tool_use_id.to_string(), String::new()));
|
|
return;
|
|
}
|
|
let result = self.execute_tool(cx, name, input);
|
|
let (text, is_error) = match result {
|
|
Ok(t) => (t, false),
|
|
Err(e) => (e, true),
|
|
};
|
|
if is_error {
|
|
self.push_entry(cx, EntryKind::Tool, &format!("⚠ {text}"));
|
|
} else if matches!(name, "route_plan" | "route_add_stop" | "route_remove_stop") {
|
|
self.push_trip_entry(cx);
|
|
}
|
|
if let (Some(agent), Some(session)) = (self.agent.as_mut(), self.session) {
|
|
agent.send_tool_result(cx, session, tool_use_id, &text, is_error);
|
|
}
|
|
}
|
|
|
|
/// Redraw route/markers/camera from the current TripModel (used by the
|
|
/// `>` re-apply button; tools do this themselves).
|
|
fn resync_trip_display(&mut self, cx: &mut Cx) {
|
|
let map = self.ui.map_view(cx, ids!(map));
|
|
let mut nav_action = None;
|
|
let mut tool_ctx = ToolCtx {
|
|
cx,
|
|
map: &map,
|
|
trip: &mut self.trip,
|
|
nav: self.nav.as_mut(),
|
|
radar: self.radar.as_ref(),
|
|
markers: &mut self.markers,
|
|
position: self.position.as_ref().map(|p| (p.lon, p.lat)),
|
|
layers: &mut self.layers,
|
|
leg_routes: &mut self.leg_routes,
|
|
nav_action: &mut nav_action,
|
|
};
|
|
tools::map::sync_trip_display(&mut tool_ctx, true);
|
|
}
|
|
|
|
/// Mirror LayerState into the popover checkboxes (agent tools and the
|
|
/// UI share one state).
|
|
fn sync_layer_checkboxes(&mut self, cx: &mut Cx) {
|
|
let overlay_ids = [
|
|
ids!(layer_chargers),
|
|
ids!(layer_transit),
|
|
ids!(layer_nature),
|
|
ids!(layer_districts),
|
|
ids!(layer_buildings),
|
|
ids!(layer_demographics),
|
|
];
|
|
for (i, id) in overlay_ids.iter().enumerate() {
|
|
self.ui
|
|
.check_box(cx, *id)
|
|
.set_active(cx, self.layers.overlay_on[i], Animate::No);
|
|
}
|
|
self.ui
|
|
.check_box(cx, ids!(layer_rain))
|
|
.set_active(cx, self.layers.rain, Animate::No);
|
|
self.ui
|
|
.check_box(cx, ids!(layer_wind))
|
|
.set_active(cx, self.layers.wind, Animate::No);
|
|
self.ui
|
|
.check_box(cx, ids!(layer_terrain))
|
|
.set_active(cx, self.layers.terrain, Animate::No);
|
|
self.ui
|
|
.check_box(cx, ids!(tilt_check))
|
|
.set_active(cx, self.layers.tilt_shift, Animate::No);
|
|
let warp_on = self.ui.map_view(cx, ids!(map)).space_warp();
|
|
self.ui
|
|
.check_box(cx, ids!(warp_check))
|
|
.set_active(cx, warp_on, Animate::No);
|
|
self.ui
|
|
.check_box(cx, ids!(theme_night))
|
|
.set_active(cx, self.layers.theme == 1, Animate::No);
|
|
self.ui
|
|
.check_box(cx, ids!(theme_circuit))
|
|
.set_active(cx, self.layers.theme == 2, Animate::No);
|
|
}
|
|
|
|
/// Restyle the app chrome to match the map theme (light vs night/circuit).
|
|
fn apply_ui_theme(&mut self, cx: &mut Cx) {
|
|
let dark = self.layers.theme != 0;
|
|
self.chat.dark = dark;
|
|
let panel_bg = if dark { vec4(0.075, 0.085, 0.105, 0.95) } else { vec4(1.0, 1.0, 1.0, 0.95) };
|
|
let panel_border = if dark { vec4(1.0, 1.0, 1.0, 0.10) } else { vec4(0.0, 0.0, 0.0, 0.13) };
|
|
let text_main = if dark { vec4(0.87, 0.90, 0.93, 1.0) } else { vec4(0.13, 0.19, 0.22, 1.0) };
|
|
let text_dim = if dark { vec4(0.52, 0.56, 0.61, 1.0) } else { vec4(0.13, 0.19, 0.24, 1.0) };
|
|
|
|
// Glass panels: theme via tint (they sample the gauss backdrop).
|
|
let (tint, tint_alpha) = if dark {
|
|
(vec4(0.04, 0.06, 0.09, 1.0), 0.42f32)
|
|
} else {
|
|
(vec4(0.97, 0.98, 1.0, 1.0), 0.30f32)
|
|
};
|
|
let _ = (panel_bg, panel_border);
|
|
for id in [ids!(assistant_panel), ids!(layers_panel)] {
|
|
let mut panel = self.ui.widget(cx, id);
|
|
script_apply_eval!(cx, panel, {
|
|
draw_bg +: {
|
|
tint_color: #(tint)
|
|
tint_alpha: #(tint_alpha)
|
|
}
|
|
});
|
|
}
|
|
for id in [ids!(header_label), ids!(intro_label), ids!(status_label)] {
|
|
let mut label = self.ui.label(cx, id);
|
|
let color = if id == ids!(header_label) { text_main } else { text_dim };
|
|
script_apply_eval!(cx, label, {
|
|
draw_text +: {
|
|
color: #(color)
|
|
}
|
|
});
|
|
}
|
|
let check_ids = [
|
|
ids!(layer_rain),
|
|
ids!(layer_wind),
|
|
ids!(layer_terrain),
|
|
ids!(tilt_check),
|
|
ids!(layer_chargers),
|
|
ids!(layer_transit),
|
|
ids!(layer_nature),
|
|
ids!(layer_districts),
|
|
ids!(layer_buildings),
|
|
ids!(layer_demographics),
|
|
ids!(theme_night),
|
|
ids!(theme_circuit),
|
|
];
|
|
// Every interaction state: hover/focus/down otherwise keep their
|
|
// DSL (light-panel) colors and go unreadable on dark.
|
|
let text_hot = if dark { vec4(1.0, 1.0, 1.0, 1.0) } else { vec4(0.0, 0.0, 0.0, 1.0) };
|
|
for id in check_ids {
|
|
let mut check = self.ui.check_box(cx, id);
|
|
script_apply_eval!(cx, check, {
|
|
draw_text +: {
|
|
color: #(text_main)
|
|
color_active: #(text_main)
|
|
color_hover: #(text_hot)
|
|
color_down: #(text_hot)
|
|
color_focus: #(text_main)
|
|
}
|
|
});
|
|
}
|
|
let input_text = if dark { vec4(0.88, 0.91, 0.94, 1.0) } else { vec4(0.09, 0.13, 0.16, 1.0) };
|
|
let placeholder = if dark { vec4(0.50, 0.54, 0.58, 1.0) } else { vec4(0.42, 0.47, 0.52, 1.0) };
|
|
let mut input = self.ui.text_input(cx, ids!(prompt_input));
|
|
script_apply_eval!(cx, input, {
|
|
draw_text +: {
|
|
color: #(input_text)
|
|
color_hover: #(input_text)
|
|
color_focus: #(input_text)
|
|
color_empty: #(placeholder)
|
|
color_empty_hover: #(placeholder)
|
|
color_empty_focus: #(placeholder)
|
|
}
|
|
});
|
|
self.ui.redraw(cx);
|
|
}
|
|
|
|
/// Push the layer/theme state to the MapView; lazily starts workers.
|
|
fn apply_layers(&mut self, cx: &mut Cx) {
|
|
self.sync_layer_checkboxes(cx);
|
|
self.apply_ui_theme(cx);
|
|
let map = self.ui.map_view(cx, ids!(map));
|
|
map.set_overlay_paths(cx, &self.layers.overlay_paths());
|
|
map.set_theme(cx, self.layers.theme);
|
|
|
|
let bbox = nav_data::radar_display_bbox();
|
|
if self.layers.rain {
|
|
if let Some(radar) = &self.radar {
|
|
if !radar.display_frames.is_empty() {
|
|
map.set_rain_frames(
|
|
cx,
|
|
radar.display_frames.clone(),
|
|
radar.display_width,
|
|
radar.display_height,
|
|
bbox,
|
|
);
|
|
map.set_rain_now_hires(cx, radar.now_hires.clone());
|
|
}
|
|
}
|
|
} else {
|
|
map.set_rain_frames(cx, Vec::new(), 0, 0, bbox);
|
|
}
|
|
|
|
if self.layers.wind {
|
|
if !self.layers.wind_worker_started {
|
|
self.layers.wind_worker_started = true;
|
|
layers::start_wind_worker(self.wind_rx.sender());
|
|
}
|
|
if let Some(update) = &self.layers.wind_cache {
|
|
map.set_wind_field(
|
|
cx,
|
|
update.nx,
|
|
update.ny,
|
|
update.u.clone(),
|
|
update.v.clone(),
|
|
update.bbox,
|
|
);
|
|
}
|
|
} else {
|
|
map.set_wind_field(cx, 0, 0, Vec::new(), Vec::new(), (0.0, 0.0, 0.0, 0.0));
|
|
}
|
|
|
|
if self.layers.terrain {
|
|
if self.layers.terrain_tx.is_none() {
|
|
self.layers.terrain_tx = Some(layers::start_terrain_worker(self.terrain_rx.sender()));
|
|
}
|
|
self.layers.last_terrain_key = None;
|
|
layers::request_terrain(cx, &map, &mut self.layers);
|
|
} else if self.layers.terrain_tx.is_some() {
|
|
map.set_terrain_overlay(cx, TerrainOverlayData::default());
|
|
}
|
|
}
|
|
}
|
|
|
|
impl MatchEvent for App {
|
|
// The test-map download rides the platform's HTTP stack, which reports
|
|
// progress as the body streams. (DDG image search reads the same
|
|
// responses through its own state machine and ignores ids that are not
|
|
// its own, so both can listen.)
|
|
fn handle_http_progress(&mut self, cx: &mut Cx, request_id: LiveId, progress: &HttpProgress) {
|
|
if self.testmap.handle_http_progress(request_id, progress) {
|
|
self.refresh_testmap_ui(cx);
|
|
}
|
|
}
|
|
|
|
fn handle_http_response(&mut self, cx: &mut Cx, request_id: LiveId, response: &HttpResponse) {
|
|
if self.testmap.handle_http_response(request_id, response) {
|
|
self.refresh_testmap_ui(cx);
|
|
}
|
|
}
|
|
|
|
fn handle_http_request_error(&mut self, cx: &mut Cx, request_id: LiveId, err: &HttpError) {
|
|
if self.testmap.handle_http_error(request_id, &err.message) {
|
|
self.refresh_testmap_ui(cx);
|
|
}
|
|
}
|
|
|
|
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
|
|
if self.testmap.is_active() {
|
|
if self.testmap.can_start() && self.ui.button(cx, ids!(testmap_start)).clicked(actions)
|
|
{
|
|
self.testmap.start(cx);
|
|
self.refresh_testmap_ui(cx);
|
|
}
|
|
if self.ui.button(cx, ids!(testmap_dismiss)).clicked(actions) {
|
|
self.testmap.dismiss();
|
|
self.refresh_testmap_ui(cx);
|
|
}
|
|
}
|
|
if let Some((text, _)) = self.ui.text_input(cx, ids!(prompt_input)).returned(actions) {
|
|
let text = text.trim().to_string();
|
|
if !text.is_empty() {
|
|
self.ui.text_input(cx, ids!(prompt_input)).set_text(cx, "");
|
|
self.send_user_prompt(cx, &text);
|
|
}
|
|
}
|
|
if self.ui.button(cx, ids!(mic_button)).clicked(actions) {
|
|
self.toggle_mic(cx);
|
|
}
|
|
if self.ui.button(cx, ids!(speaker_button)).clicked(actions) {
|
|
if let Some(speech) = &mut self.speech {
|
|
let muted = !speech.is_muted();
|
|
speech.set_muted(muted);
|
|
self.ui
|
|
.button(cx, ids!(speaker_button))
|
|
.set_text(cx, if muted { "🔇" } else { "🔊" });
|
|
self.push_line(cx, if muted { "🔇 voice off" } else { "🔊 voice on" });
|
|
}
|
|
}
|
|
// Endpointed transcripts from the mic → attention gate.
|
|
let mic_uid = self.ui.widget(cx, ids!(mic_wave)).widget_uid();
|
|
for action in actions {
|
|
let Some(action) = action.as_widget_action() else {
|
|
continue;
|
|
};
|
|
if action.widget_uid != mic_uid {
|
|
continue;
|
|
}
|
|
match action.cast::<VoiceWaveAction>() {
|
|
VoiceWaveAction::VoiceActivity(active) => {
|
|
// Barge-in: the user talking mutes the assistant NOW —
|
|
// they are not waiting for it to finish. (Assistant echo
|
|
// can also trigger this; acceptable — the gate still
|
|
// decides what the words meant.)
|
|
if active {
|
|
if let Some(speech) = &mut self.speech {
|
|
if speech.is_speaking() {
|
|
speech.stop();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
VoiceWaveAction::InjectText(text) => {
|
|
let text = text.trim().to_string();
|
|
if !text.is_empty() {
|
|
// Raw transcript, immediately visible; the gate's
|
|
// verdict follows as its own line. Assistant echo is
|
|
// NOT filtered here — the gate skips non-directed
|
|
// lines, and blocking the mic while speaking made
|
|
// interruptions impossible.
|
|
self.push_entry(cx, EntryKind::Info, &format!("🎤 “{text}”"));
|
|
if let Some(gate) = &self.voice_gate {
|
|
gate.submit(text, self.recent_dialog());
|
|
} else {
|
|
self.push_entry(cx, EntryKind::Info, "(gate still loading)");
|
|
}
|
|
}
|
|
}
|
|
VoiceWaveAction::RecordVoice(on) => {
|
|
if on != self.mic_on {
|
|
// widget-side toggle (click on the wave / F1)
|
|
self.mic_on = on;
|
|
self.sync_voice_state(cx);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
if self.ui.button(cx, ids!(layers_button)).clicked(actions) {
|
|
self.layers_panel_open = !self.layers_panel_open;
|
|
self.ui
|
|
.widget(cx, ids!(layers_panel))
|
|
.set_visible(cx, self.layers_panel_open);
|
|
}
|
|
if self.ui.button(cx, ids!(assistant_button)).clicked(actions) {
|
|
self.assistant_panel_open = !self.assistant_panel_open;
|
|
self.ui
|
|
.widget(cx, ids!(assistant_panel))
|
|
.set_visible(cx, self.assistant_panel_open);
|
|
}
|
|
let layer_checks: [(&[LiveId], &str); 10] = [
|
|
(ids!(layer_rain), "rain"),
|
|
(ids!(layer_wind), "wind"),
|
|
(ids!(layer_terrain), "terrain"),
|
|
(ids!(tilt_check), "tiltshift"),
|
|
(ids!(layer_chargers), "chargers"),
|
|
(ids!(layer_transit), "transit"),
|
|
(ids!(layer_nature), "nature"),
|
|
(ids!(layer_districts), "districts"),
|
|
(ids!(layer_buildings), "buildings_age"),
|
|
(ids!(layer_demographics), "demographics"),
|
|
];
|
|
for (id, name) in layer_checks {
|
|
if let Some(on) = self.ui.check_box(cx, id).changed(actions) {
|
|
let _ = self.layers.set_layer(name, on);
|
|
self.layers.dirty = false;
|
|
self.apply_layers(cx);
|
|
}
|
|
}
|
|
// The Inception fold: a live rendering mode on the map itself, not
|
|
// a data layer — MapView owns the tween and the close-3D gating
|
|
// (the setting remembers intent while the camera is elsewhere).
|
|
// Grayed = inert: CheckBox still fires Change while disabled, so
|
|
// outside the regime the mark snaps back and nothing arms.
|
|
if let Some(on) = self.ui.check_box(cx, ids!(warp_check)).changed(actions) {
|
|
let map = self.ui.map_view(cx, ids!(map));
|
|
if map.space_warp_available() {
|
|
map.set_space_warp(cx, on);
|
|
} else {
|
|
self.ui
|
|
.check_box(cx, ids!(warp_check))
|
|
.set_active(cx, map.space_warp(), Animate::No);
|
|
}
|
|
}
|
|
if let Some(on) = self.ui.check_box(cx, ids!(theme_night)).changed(actions) {
|
|
let _ = self.layers.set_theme_name(if on { "night" } else { "light" });
|
|
self.layers.dirty = false;
|
|
self.apply_layers(cx);
|
|
}
|
|
if let Some(on) = self.ui.check_box(cx, ids!(theme_circuit)).changed(actions) {
|
|
let _ = self.layers.set_theme_name(if on { "circuit" } else { "light" });
|
|
self.layers.dirty = false;
|
|
self.apply_layers(cx);
|
|
}
|
|
let map = self.ui.map_view(cx, ids!(map));
|
|
if let Some(id) = map.marker_clicked(actions) {
|
|
if let Some(name) = self.markers.name_of(id) {
|
|
let name = name.to_string();
|
|
self.push_line(cx, &format!("map: {name}"));
|
|
}
|
|
}
|
|
if let Some((lon, lat)) = map.long_pressed(actions) {
|
|
self.push_line(cx, &format!("map: long-press at {lon:.5}, {lat:.5}"));
|
|
}
|
|
if map.viewport_changed(actions).is_some() && self.layers.terrain {
|
|
layers::request_terrain(cx, &map, &mut self.layers);
|
|
}
|
|
// '>' on a trip row: re-apply that snapshot.
|
|
let list = self.ui.portal_list(cx, ids!(list));
|
|
for (item_id, item) in list.items_with_actions(actions) {
|
|
if item.button(cx, ids!(apply_btn)).clicked(actions) {
|
|
let re_applied = self
|
|
.chat
|
|
.entries
|
|
.get(item_id)
|
|
.and_then(|e| e.trip)
|
|
.and_then(|i| self.chat.trips.get(i).cloned());
|
|
if let Some(trip) = re_applied {
|
|
self.trip = trip;
|
|
self.resync_trip_display(cx);
|
|
self.drive_log.log_trip(&self.trip.digest());
|
|
self.push_line(cx, "↩ trip re-applied");
|
|
}
|
|
}
|
|
}
|
|
if let Some((lon, lat, info)) = map.pin_tapped(actions) {
|
|
let summary: Vec<String> = info
|
|
.iter()
|
|
.filter(|(k, _)| matches!(k.as_str(), "name" | "operator" | "max_kw" | "kind" | "city"))
|
|
.map(|(k, v)| format!("{k}: {v}"))
|
|
.collect();
|
|
let text = if summary.is_empty() {
|
|
format!("map: pin at {lon:.5},{lat:.5}")
|
|
} else {
|
|
format!("map: pin — {}", summary.join(", "))
|
|
};
|
|
self.push_line(cx, &text);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AppMain for App {
|
|
fn script_mod(vm: &mut ScriptVm) -> ScriptValue {
|
|
// Whisper stays on the F16 default (ggml-large-v3-turbo.bin): the
|
|
// voice Metal library has no quantized matmul kernels, so q5_0/q8_0
|
|
// models fail every GPU op. Port the kernels before re-quantizing.
|
|
crate::makepad_widgets::script_mod(vm);
|
|
mp_theme::apply(vm);
|
|
self::script_mod(vm)
|
|
}
|
|
|
|
fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
|
|
self.ensure_started(cx);
|
|
// The Space-warp row tracks the camera live: grayed (but visible,
|
|
// so it stays discoverable) outside the near-first-person regime,
|
|
// re-enabled the moment tilt + zoom qualify. Cached so the
|
|
// animator only toggles on transitions.
|
|
let warp_avail = self.ui.map_view(cx, ids!(map)).space_warp_available();
|
|
if self.warp_check_disabled != Some(!warp_avail) {
|
|
self.warp_check_disabled = Some(!warp_avail);
|
|
self.ui
|
|
.check_box(cx, ids!(warp_check))
|
|
.set_disabled(cx, !warp_avail);
|
|
}
|
|
match event {
|
|
Event::Shutdown => {
|
|
self.drive_log.close();
|
|
}
|
|
Event::AudioDevices(devices) => {
|
|
// TTS playback device; mic input selection lives inside the
|
|
// VoiceWave's own handler.
|
|
cx.use_audio_outputs(&devices.default_output());
|
|
}
|
|
Event::LocationUpdate(fix) => {
|
|
self.drive_log.log_fix(fix);
|
|
let map = self.ui.map_view(cx, ids!(map));
|
|
map.set_puck(
|
|
cx,
|
|
Some(MapPuck::new(fix.lon, fix.lat, fix.heading_deg, fix.accuracy_m)),
|
|
);
|
|
if !self.had_first_fix {
|
|
self.had_first_fix = true;
|
|
map.fly_to(cx, fix.lon, fix.lat, 14.0);
|
|
self.push_line(
|
|
cx,
|
|
&format!("gps: fix acquired (±{:.0}m)", fix.accuracy_m),
|
|
);
|
|
}
|
|
self.position = Some(fix.clone());
|
|
// Live turn-by-turn: feed the fix into the session.
|
|
let tick = self.active_nav.as_mut().and_then(|nav| {
|
|
if nav.simulate {
|
|
return None;
|
|
}
|
|
let now = std::time::Instant::now();
|
|
let dt = nav
|
|
.sim_last_tick
|
|
.map(|last| now.duration_since(last).as_secs_f64())
|
|
.unwrap_or(1.0)
|
|
.clamp(0.05, 5.0);
|
|
nav.sim_last_tick = Some(now);
|
|
let pos = makepad_map_nav::geo::LonLat {
|
|
lon: fix.lon,
|
|
lat: fix.lat,
|
|
};
|
|
Some(nav.feed(pos, fix.heading_deg, dt))
|
|
});
|
|
if let Some(tick) = tick {
|
|
self.apply_nav_tick(cx, tick);
|
|
}
|
|
}
|
|
Event::NetworkResponses(responses) => {
|
|
let mut ddg_events = Vec::new();
|
|
for response in responses.iter() {
|
|
ddg_events.extend(self.ddg.handle_response(cx, response));
|
|
}
|
|
for event in ddg_events {
|
|
self.on_ddg_event(cx, event);
|
|
}
|
|
}
|
|
Event::LocationError(error) => {
|
|
let text = match error {
|
|
LocationErrorEvent::PermissionDenied => {
|
|
"gps: permission denied — using map center as position".to_string()
|
|
}
|
|
LocationErrorEvent::Unavailable(msg) => format!("gps: unavailable ({msg})"),
|
|
};
|
|
self.push_line(cx, &text);
|
|
}
|
|
_ => (),
|
|
}
|
|
// Esc closes the assistant popover from anywhere, regardless of
|
|
// which widget currently owns key focus.
|
|
if let Event::KeyDown(key) = event {
|
|
if key.key_code == KeyCode::Escape && self.assistant_panel_open {
|
|
self.assistant_panel_open = false;
|
|
self.ui
|
|
.widget(cx, ids!(assistant_panel))
|
|
.set_visible(cx, false);
|
|
}
|
|
}
|
|
if self.nav_frame.is_event(event).is_some() {
|
|
self.tick_nav_sim(cx);
|
|
}
|
|
if let Some(gate) = &mut self.voice_gate {
|
|
let results = gate.poll();
|
|
for result in results {
|
|
self.on_gate_result(cx, result);
|
|
}
|
|
}
|
|
if !self.busy && !self.voice_queue.is_empty() {
|
|
let next = self.voice_queue.remove(0);
|
|
self.send_user_prompt(cx, &next);
|
|
}
|
|
// The bake worker talks the same way every other worker here does.
|
|
if self.testmap.poll() {
|
|
let finished = matches!(self.testmap.stage, TestMapStage::Done)
|
|
&& !self.testmap_adopted;
|
|
if finished {
|
|
self.testmap_adopted = true;
|
|
self.testmap_finished(cx);
|
|
}
|
|
self.refresh_testmap_ui(cx);
|
|
}
|
|
while let Ok(load) = self.nav_rx.try_recv() {
|
|
match load {
|
|
NavLoad::Ready { data, stats } => {
|
|
self.nav = Some(*data);
|
|
self.push_entry(cx, EntryKind::Info, &stats);
|
|
}
|
|
NavLoad::Failed { error } => {
|
|
self.set_status(cx, &format!("nav data failed: {error}"));
|
|
}
|
|
}
|
|
}
|
|
while let Ok(radar) = self.radar_rx.try_recv() {
|
|
self.radar = Some(radar);
|
|
if self.layers.rain {
|
|
self.apply_layers(cx);
|
|
}
|
|
}
|
|
while let Ok(update) = self.wind_rx.try_recv() {
|
|
self.layers.wind_cache = Some(update);
|
|
if self.layers.wind {
|
|
self.apply_layers(cx);
|
|
}
|
|
}
|
|
while let Ok(update) = self.terrain_rx.try_recv() {
|
|
if self.layers.terrain {
|
|
self.ui.map_view(cx, ids!(map)).set_terrain_overlay(
|
|
cx,
|
|
TerrainOverlayData {
|
|
texels: update.texels,
|
|
width: update.width,
|
|
height: update.height,
|
|
elev_texels: update.elev_texels,
|
|
elev: update.elev,
|
|
elev_width: update.elev_width,
|
|
elev_height: update.elev_height,
|
|
bbox: update.bbox,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
if let Some(mut agent) = self.agent.take() {
|
|
let events = agent.handle_event(cx, event);
|
|
self.agent = Some(agent);
|
|
for agent_event in events {
|
|
self.on_agent_event(cx, agent_event);
|
|
}
|
|
}
|
|
if let Some(mut cloud) = self.cloud_agent.take() {
|
|
let events = cloud.handle_event(cx, event);
|
|
self.cloud_agent = Some(cloud);
|
|
for cloud_event in events {
|
|
self.on_cloud_event(cx, cloud_event);
|
|
}
|
|
}
|
|
self.match_event(cx, event);
|
|
// Feed the tilt-shift layer: on when the checkbox is set AND the
|
|
// map is actually tilted (strength ramps with tilt angle).
|
|
self.chat.tilt_shift_on = self.layers.tilt_shift;
|
|
let tilt = self.ui.map_view(cx, ids!(map)).tilt() as f32;
|
|
self.chat.tilt_strength = ((tilt - 5.0) / 50.0).clamp(0.0, 1.0);
|
|
self.ui
|
|
.handle_event(cx, event, &mut Scope::with_data(&mut self.chat));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// 19:00 through 06:59 is night; 07:00 through 18:59 is light — the
|
|
/// boundary hours (6/7/18/19) are the ones a fencepost bug would miss.
|
|
#[test]
|
|
fn theme_for_hour_matches_civil_twilight_rule() {
|
|
assert_eq!(theme_name_for_hour(19), "night");
|
|
assert_eq!(theme_name_for_hour(7), "light");
|
|
assert_eq!(theme_name_for_hour(6), "night");
|
|
assert_eq!(theme_name_for_hour(18), "light");
|
|
assert_eq!(theme_name_for_hour(0), "night");
|
|
assert_eq!(theme_name_for_hour(23), "night");
|
|
assert_eq!(theme_name_for_hour(12), "light");
|
|
assert_eq!(theme_name_for_hour(20), "night");
|
|
}
|
|
|
|
#[test]
|
|
fn utc_offset_parses_sign_and_magnitude() {
|
|
assert_eq!(parse_utc_offset("+0200"), 7200);
|
|
assert_eq!(parse_utc_offset("-0730"), -27000);
|
|
assert_eq!(parse_utc_offset("+0000"), 0);
|
|
assert_eq!(parse_utc_offset("garbage"), 0);
|
|
}
|
|
}
|