CMMS embedding: AI Assistant editor-buffer bridge (read/replace the open drawing)

The CMMS AI Assistant's Stage 3 editor-buffer bridge already covers the
circuit / drawio / OpenSCAD editors; this adds the DXF/DWG one.

- src/sys.rs: ai_editor_bridge module - a window 'message' listener
  (installed from boot_web only when cmms_config() is present) that parks
  a 'cmms-ai-editor-get-buffer' / 'cmms-ai-editor-apply' request in a
  thread-local. The listener can't reach app state, so:
- src/app/view/mod.rs: a 250ms CmmsAiBridgePoll subscription (embedded only)
- src/app/update/mod.rs: the poll handler - reply with the active drawing
  serialized as ASCII DXF (save_to_bytes), or replace the active tab's
  document in place with an applied buffer (reset the tab to blank so
  on_file_opened reuses the slot, then load through the standard pipeline).
- src/io/mod.rs: load_ai_buffer_web() - load_from_cmms_web minus the fetch,
  always DXF.

Additive, wasm-only, gated on window.CAD_EDITOR_CONFIG like every other
CMMS hook. Standalone use unaffected. cargo check --target wasm32 clean.
This commit is contained in:
AI Assistant 2026-09-03 12:08:50 +10:00
commit 8908a20902
5 changed files with 259 additions and 0 deletions

View file

@ -1850,6 +1850,12 @@ pub enum Message {
/// to the host page instead of triggering a browser download.
#[cfg(target_arch = "wasm32")]
CmmsSaveResult(Result<(), String>),
/// CMMS embedding (not upstream): timer tick that drains the AI Assistant
/// editor-buffer bridge queue — a `read_editor_buffer` request (reply with
/// the drawing as DXF) or a `write_editor_buffer` apply (replace the active
/// tab's drawing). See `crate::sys::ai_editor_bridge`.
#[cfg(target_arch = "wasm32")]
CmmsAiBridgePoll,
/// CMMS embedding (not upstream): result of "Export to CAM" - POSTing a
/// freshly-built STL as a new attachment and navigating to Kiri:Moto.
/// A successful result already navigated away, so this only ever
@ -3894,6 +3900,15 @@ impl OpenCADStudio {
Task::none()
};
// CMMS AI Assistant editor-buffer bridge (not upstream): let the
// embedded chat read the drawing open here and push a revised version
// back over postMessage. The listener parks requests in a thread-local
// that the `CmmsAiBridgePoll` subscription drains. Standalone builds
// (no config) skip it entirely.
if crate::sys::cmms_config().is_some() {
crate::sys::install_ai_editor_bridge();
}
(
s,
Task::batch([focus, fonts, patrons, videos, discussions, thumbs_fetch, cmms_load]),

View file

@ -867,6 +867,66 @@ impl OpenCADStudio {
Task::none()
}
// CMMS AI Assistant editor-buffer bridge (not upstream). The
// `message` listener (crate::sys::ai_editor_bridge) parks a request;
// this timer-driven arm is where it actually touches the document.
#[cfg(target_arch = "wasm32")]
Message::CmmsAiBridgePoll => {
let (want_read, apply) = crate::sys::take_ai_editor_bridge_requests();
if want_read {
// Reply with the active drawing as ASCII DXF (the editor's
// export format regardless of the on-disk format). An empty
// reply tells the CMMS read tool the buffer isn't ready.
let dxf = self
.tabs
.get(self.active_tab)
.and_then(|tab| {
crate::io::save_to_bytes(
&tab.scene.document,
"dxf",
tab.scene.document.version,
)
.ok()
})
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
.unwrap_or_default();
crate::sys::reply_ai_editor_buffer(&dxf);
}
if let Some(dxf) = apply {
// Replace the drawing in the active tab in place (the user
// picked this over a new tab). Resetting the tab to blank
// first makes on_file_opened's "current tab is empty" path
// reuse this slot; the pre-apply undo history goes with the
// old state and the user reverts by not Saving.
let i = self.active_tab;
self.tab_counter += 1;
self.tabs[i] = super::document::DocumentTab::new_drawing(self.tab_counter);
self.active_tab = i;
self.apply_bg_default(i);
let state = std::sync::Arc::new(crate::io::OpenProgressState::new(
crate::app::OPEN_PHASE_READING,
));
let open_id = self.next_open_id();
self.opening = Some(crate::app::OpenProgress {
id: open_id,
name: "Applying AI edit…".into(),
source_path: None,
size_bytes: 0,
state: state.clone(),
started: Instant::now(),
recovery_error: None,
recovery_read_stats: None,
recovery_bytes: None,
});
return Task::perform(
crate::io::load_ai_buffer_web(dxf, state),
move |outcome| Message::WebFileOpened(open_id, outcome),
);
}
Task::none()
}
// A success already navigated the browser to the CAM page
// (post_stl_export_to_host does this itself once the save
// response comes back) - only a failure is ever actually seen

View file

@ -2148,6 +2148,19 @@ impl OpenCADStudio {
iced::time::every(std::time::Duration::from_millis(300)).map(|_| Message::PollWebFonts);
#[cfg(not(target_arch = "wasm32"))]
let web_fonts = Subscription::none();
// CMMS embedding (not upstream): drain the AI Assistant editor-buffer
// bridge queue while embedded. The `message` listener can't reach app
// state, so a light timer polls for a queued get-buffer / apply
// request. Only runs inside cad-edit.php (config present).
#[cfg(target_arch = "wasm32")]
let cmms_ai_bridge = if crate::sys::cmms_config().is_some() {
iced::time::every(std::time::Duration::from_millis(250))
.map(|_| Message::CmmsAiBridgePoll)
} else {
Subscription::none()
};
#[cfg(not(target_arch = "wasm32"))]
let cmms_ai_bridge = Subscription::none();
// Periodic autosave to a `.sv$` recovery file (native only). SAVETIME is
// the interval in minutes; 0 disables it.
#[cfg(not(target_arch = "wasm32"))]
@ -2194,6 +2207,7 @@ impl OpenCADStudio {
thumbnail_capture,
caret_blink,
web_fonts,
cmms_ai_bridge,
autosave,
plugin_drain,
single_instance,

View file

@ -580,6 +580,40 @@ pub async fn load_from_cmms_web(progress: Arc<OpenProgressState>) -> Option<WebO
})
}
/// CMMS AI Assistant editor-buffer bridge (not upstream): parse an ASCII DXF
/// string the assistant produced (handed over via postMessage, already
/// structurally validated by the CMMS server) into the active tab. Mirrors
/// `load_from_cmms_web` but takes the bytes directly rather than fetching a
/// URL, and always treats them as DXF - the assistant only ever writes DXF,
/// whatever the file on disk is.
#[cfg(target_arch = "wasm32")]
pub async fn load_ai_buffer_web(
source: String,
progress: Arc<OpenProgressState>,
) -> WebOpenOutcome {
progress.set(crate::app::OPEN_PHASE_READING, 500, 1, 2);
let name = crate::sys::cmms_config()
.and_then(|cfg| cfg.original_name)
.and_then(|n| {
std::path::Path::new(&n)
.file_stem()
.map(|stem| format!("{}.dxf", stem.to_string_lossy()))
})
.unwrap_or_else(|| "drawing.dxf".to_string());
let bytes: Arc<[u8]> = Arc::from(source.into_bytes());
let size_bytes = bytes.len() as u64;
let result = load_web_bytes(&name, &bytes, progress.clone(), false, "", None).await;
let cache_bytes = result.is_ok().then(|| Arc::clone(&bytes));
WebOpenOutcome {
name,
size_bytes,
result,
recovery_bytes: None,
cache_bytes,
record_recent: false,
}
}
/// Reopen a browser-private recent copy without showing the file picker.
#[cfg(target_arch = "wasm32")]
pub async fn open_recent_web(

View file

@ -533,6 +533,142 @@ pub async fn post_stl_export_to_host(bytes: &[u8], filename: &str) -> Result<(),
Ok(())
}
// --- CMMS AI Assistant editor-buffer bridge (not upstream) -----------------
//
// The CMMS AI Assistant embeds a chat panel (an <iframe>) into edit.php. Its
// "Stage 3 editor-buffer bridge" lets the assistant read the drawing currently
// open in this editor and push a revised version back for the user to review
// and Save, over `window.postMessage`:
//
// * chat iframe -> parent window: {type:"cmms-ai-editor-get-buffer"}
// we reply to the iframe: {type:"cmms-ai-editor-buffer", buffer:<DXF>}
// * chat iframe -> parent window: {type:"cmms-ai-editor-apply", editor:"cad",
// new_source:<DXF>}
//
// This editor's WASM app *is* the parent window (index.html is the page; the
// chat is the nested frame), so it handles these messages itself - no glue JS
// in edit.php. A `message` listener parks each request in a thread-local; the
// iced event loop drains it on a short timer (`Message::CmmsAiBridgePoll`)
// because the listener can't touch app state. Installed from `boot_web` only
// when `cmms_config()` is present, so standalone builds get nothing.
#[cfg(target_arch = "wasm32")]
mod ai_editor_bridge {
use std::cell::RefCell;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::{JsCast, JsValue};
struct State {
listener: Option<Closure<dyn FnMut(web_sys::MessageEvent)>>,
reply_to: Option<web_sys::Window>,
want_read: bool,
pending_apply: Option<String>,
}
thread_local! {
static STATE: RefCell<State> = const { RefCell::new(State {
listener: None,
reply_to: None,
want_read: false,
pending_apply: None,
}) };
}
fn str_field(obj: &JsValue, key: &str) -> Option<String> {
js_sys::Reflect::get(obj, &JsValue::from_str(key))
.ok()?
.as_string()
}
pub fn install() {
let Some(window) = web_sys::window() else {
return;
};
STATE.with(|state| {
let mut state = state.borrow_mut();
if state.listener.is_some() {
return;
}
let cb = Closure::wrap(Box::new(move |event: web_sys::MessageEvent| {
let data = event.data();
let Some(kind) = str_field(&data, "type") else {
return;
};
match kind.as_str() {
"cmms-ai-editor-get-buffer" => {
let source = js_sys::Reflect::get(
event.as_ref(),
&JsValue::from_str("source"),
)
.ok()
.and_then(|s| s.dyn_into::<web_sys::Window>().ok());
STATE.with(|state| {
let mut state = state.borrow_mut();
state.reply_to = source;
state.want_read = true;
});
}
"cmms-ai-editor-apply" => {
if str_field(&data, "editor").as_deref() != Some("cad") {
return;
}
if let Some(new_source) = str_field(&data, "new_source") {
STATE.with(|state| {
state.borrow_mut().pending_apply = Some(new_source);
});
}
}
_ => {}
}
}) as Box<dyn FnMut(web_sys::MessageEvent)>);
let _ = window
.add_event_listener_with_callback("message", cb.as_ref().unchecked_ref());
state.listener = Some(cb);
});
}
/// Drain any queued bridge requests: `(a read was asked for, a buffer to apply)`.
pub fn take() -> (bool, Option<String>) {
STATE.with(|state| {
let mut state = state.borrow_mut();
(
std::mem::take(&mut state.want_read),
state.pending_apply.take(),
)
})
}
/// Post the current drawing (ASCII DXF) back to the chat iframe that asked.
pub fn reply_buffer(dxf: &str) {
STATE.with(|state| {
let Some(target) = state.borrow_mut().reply_to.take() else {
return;
};
let message = js_sys::Object::new();
let _ = js_sys::Reflect::set(
&message,
&JsValue::from_str("type"),
&JsValue::from_str("cmms-ai-editor-buffer"),
);
let _ = js_sys::Reflect::set(
&message,
&JsValue::from_str("buffer"),
&JsValue::from_str(dxf),
);
let origin = web_sys::window()
.and_then(|w| w.location().origin().ok())
.unwrap_or_else(|| "*".to_string());
let _ = target.post_message(&message, &origin);
});
}
}
#[cfg(target_arch = "wasm32")]
pub use ai_editor_bridge::{
install as install_ai_editor_bridge, reply_buffer as reply_ai_editor_buffer,
take as take_ai_editor_bridge_requests,
};
/// Short platform string for bug reports: OS + architecture on the desktop,
/// the browser user-agent on the web.
#[cfg(not(target_arch = "wasm32"))]