← Commit history

2.37.0: AI-progress status route (1.0.187+); one send path

John Lauer ·7b2638a1b4 ·1mo ago ·parent 2a8359b
10 files changed +116−72
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-lbr"-version = "2.36.1"+version = "2.37.0" dependencies = [  "altium-codec",  "anyhow",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.36.1"+version = "2.37.0" edition = "2021" description = "adom-lbr generator / EDA translator: the canonical adom-lbr JSON is the hub, with native converters to Altium (.SchLib/.PcbLib/.IntLib), EAGLE/Fusion (.lbr), and KiCad. Altium output is written natively — no Altium needed at conversion time." 
VERSION+1−1
@@ -1 +1 @@-2.36.1+2.37.0
bin/adom-lbr
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.36.1",+  "version": "2.37.0",   "type": "app",   "description": "adom-lbr \u2014 the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad \u21c4 Altium \u21c4 EAGLE/Fusion) through one canonical adom-lbr JSON \u2014 symbol + footprint + 3D + pin\u2194pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.",   "dependencies": {},
page.json+2−2
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.36.1",+  "version": "2.37.0",   "type": "app",   "description": "adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footprint + 3D + pin↔pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.",   "dependencies": {},@@ -88,4 +88,4 @@       "label": "Browse parts"     }   ]-}\ No newline at end of file+}
skills/adom-lbr-dockbar/SKILL.md+17−4
@@ -100,10 +100,23 @@ escalates to "stuck?" past 15 minutes), and newbie-graduation (localStorage key `adom-lbr:dash-graduation`, namespaced because the cloud proxy serves every port under one origin; dismissal / 3 green launches / first AI-driven action fold the scaffolding, the say strip last at 5).-"Send to AI" goes through `POST /api/say-send`: the backend opens a NEW-thread via the panel's AI-threads API (tag `dock:adom-lbr`) and submits --new threads only, resume stays parked; failure answers {ok:false, why}-and the page falls back to copying.+"Send to AI" belongs to the SHARED newbie-panel module, not to this app. The+module owns the one send implementation in the fleet; this server only proxies+three routes to the Hydrogen control API, same-origin, bytes unchanged in both+directions:++    GET  /updater/status                 (the version gate)+    POST /ai-threads/run-prompt          (120s: the tab create alone can take 60)+    GET  /ai-threads/run-prompt/status   (query string INTACT, polled once a second)++The third is new for Hydrogen 1.0.187+, which returns a `job` id in under a+second and never fills `delivered_to` again; all progress lives on the status+route. An app that forwards only the path asks about no job at all, and every+poll answers about nothing while still returning 200.++`POST /api/say-send` is GONE (2.37.0). It was a second, server-side send that+verified by `delivered_to`, so on 1.0.187+ it would have called every successful+send a failure. One send path, in the module.  ## Say it, and Try it 
src/dock.rs−48
@@ -248,54 +248,6 @@ pub fn control_proxy_raw(path: &str, post_body: Option<&str>, timeout_s: u32) ->     Ok(ProxyReply { status, content_type, body }) } -pub fn say_send(phrase: &str) -> Value {-    if phrase.trim().is_empty() {-        return json!({"ok": false, "why": "no phrase given"});-    }-    let base = control_api_base();--    // Gate NUMERICALLY on the updater's current version (a lexical compare-    // passes 1.0.99 as newer than 1.0.142). The crash fix ships in 1.0.142;-    // an older shell gets an honest refusal and the page's copy fallback.-    if let Some(v) = curl_json(&format!("{base}/updater/status"), None, 4)-        .and_then(|j| j.pointer("/updater/current_version").or_else(|| j.pointer("/current_version"))-            .and_then(|x| x.as_str()).map(String::from))-    {-        let nums: Vec<u32> = v.trim_start_matches('v').split('.').filter_map(|p| p.parse().ok()).collect();-        if nums.len() >= 3 && (nums[0], nums[1], nums[2]) < (1u32, 0u32, 142u32) {-            return json!({"ok": false, "shellTooOld": true, "shellVersion": v,-                "why": format!("This Hydrogen ({v}) predates the fix for a crash in the AI panel's send path; update to 1.0.142 or newer. Copied instead; paste it into any AI thread.")});-        }-    }--    // ONE call: /ai-threads/new never returns an id, so the create and the-    // prompt ride together. 120 s, because the tab create alone can take 60.-    let body = json!({-        "new": true,-        "prompt": phrase,-        "submit": true,-        "name": "Adom LBR - say it",-        "tags": ["dock:adom-lbr"]-    });-    let Some(r) = curl_json(&format!("{base}/ai-threads/run-prompt"), Some(&body), 120) else {-        // NEVER retry blind on a create timeout: a second call can land a-        // second thread. Surface the failure and let the page copy instead.-        return json!({"ok": false, "why": "the AI panel did not answer within 120 s; nothing was sent. Copied instead."});-    };-    // Verify by delivered_to, never by the thread's name: a correctly-named tab-    // that delivered nothing, and an unnamed tab that delivered, both happened.-    let delivered = r.get("delivered_to").and_then(|d| d.as_str()).filter(|d| !d.is_empty());-    match delivered {-        Some(d) => json!({"ok": true, "delivered_to": d}),-        None => {-            let why = r.get("_hints").and_then(|h| h.as_str())-                .or_else(|| r.get("error").and_then(|e| e.as_str()))-                .unwrap_or("the AI panel accepted the call but confirmed no delivery");-            json!({"ok": false, "why": format!("{why}. Copied instead.")})-        }-    }-}- /// "Try it": stage the phrase, unsubmitted, into this app's own AI thread via /// the Hydrogen control API. Census first (open threads), history second /// (closed ones, resumed first), else tell the page to copy. Reachable from a
src/manage.rs+9−7
@@ -132,7 +132,8 @@ fn path_known(path: &str) -> bool {     matches!(path,         "/" | "/health" | "/api/apply" | "/api/apply-progress" | "/api/status"         | "/newbie-panel.js" | "/updater/status" | "/ai-threads/run-prompt"-        | "/api/say" | "/api/say-send" | "/api/try" | "/api/settings"+        | "/ai-threads/run-prompt/status"+        | "/api/say" | "/api/try" | "/api/settings"         | "/api/settings/reset" | "/api/claim-webview" | "/api/components")         || (path.starts_with("/tour/step-") && path.ends_with(".mp3"))         || path.ends_with("/favicon.svg") || path.ends_with("/favicon.ico")@@ -211,15 +212,16 @@ fn route_inner(method: &Method, url: &str, path: &str, body: &str) -> Response<C         // create alone can take 60, and a blind retry lands a SECOND thread.         (Method::Get, "/updater/status") => proxy_resp(dock::control_proxy_raw("/updater/status", None, 5)),         (Method::Post, "/ai-threads/run-prompt") => proxy_resp(dock::control_proxy_raw("/ai-threads/run-prompt", Some(body), 120)),+        // Hydrogen 1.0.187+ splits the send in two: the POST returns a job id in well under+        // a second and NEVER fills delivered_to any more, so all progress lives here and is+        // polled once a second. FORWARD THE QUERY STRING: this is called as ?job=<uuid>, and+        // a proxy that forwards only the path answers about no job at all. Without the route+        // every poll 404s and the user gets no progress reporting; the module fails fast and+        // names it, but that is a diagnostic, not a substitute for wiring it.+        (Method::Get, "/ai-threads/run-prompt/status") => proxy_resp(dock::control_proxy_raw(url, None, 5)),         (Method::Get, "/api/say") => json_resp(&json!({"status":"ok","say":dock::say_phrases(),"noun":"lbr"}).to_string()),         // say-strip contract: Send to AI opens a NEW thread and submits (new         // threads only; the resume path stays parked in /api/try).-        (Method::Post, "/api/say-send") => {-            let phrase = serde_json::from_str::<Value>(body).ok()-                .and_then(|v| v.get("phrase").and_then(|p| p.as_str()).map(String::from))-                .unwrap_or_default();-            json_resp(&dock::say_send(&phrase).to_string())-        }         (Method::Post, "/api/try") => {             let phrase = serde_json::from_str::<Value>(body).ok()                 .and_then(|v| v.get("phrase").and_then(|p| p.as_str()).map(String::from))
src/newbie-panel.js+84−7
@@ -112,6 +112,23 @@   var TWO_COL_MIN = 700, PHRASE_BUDGET = 336;   var MIN_API = [1, 0, 142]; +  /* Hydrogen 1.0.187+ answers a send with a JOB and reports progress on+   * GET /ai-threads/run-prompt/status?job=<uuid>. Measured on the VM: tab visible ~0.1s,+   * session id ~2.5s, sent by 3-5s, a trivial answer done ~8s. One poll a second is the+   * right cadence for that shape, and it is an in-memory read on the other end. */+  var PHASE = {+    opening:               'Opening a new AI chat…',+    prefilled:             'Prompt ready…',+    submitted:             'Sent, waiting for the AI…',+    working:               'The AI is working…',+    needs_you:             'The AI needs you. Open the tab.',+    done:                  'Done.',+    submit_failed:         'The prompt is in the tab. Press Enter there to send it.',+    submitted_unidentified:'Sent, but the AI never checked in.',+    submit_unconfirmed:    'Sent, but delivery was not confirmed.',+    still_working:         'The AI is still working. Open the tab.'+  };+   function css() {     if (document.getElementById('anp-css')) return;     var s = document.createElement('style');@@ -174,6 +191,9 @@     if (!toastHost) { toastHost = el('div', 'anp-toasts'); document.body.appendChild(toastHost); }     var t = el('div', 'anp-toast ' + (kind || 'info'), esc(msg));     toastHost.appendChild(t);+    /* retextable, so a long send can narrate its phases in ONE toast instead of+     * stacking six of them on top of each other. */+    t.setText = function (next) { t.innerHTML = esc(next); return t; };     setTimeout(function () { t.remove(); }, ms || (kind === 'err' ? 7000 : 4000));     return t;   }@@ -224,7 +244,7 @@       })       .catch(function () { return { ok: true, version: null, open: true }; });   }-  function send(base, phrase, appName, slug) {+  function send(base, phrase, appName, slug, onProgress) {     return gate(base).then(function (g) {       if (!g.ok) throw new Error('Adom needs updating to send prompts (found ' + g.version + ', needs 1.0.142). Run: adom update');       var ctl = new AbortController(), timer = setTimeout(function () { ctl.abort(); }, 120000);@@ -241,12 +261,55 @@         .then(function (res) {           clearTimeout(timer);           var j = res.j || {};-          /* THREE states, never two. delivered_to is an ARRAY; a thread that opened but did-           * not confirm delivery is NOT a failure, and retrying it double-sends. */+          if (!(res.status >= 200 && res.status < 300))+            throw new Error((j._hints && j._hints.note) || j.error || ('AI threads returned ' + res.status));+          /* PRE-1.0.187 SHAPE, kept because older Hydrogen builds still answer this way.+           * delivered_to is an ARRAY; a thread that opened but did not confirm delivery is+           * NOT a failure, and retrying it double-sends. */           var dt = j.delivered_to, arr = Array.isArray(dt) ? dt : (dt ? [dt] : []);           if (arr.length || (j.inject && j.inject.delivered_in_conversation)) return { state: 'delivered' };-          if (res.status >= 200 && res.status < 300) return { state: 'opened', hint: j._hints && j._hints.note };-          throw new Error((j._hints && j._hints.note) || j.error || ('AI threads returned ' + res.status));+          /* 1.0.187+ returns in well under a second with a JOB, not a delivery. ok:true means+           * the job STARTED. delivered_to is never filled on this path any more, so without+           * the poll below every send would report "delivery not confirmed" forever. */+          if (!j.job) return { state: 'opened', hint: j._hints && j._hints.note };+          var started = Date.now(), missing = false;+          return new Promise(function (resolve) {+            (function tick() {+              /* the deadline is checked in BOTH paths. Checking it only after a successful+               * poll leaves a dashboard whose server stops answering polling forever. */+              function again() {+                if (Date.now() - started > 120000)+                  return resolve({ state: 'opened', hint: 'no confirmation within 120s' });+                setTimeout(tick, 1000);+              }+              fetch(base + '/ai-threads/run-prompt/status?job=' + encodeURIComponent(j.job))+                .then(function (r) {+                  /* THE STATUS ROUTE IS A THIRD PROXY ROUTE. Dashboards proxy /updater/status+                   * and POST /ai-threads/run-prompt only. If this one was never added, every+                   * poll 404s and the user watches "Opening…" for the full 120s before a+                   * hedge, which is worse than the behaviour this replaced. Fail FAST and say+                   * exactly which route is missing instead of hanging. */+                  if (r.status === 404) { missing = true; return null; }+                  return r.json();+                })+                .then(function (st) {+                  if (missing) return resolve({ state: 'opened', phase: 'no_status_route',+                    hint: 'Your AI chat opened. This app does not proxy GET ' ++                          '/ai-threads/run-prompt/status yet, so progress cannot be shown.' });+                  var state = (st.job && st.job.state) || '';+                  if (typeof onProgress === 'function') { try { onProgress(state, st.job); } catch (e) {} }+                  /* working or later IS sent, for the user-facing toast. */+                  if (state === 'working' || state === 'done' || state === 'needs_you' ||+                      state === 'still_working') return resolve({ state: 'delivered', phase: state });+                  if (state === 'submit_failed' || state === 'submitted_unidentified' ||+                      state === 'submit_unconfirmed')+                    return resolve({ state: 'opened', phase: state,+                                     hint: (st.job && (st.job.error || st.job.note)) || state });+                  again();+                })+                .catch(again);+            })();+          });         })         .catch(function (e) {           clearTimeout(timer);@@ -316,7 +379,10 @@         if (sd.disabled) return;         sd.disabled = true;         var pending = toast('Opening a new AI thread…', 'info', 120000);-        send(base, phrase, appName, slug).then(function (r) {+        send(base, phrase, appName, slug, function (state, job) {+          if (PHASE[state]) pending.setText(PHASE[state]);+          if (typeof cfg.onProgress === 'function') { try { cfg.onProgress(state, job); } catch (e) {} }+        }).then(function (r) {           pending.remove();           if (r.state === 'delivered') toast('Sent. Your AI thread has the prompt.', 'ok');           else toast('Thread opened, delivery not confirmed. Check your AI threads before sending again.', 'info', 8000);@@ -605,10 +671,21 @@               real !== 'ERR' && real !== 404 && real !== fake);         });       })+      .then(function () {+        /* THE STATUS ROUTE, added for Hydrogen 1.0.187+. It is a THIRD proxy route and no+         * dashboard had it before 2.22.0, so it is the row most likely to be red. A GET with+         * a job id that cannot exist must still be ANSWERED (any non-404), because a 404 here+         * means the route was never proxied and progress can never be reported. */+        return fetch(base + '/ai-threads/run-prompt/status?job=__anp_probe__')+          .then(function (r) { return r.status; }, function () { return 'ERR'; })+          .then(function (st) {+            add('progress route wired', 'answers (not 404)', String(st), st !== 'ERR' && st !== 404);+          });+      })       .then(function () { return {ok: ok, rows: rows}; });   }    root.AdomNewbiePanel = { mount: mount, toast: toast, selfCheck: selfCheck, copyText: copyText,-                           checkSend: checkSend, version: '2.21.0',+                           checkSend: checkSend, version: '2.22.0',                            TWO_COL_MIN: TWO_COL_MIN, PHRASE_BUDGET: PHRASE_BUDGET }; })(typeof window !== 'undefined' ? window : this);