app
Parts Search
Public Made by Adomby adom
One search box across Mouser, DigiKey and JLCPCB: parallel queries, side-by-side product photos, and a Mouser-preferred recommendation that reasons around stock and lead time.
← Commit history
v1.1.1: Quota guard, copy-only Say it phrases, neutral best-source language
11 files changed
+57−119
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-parts-search"-version = "1.1.0"+version = "1.1.1" dependencies = [ "base64", "clap",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-parts-search"-version = "1.1.0"+version = "1.1.1" edition = "2021" description = "Parts Search Electronics component search — CLI, backend HTTP service, and Hydrogen webview app" license = "MIT"
VERSION+1−1@@ -1 +1 @@-1.1.0+1.1.1
package.json+1−40@@ -38,44 +38,5 @@ "SKILL.md", "BUILD-SKILL.md", "CHANGELOG.md"- ],- "changelog": "Quota guard, copy-only Say it phrases, neutral best-source language",- "discovery_triggers": [- "parts search",- "find part",- "source component",- "source part",- "compare distributors",- "compare vendors",- "Mouser or DigiKey",- "BOM sourcing",- "price this part",- "where can I get",- "check stock",- "parts availability",- "fastest delivery",- "cheapest part",- "component search",- "part lookup",- "price breaks"- ],- "discovery_pitch": "Search Mouser, DigiKey and JLCPCB in parallel with product photos; Mouser-preferred best-source pick. Say 'with parts search' to your AI.",- "sample_prompts": [- {- "label": "source an STM32F103RBT6",- "prompt": "source an STM32F103RBT6"- },- {- "label": "where can I get an ESP32-S3-WROOM cheape",- "prompt": "where can I get an ESP32-S3-WROOM cheapest and fastest"- },- {- "label": "compare Mouser, DigiKey and JLCPCB for a",- "prompt": "compare Mouser, DigiKey and JLCPCB for an LM555"- },- {- "label": "price this BOM across distributors",- "prompt": "price this BOM across distributors"- } ]-}+}
page.json+9−50@@ -3,7 +3,7 @@ "type": "app", "slug": "adom-parts-search", "title": "Parts Search",- "brief": "One search box across Mouser, DigiKey and JLCPCB: parallel queries, side-by-side product photos, and a Mouser-preferred recommendation (40-minute drone delivery to Fort Worth) that reasons around stock and lead time.",+ "brief": "One search box across Mouser, DigiKey and JLCPCB: parallel queries, side-by-side product photos, and a Mouser-preferred recommendation that reasons around stock and lead time.", "version": "1.1.1", "install": { "binary_name": "adom-parts-search",@@ -33,25 +33,13 @@ }, "hero": { "type": "image",- "path": "screenshots/hero.png"+ "path": "docs/hero.png" }, "sample_prompts": [- {- "label": "source an STM32F103RBT6",- "prompt": "source an STM32F103RBT6"- },- {- "label": "where can I get an ESP32-S3-WROOM cheape",- "prompt": "where can I get an ESP32-S3-WROOM cheapest and fastest"- },- {- "label": "compare Mouser, DigiKey and JLCPCB for a",- "prompt": "compare Mouser, DigiKey and JLCPCB for an LM555"- },- {- "label": "price this BOM across distributors",- "prompt": "price this BOM across distributors"- }+ "source an STM32F103RBT6",+ "where can I get an ESP32-S3-WROOM cheapest and fastest",+ "compare Mouser, DigiKey and JLCPCB for an LM555",+ "price this BOM across distributors" ], "discovery_triggers": [ "parts search",@@ -66,41 +54,12 @@ "where can I get", "check stock", "parts availability",- "fastest delivery",- "cheapest part",- "component search",- "part lookup",- "price breaks"+ "fastest delivery" ],- "discovery_pitch": "Search Mouser, DigiKey and JLCPCB in parallel with product photos; Mouser-preferred best-source pick. Say 'with parts search' to your AI.",+ "discovery_pitch": "Search Mouser + DigiKey + JLCPCB in parallel from one interface, with product photos and a Mouser-preferred best-source pick.", "metadata": { "platform": "linux" }, "category": "tools",- "org": "adom",- "description": "One search box across Mouser, DigiKey and JLCPCB. Parallel queries, side-by-side product photos, and a Mouser-preferred recommendation that reasons around stock and lead time. Dock Bar ready: serve, health, settings, Say it.",- "dependencies": {- "adom/adom-mouser": "*",- "adom/adom-digikey": "*",- "adom/adom-jlcpcb": "*"- },- "needs_sudo": false,- "scripts": {- "install": "./install.sh",- "uninstall": "./uninstall.sh"- },- "files": [- "bin/**",- "skills/**",- "docs/icon.svg",- "dockbar.json",- "install.sh",- "uninstall.sh",- "package.json",- "README.md",- "SKILL.md",- "BUILD-SKILL.md",- "CHANGELOG.md"- ],- "changelog": "Quota guard, copy-only Say it phrases, neutral best-source language"+ "org": "adom" }
screenshots/dock-launch.pngadded⋯ 1 unchanged line ⋯
src/app/mod.rs+28@@ -253,7 +253,33 @@ fn stage_phrase(phrase: &str) -> Value { json!({"ok": false, "error": "No Parts Search AI thread exists yet. Copied the phrase instead: paste it into any Adom AI thread."}) } +/// Vendor API calls are quota-limited (Mouser's free tier is ~1000/day,+/// shared). An identical search repeated within this window is answered+/// from the app's own last result instead of hitting the backends, so a+/// polling AI or a retry loop can never drain the day's quota.+const SEARCH_CACHE_MS: u128 = 300_000;+ fn run_search(state: &AppState, keyword: &str, limit: u32, in_stock: bool, source: &str) -> Value {+ {+ let last_q = state.last_query.lock().unwrap().clone();+ let last_ts = *state.last_query_ts.lock().unwrap();+ if last_q.as_deref() == Some(keyword) && now_ms().saturating_sub(last_ts) < SEARCH_CACHE_MS {+ if let Some(prev) = state.last_results.lock().unwrap().clone() {+ let same_params = prev.get("limit_used").and_then(|v| v.as_u64()) == Some(limit as u64)+ && prev.get("in_stock_used").and_then(|v| v.as_bool()) == Some(in_stock);+ let all_ok = ["mouser", "digikey", "jlcpcb"].iter().all(|k| {+ let b = &prev[k];+ b.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) || b.get("skipped").and_then(|v| v.as_bool()).unwrap_or(false)+ });+ if same_params && all_ok {+ let mut v = prev;+ v["cached"] = json!(true);+ v["_hint"] = json!(format!("identical search within {}s answered from the app's cache; vendor quotas untouched", SEARCH_CACHE_MS / 1000));+ return v;+ }+ }+ }+ } let s = state.settings.lock().unwrap().clone(); let opts = vendors::SearchOpts::from_settings(&s); *state.searching.lock().unwrap() = Some(keyword.to_string());@@ -265,6 +291,8 @@ fn run_search(state: &AppState, keyword: &str, limit: u32, in_stock: bool, sourc if let Some(b) = bar { b.finish(true); } let mut v = serde_json::to_value(&agg).unwrap_or(json!({})); v["elapsed_ms"] = json!(ms);+ v["limit_used"] = json!(limit);+ v["in_stock_used"] = json!(in_stock); v["source"] = json!(source); v["policy"] = json!(s.policy); *state.last_query.lock().unwrap() = Some(keyword.to_string());
src/app/ui.html+12−22@@ -91,11 +91,7 @@ main{max-width:1320px;margin:0 auto;padding:16px 16px 40px} .say-phrase em{font-style:normal;color:var(--accent)} .say-phrase:hover{background:var(--overlay)} .say-phrase:active{transform:translateY(1px) scale(.98)}-.say-try{display:inline-flex;align-items:center;padding:0 12px;border:none;border-left:1px solid var(--border);background:none;color:var(--text-2);font:600 12px var(--brand-heading);cursor:pointer;transition:background .15s ease,color .15s ease,transform .06s ease}-.say-try:hover{background:var(--overlay);color:var(--accent)}-.say-try:active{transform:translateY(1px)}-.say-try:disabled{opacity:.5;cursor:progress}-.say-phrase:focus-visible,.say-try:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}+.say-phrase:focus-visible{outline:2px solid var(--accent);outline-offset:-2px} /* Search bar */ .searchwrap{position:relative;border-radius:14px;padding:1px;background:var(--border);transition:background .2s ease;box-shadow:0 12px 40px rgba(0,0,0,.35)}@@ -319,7 +315,6 @@ select.sel:disabled{opacity:.5;cursor:not-allowed} .seg button{min-width:40px;height:36px} .toggle-row{min-height:44px} .say-phrase{min-height:40px}- .say-try{padding:0 14px} .app-footer .f-hide{display:none} .recommendation{padding:12px;flex-wrap:wrap} .examples{display:none}@@ -330,7 +325,7 @@ select.sel:disabled{opacity:.5;cursor:not-allowed} .settings-body{max-height:calc(100vh - 50px)} } @media (hover:none),(pointer:coarse){- .iconbtn,.btn,.say-try{min-width:44px;min-height:44px}+ .iconbtn,.btn{min-width:44px;min-height:44px} } </style> </head>@@ -365,7 +360,7 @@ select.sel:disabled{opacity:.5;cursor:not-allowed} <button class="iconbtn say-close" id="sayClose" aria-label="Hide the Say it strip" data-tooltip="Hide this strip. It stays hidden on every visit until you turn it back on in Settings (Say it strip)."><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button> <div class="say-head"> <h2 id="sayTitle">Say it to your AI</h2>- <span class="say-sub">Ask your AI for parts by name, using the words <b>parts search</b>. Click a phrase to copy it; Try it stages it into this app's AI thread, unsent.</span>+ <span class="say-sub">Ask your AI for parts by name, using the words <b>parts search</b>. Click a phrase to copy it, then paste it into any Adom AI thread.</span> </div> <div class="say-list" id="sayList"></div> </section>@@ -384,7 +379,7 @@ select.sel:disabled{opacity:.5;cursor:not-allowed} <button type="button" class="toggle" id="instock" role="switch" aria-checked="false" aria-label="In stock only"><span class="toggle-track"><span class="toggle-thumb"></span></span></button> <span class="tl">In stock only</span> </label>- <button type="submit" class="btn primary" id="go" data-tooltip="Fires one search at Mouser, DigiKey, and JLCPCB in parallel and renders three columns below, then names the best source. Mouser wins by default (40-minute drone delivery from Mansfield to Fort Worth, near-zero shipping) unless it is out of stock or long-lead; change the policy in Settings.">Search all 3</button>+ <button type="submit" class="btn primary" id="go" data-tooltip="Fires one search at Mouser, DigiKey, and JLCPCB in parallel and renders three columns below, then names the best source. Mouser wins by default (the preferred vendor) unless it is out of stock or long-lead; change the policy in Settings.">Search all 3</button> </div> </form></div> <div class="examples" id="examples" aria-label="Example searches"><span class="ex-label">Try</span></div>@@ -394,7 +389,7 @@ select.sel:disabled{opacity:.5;cursor:not-allowed} <div id="grid" class="grid"> <section id="col-mouser" class="column mouser" aria-labelledby="t-mouser">- <div class="col-head"><span class="col-dot"></span><span class="col-title" id="t-mouser">Mouser</span><span class="col-badge" id="badge-mouser" data-tooltip="Mouser is the preferred vendor under the default policy: 40-minute drone delivery from Mansfield, TX to the Fort Worth facility, with near-zero shipping cost.">Preferred</span><span class="col-meta" id="meta-mouser"></span></div>+ <div class="col-head"><span class="col-dot"></span><span class="col-title" id="t-mouser">Mouser</span><span class="col-badge" id="badge-mouser" data-tooltip="Mouser is the preferred vendor under the default policy: fastest, lowest-cost fulfillment for Adom orders.">Preferred</span><span class="col-meta" id="meta-mouser"></span></div> <div class="col-body" id="body-mouser"></div> </section> <section id="col-digikey" class="column digikey" aria-labelledby="t-digikey">@@ -503,7 +498,7 @@ select.sel:disabled{opacity:.5;cursor:not-allowed} <label class="choice" data-policy="mouser" data-selected="false"> <input type="radio" name="policy" value="mouser"> <span class="choice-name"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l2-5 4 10 2-5h6"/></svg>Mouser preferred</span>- <span class="choice-desc">40-minute drone delivery from Mansfield to Fort Worth and near-zero shipping beat a few cents of unit price. Mouser wins whenever it has stock.</span>+ <span class="choice-desc">Mouser is Adom's preferred vendor: fastest, lowest-cost fulfillment beats a few cents of unit price. Mouser wins whenever it has stock.</span> </label> <label class="choice" data-policy="cheapest" data-selected="false"> <input type="radio" name="policy" value="cheapest">@@ -665,19 +660,14 @@ let SAY=[]; function renderSay(list){ if(JSON.stringify(list)===JSON.stringify(SAY)&&$('#sayList').children.length)return; SAY=list;const host=$('#sayList');$('#say').hidden=!settings.showSayStrip;- host.innerHTML=SAY.map((p,i)=>'<span class="say-item"><button type="button" class="say-phrase" id="say-copy-'+i+'" data-i="'+i+'" data-tooltip="Click to copy this phrase, then paste it into any Adom AI thread."><span class="txt">'+escape(p).replace(/parts search/i,'<em>parts search</em>')+'</span></button><button type="button" class="say-try" id="say-try-'+i+'" data-i="'+i+'" data-tooltip="Stage this phrase into your Parts Search AI thread. It is not sent until you press Enter there.">Try it</button></span>').join('');+ host.innerHTML=SAY.map((p,i)=>'<span class="say-item"><button type="button" class="say-phrase" id="say-copy-'+i+'" data-i="'+i+'" data-tooltip="Copy this phrase to the clipboard, then paste it into any Adom AI thread."><span class="txt">'+escape(p).replace(/parts search/i,'<em>parts search</em>')+'</span></button></span>').join(''); } $('#sayList').addEventListener('click',async e=>{- const ph=e.target.closest('.say-phrase'),tr=e.target.closest('.say-try');- if(ph){const p=SAY[+ph.dataset.i],item=ph.closest('.say-item');try{await navigator.clipboard.writeText(p);}catch{}- item.classList.add('copied');const old=ph.innerHTML;ph.innerHTML='<span class="txt">copied</span>';setTimeout(()=>{item.classList.remove('copied');ph.innerHTML=old;},1200);- toast('Copied: '+p,'success');return;}- if(tr){const p=SAY[+tr.dataset.i];tr.disabled=true;tr.textContent='Staging';- try{const r=await fetch('api/try',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({phrase:p})});const d=await r.json();- if(d.ok)toast('Staged in your '+(d.where||'AI thread')+'. Press Enter there to send it.','success');- else{try{await navigator.clipboard.writeText(p);}catch{} toast(d.error||'Could not stage the phrase; copied it instead.','error');}- }catch(err){try{await navigator.clipboard.writeText(p);}catch{} toast('Hydrogen did not answer; copied the phrase instead.','error');}- tr.disabled=false;tr.textContent='Try it';}+ const ph=e.target.closest('.say-phrase');+ if(!ph)return;+ const p=SAY[+ph.dataset.i],item=ph.closest('.say-item');try{await navigator.clipboard.writeText(p);}catch{}+ item.classList.add('copied');const old=ph.innerHTML;ph.innerHTML='<span class="txt">copied</span>';setTimeout(()=>{item.classList.remove('copied');ph.innerHTML=old;},1200);+ toast('Copied: '+p,'success'); }); // ---- Rendering ----
src/main.rs+1−1@@ -68,7 +68,7 @@ macro_rules! note { } #[derive(Parser)]-#[command(name = "adom-parts-search", version, about = "Parts Search: one query across Mouser, DigiKey, and JLCPCB, with a Mouser-preferred recommendation (40-minute drone delivery to Fort Worth)")]+#[command(name = "adom-parts-search", version, about = "Parts Search: one query across Mouser, DigiKey, and JLCPCB, with a Mouser-preferred best-source recommendation")] pub struct Cli { #[command(subcommand)] cmd: Cmd,
src/settings.rs+1−1@@ -18,7 +18,7 @@ pub struct Settings { pub vendor_mouser: bool, pub vendor_digikey: bool, pub vendor_jlcpcb: bool,- /// "mouser" (Mouser-preferred, the Fort Worth drone policy) or+ /// "mouser" (Mouser-preferred) or /// "cheapest" (lowest in-stock unit price wins across all vendors). pub policy: String, /// Render vendor product photos (fetched from the vendors' CDNs).
src/vendors.rs+2−2@@ -582,7 +582,7 @@ fn build_empty_state(agg: &AggregatedResults) -> EmptyState { } /// Apply the Mouser-preferred policy from adom-parts-search SKILL:-/// near-zero shipping + 40-min drone means Mouser wins unless it's+/// Mouser (the preferred vendor) wins unless it's /// out of stock / high-lead / DigiKey is dramatically cheaper. /// /// We scan ALL hits per vendor, not just the first one — it's common@@ -627,7 +627,7 @@ fn pick_winner(agg: &AggregatedResults) -> Option<Recommendation> { if let Some(m) = &m { return Some(Recommendation { winner: Vendor::Mouser,- reason: "Mouser has it in stock: 40-minute drone delivery from Mansfield, near-zero shipping.".into(),+ reason: "Mouser has it in stock and is the preferred vendor: fastest, lowest-cost fulfillment.".into(), mpn: m.mpn.clone(), unit_price: m.unit_price.clone(), stock: m.stock,