use wasm_bindgen::prelude::*;
use crate::app::{dom, templates};
pub(crate) async fn refresh_fund_banner() {
if dom::by_id("fund-banner").is_none() {
return;
}
let is_credits = local_storage()
.and_then(|s| s.get_item("lh_model_access").ok().flatten())
.map(|m| m != "byok")
.unwrap_or(true);
if !is_credits {
dom::swap_inner("fund-banner", "");
return;
}
let Some(addr) = crate::app::chat::credit_address_existing().await else {
dom::swap_inner("fund-banner", "");
return;
};
let wallet = crate::app::registry::token_balance_of(&addr).await.unwrap_or(0);
let meter = crate::app::registry::credit_balance_of(&addr).await.unwrap_or(0);
if wallet == 0 && meter == 0 {
dom::swap_inner("fund-banner", &templates::fund_banner_body().into_string());
} else {
dom::swap_inner("fund-banner", "");
}
}
pub(super) fn run_set_model_access(mode: String) {
if let Some(storage) =
web_sys::window().and_then(|w| w.local_storage().ok().flatten())
{
let _ = storage.set_item("lh_model_access", &mode);
}
if dom::by_id("credits-section").is_some() {
dom::swap_outer(
"credits-section",
&crate::app::templates::admin_credits_section().into_string(),
);
}
if mode == "credits" {
if let Some(el) = dom::by_id("api-key-modal") {
if let Some(parent) = el.parent_element() {
let _ = parent.remove_child(&el);
}
}
}
wasm_bindgen_futures::spawn_local(async {
super::refresh_credits_pill().await;
});
}
pub(super) fn run_set_model(model: String) {
wasm_bindgen_futures::spawn_local(async move {
crate::app::model::save(&model).await;
refresh_model_selector().await;
let label = crate::app::model::MODELS
.iter()
.find(|(id, _)| *id == model)
.map(|(_, l)| *l)
.unwrap_or("model");
dom::swap_inner(
"model-msg",
&format!("{label} — applies on your next message"),
);
});
}
const LOCAL_WEIGHTS_URL: &str =
"https://huggingface.co/unsloth/gemma-3-270m/resolve/main/model.safetensors";
const LOCAL_TOKENIZER_URL: &str =
"https://huggingface.co/unsloth/gemma-3-270m/resolve/main/tokenizer.json";
const LOCAL_WEIGHTS_OPFS: &str = ".lh_local_model.safetensors";
const LOCAL_TOKENIZER_OPFS: &str = ".lh_local_tokenizer.json";
pub(super) fn run_download_local_model() {
use futures_util::StreamExt as _;
wasm_bindgen_futures::spawn_local(async move {
let fs = crate::app::shared_opfs();
async fn fetch_to_opfs(
fs: &crate::filesystem::SharedFilesystem,
url: &str,
opfs_path: &str,
label: &str,
) -> Result<(), String> {
let resp = reqwest::Client::new()
.get(url)
.send()
.await
.map_err(|e| format!("fetch {label}: {e}"))?;
if !resp.status().is_success() {
return Err(format!("fetch {label}: HTTP {}", resp.status().as_u16()));
}
let total = resp.content_length();
let mut buf: Vec<u8> = Vec::with_capacity(total.unwrap_or(0) as usize);
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("download {label}: {e}"))?;
buf.extend_from_slice(&chunk);
let got_mb = buf.len() / (1024 * 1024);
let msg = match total {
Some(t) => {
let pct = (buf.len() as f64 / t as f64 * 100.0) as u32;
format!("downloading {label}: {got_mb} MB ({pct}%)")
}
None => format!("downloading {label}: {got_mb} MB"),
};
dom::swap_inner("local-model-msg", &msg);
}
fs.write_atomic(opfs_path, &buf)
.await
.map_err(|e| format!("save {label}: {e}"))?;
Ok(())
}
dom::swap_inner("local-model-msg", "starting download…");
let result = async {
fetch_to_opfs(&fs, LOCAL_TOKENIZER_URL, LOCAL_TOKENIZER_OPFS, "tokenizer").await?;
fetch_to_opfs(&fs, LOCAL_WEIGHTS_URL, LOCAL_WEIGHTS_OPFS, "weights").await?;
Ok::<(), String>(())
}
.await;
match result {
Ok(()) => dom::swap_inner(
"local-model-msg",
"local model ready — select Local (Gemma) and send a message",
),
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("local model download: {e}")));
dom::swap_inner("local-model-msg", &dom::msg_span(dom::Msg::Error, &e));
}
}
});
}
pub(super) async fn refresh_model_selector() {
if dom::by_id("model-selector-row").is_none() {
return;
}
let chosen = crate::app::model::load().await;
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
if let Ok(buttons) = doc.query_selector_all("#model-selector-row button[data-model]") {
for i in 0..buttons.length() {
if let Some(el) = buttons.get(i) {
let btn: web_sys::Element = JsCast::unchecked_into(el);
let is_active = btn.get_attribute("data-model").as_deref() == Some(&chosen);
btn.set_class_name(if is_active { "ghost active" } else { "ghost" });
}
}
}
}
}
pub(super) fn redeem_code_pressed() {
redeem_from("redeem-code", "credits-msg");
}
pub(super) fn redeem_banner_pressed() {
redeem_from("fund-redeem-code", "fund-msg");
}
fn redeem_from(input_id: &'static str, msg_id: &'static str) {
let Some(input) = dom::input_by_id(input_id) else { return };
let code = input.value().trim().to_string();
if code.is_empty() {
return;
}
dom::swap_inner(
msg_id,
"<span style=\"color:var(--muted)\">redeeming…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
let result = async {
let (signer, _) = crate::app::chat::credit_signer()
.await
.ok_or_else(|| "no identity".to_string())?;
let fee_payer = crate::app::sponsor::signer()?;
crate::app::registry::redeem_sponsored(
&signer,
&fee_payer,
&code,
crate::app::registry::ALPHA_USD_ADDRESS,
)
.await
}
.await;
match result {
Ok(_) => {
dom::swap_inner(
msg_id,
"<span style=\"color:var(--muted)\">redeemed</span>",
);
crate::app::chat::ensure_credit_meter().await;
super::refresh_credits_pill().await;
refresh_fund_banner().await;
}
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("redeem: {e}")));
dom::swap_inner(
msg_id,
&dom::msg_span(dom::Msg::Error, &format!("redeem failed: {e}")),
);
}
}
});
}
pub(super) fn redeem_invite_onboard_pressed() {
let Some(input) = dom::input_by_id("invite-onboard-input") else {
return;
};
let code = input.value().trim().to_string();
if code.is_empty() {
return;
}
let Some(flow_guard) = super::onboard_flow_begin() else {
return;
};
let msg_id = "invite-onboard-msg";
let is_invite = code.starts_with("inv-");
dom::swap_inner(
msg_id,
"<span style=\"color:var(--muted)\">creating identity…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
let _flow_guard = flow_guard; let result = async {
let (signer, _) = crate::app::net::with_timeout(
15_000,
crate::app::chat::credit_signer(),
)
.await
.map_err(|_| "identity setup timed out — reload and try again".to_string())?
.ok_or_else(|| "no identity".to_string())?;
let fee_payer = crate::app::sponsor::signer()?;
crate::app::debuglog::log("onboard: identity ready — sending sponsored claim");
dom::swap_inner(
"invite-onboard-msg",
"<span style=\"color:var(--muted)\">accepting on-chain…</span>",
);
let send = async {
if is_invite {
crate::app::registry::accept_invite_sponsored(
&signer,
&fee_payer,
&code,
crate::app::registry::ALPHA_USD_ADDRESS,
)
.await
} else {
crate::app::registry::redeem_sponsored(
&signer,
&fee_payer,
&code,
crate::app::registry::ALPHA_USD_ADDRESS,
)
.await
}
};
crate::app::net::with_timeout(45_000, send)
.await
.map_err(|_| {
"network timed out — check your connection and tap redeem again".to_string()
})?
}
.await;
match result {
Ok(_) => {
if let Some(s) = local_storage() {
let _ = s.set_item("lh_model_access", "credits");
}
dom::swap_inner(
msg_id,
&dom::msg_span(dom::Msg::Accent, "redeemed — $LH added"),
);
crate::app::chat::ensure_credit_meter().await;
crate::app::paint_apex(crate::app::tenant::Host::Apex).await;
}
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("invite redeem: {e}")));
dom::swap_inner(
msg_id,
&dom::msg_span(
dom::Msg::Error,
"couldn't redeem (it may be used or expired)",
),
);
}
}
});
}
fn local_storage() -> Option<web_sys::Storage> {
web_sys::window().and_then(|w| w.local_storage().ok().flatten())
}
pub(crate) fn pending_invite_code() -> Option<String> {
local_storage()?
.get_item("lh_pending_invite")
.ok()
.flatten()
.filter(|s| !s.is_empty())
}
pub(crate) async fn try_redeem_pending_invite(allow_generate: bool) {
let Some(code) = pending_invite_code() else {
return;
};
if !allow_generate && crate::app::chat::credit_address_existing().await.is_none() {
return;
}
let Some((signer, _)) = crate::app::chat::credit_signer().await else {
return;
};
let Ok(fee_payer) = crate::app::sponsor::signer() else {
return;
};
if let Some(s) = local_storage() {
let _ = s.remove_item("lh_pending_invite");
}
let is_invite = code.starts_with("inv-");
dom::set_status(
if is_invite { "accepting invite…" } else { "redeeming invite…" },
false,
);
let claim = async {
if is_invite {
crate::app::registry::accept_invite_sponsored(
&signer,
&fee_payer,
&code,
crate::app::registry::ALPHA_USD_ADDRESS,
)
.await
} else {
crate::app::registry::redeem_sponsored(
&signer,
&fee_payer,
&code,
crate::app::registry::ALPHA_USD_ADDRESS,
)
.await
}
};
let result = crate::app::net::with_timeout(45_000, claim)
.await
.map_err(|_| "timed out".to_string())
.and_then(|r| r);
match result {
Ok(_) => {
if let Some(s) = local_storage() {
let _ = s.set_item("lh_model_access", "credits");
}
dom::set_status(
if is_invite {
"invite accepted — $LH added"
} else {
"invite redeemed — platform credits added"
},
false,
);
super::refresh_credits_pill().await;
refresh_fund_banner().await;
}
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("invite claim: {e}")));
dom::set_status(
"invite couldn't be claimed (it may be used or expired)",
true,
);
refresh_fund_banner().await;
}
}
}
const INVITE_DEFAULT_TTL_SECS: u64 = 7 * 24 * 3600;
fn gen_invite_code(amount_label: &str) -> String {
const ALPHABET: &[u8; 32] = b"abcdefghjkmnpqrstvwxyz23456789ab";
let bytes = crate::app::registry::random_x402_nonce(); let mut tail = String::with_capacity(10);
for &b in bytes.iter().take(10) {
tail.push(ALPHABET[(b & 0x1f) as usize] as char);
}
format!("inv-{amount_label}-{tail}")
}
pub(super) fn create_invite_pressed() {
let Some(input) = dom::input_by_id("invite-amount") else {
return;
};
let raw = input.value().trim().to_string();
let Some(amount_wei) = crate::encoding::parse_token_amount(&raw) else {
return;
};
if amount_wei == 0 {
return;
}
let amount_label: String = raw
.chars()
.filter(|c| c.is_ascii_digit() || *c == '.')
.collect();
let code = gen_invite_code(&amount_label);
let code_hash = crate::app::registry::invite_code_hash(&code);
dom::swap_inner(
"invite-result",
"<span style=\"color:var(--muted)\">creating invite…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
let result = async {
super::sponsor_rate_guard()?;
let (signer, addr) = crate::app::chat::credit_signer()
.await
.ok_or_else(|| "no identity".to_string())?;
let from_hex = crate::encoding::bytes_to_hex_str(&addr);
let bridge_wei =
crate::app::chat::escrow_bridge_wei(&from_hex, amount_wei).await?;
let fee_payer = crate::app::sponsor::signer()?;
crate::app::registry::create_invite_sponsored_bridged(
&signer,
&fee_payer,
code_hash,
amount_wei,
INVITE_DEFAULT_TTL_SECS,
crate::app::registry::ALPHA_USD_ADDRESS,
bridge_wei,
)
.await
}
.await;
match result {
Ok(_) => {
super::refresh_credits_pill().await;
let link = format!("https://localharness.xyz/?invite={code}");
dom::swap_inner(
"invite-result",
&templates::invite_result_panel(&code, &link).into_string(),
);
}
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("create invite: {e}")));
dom::swap_inner(
"invite-result",
&dom::msg_span(dom::Msg::Error, "invite couldn't be created (need $LH to escrow)"),
);
}
}
});
}