AI bridge: fix cross-realm reply drop + add diagnostics

read_editor_buffer came back empty in testing: the reply to the chat
iframe was dropped because event.source (the iframe's Window) is from
another JS realm, so dyn_into::<Window>() returned None.

- reply via unchecked_ref::<Window>() (postMessage works cross-realm even
  though instanceof Window does not), plus a DOM-query fallback that posts
  straight to the '#ai-assistant-editor-embed iframe' contentWindow.
- console.log at each step ([cmms-ai-bridge] …); on-page command-line
  error if save_to_bytes fails.
- Cargo.toml: web-sys HtmlIFrameElement.
This commit is contained in:
AI Assistant 2026-09-03 13:04:43 +10:00
commit 89e014afa1
3 changed files with 72 additions and 35 deletions

View file

@ -92,6 +92,7 @@ web-sys = { version = "=0.3.85", features = [
"Document",
"Element",
"HtmlAnchorElement",
"HtmlIFrameElement",
"Blob",
"File",
"FileSystemDirectoryHandle",

View file

@ -877,19 +877,26 @@ impl OpenCADStudio {
// 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| {
let dxf = match self.tabs.get(self.active_tab).map(|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();
}) {
Some(Ok(bytes)) => String::from_utf8_lossy(&bytes).into_owned(),
Some(Err(e)) => {
self.command_line.push_error(
crate::tf!("AI Assistant: could not read the drawing: {e}").as_ref(),
);
String::new()
}
None => String::new(),
};
web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
"[cmms-ai-bridge] poll: want_read, replying {} bytes DXF",
dxf.len()
)));
crate::sys::reply_ai_editor_buffer(&dxf);
}
if let Some(dxf) = apply {

View file

@ -560,7 +560,11 @@ mod ai_editor_bridge {
struct State {
listener: Option<Closure<dyn FnMut(web_sys::MessageEvent)>>,
reply_to: Option<web_sys::Window>,
// The chat iframe's Window, kept as a raw JsValue: it comes from
// another realm (the <iframe>), so `instanceof Window` / `dyn_into`
// would fail - we only ever call `.postMessage()` on it, which works
// cross-realm.
reply_to: Option<JsValue>,
want_read: bool,
pending_apply: Option<String>,
}
@ -574,6 +578,10 @@ mod ai_editor_bridge {
}) };
}
fn log(msg: &str) {
web_sys::console::log_1(&JsValue::from_str(&format!("[cmms-ai-bridge] {msg}")));
}
fn str_field(obj: &JsValue, key: &str) -> Option<String> {
js_sys::Reflect::get(obj, &JsValue::from_str(key))
.ok()?
@ -601,7 +609,8 @@ mod ai_editor_bridge {
&JsValue::from_str("source"),
)
.ok()
.and_then(|s| s.dyn_into::<web_sys::Window>().ok());
.filter(|s| !s.is_null() && !s.is_undefined());
log(&format!("get-buffer received (source={})", source.is_some()));
STATE.with(|state| {
let mut state = state.borrow_mut();
state.reply_to = source;
@ -613,6 +622,7 @@ mod ai_editor_bridge {
return;
}
if let Some(new_source) = str_field(&data, "new_source") {
log(&format!("apply received ({} bytes)", new_source.len()));
STATE.with(|state| {
state.borrow_mut().pending_apply = Some(new_source);
});
@ -625,6 +635,7 @@ mod ai_editor_bridge {
.add_event_listener_with_callback("message", cb.as_ref().unchecked_ref());
state.listener = Some(cb);
});
log("message listener installed");
}
/// Drain any queued bridge requests: `(a read was asked for, a buffer to apply)`.
@ -639,11 +650,9 @@ mod ai_editor_bridge {
}
/// Post the current drawing (ASCII DXF) back to the chat iframe that asked.
/// Also broadcasts to the embedded chat iframe found in the DOM, in case
/// `event.source` was unusable.
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,
@ -658,8 +667,28 @@ mod ai_editor_bridge {
let origin = web_sys::window()
.and_then(|w| w.location().origin().ok())
.unwrap_or_else(|| "*".to_string());
let _ = target.post_message(&message, &origin);
});
let mut posted = false;
if let Some(target) = STATE.with(|state| state.borrow_mut().reply_to.take()) {
if target
.unchecked_ref::<web_sys::Window>()
.post_message(&message, &origin)
.is_ok()
{
posted = true;
}
}
// Fallback: post straight to the embedded chat iframe's contentWindow.
if let Some(frame_win) = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.query_selector("#ai-assistant-editor-embed iframe").ok().flatten())
.and_then(|el| el.dyn_into::<web_sys::HtmlIFrameElement>().ok())
.and_then(|f| f.content_window())
{
let _ = frame_win.post_message(&message, &origin);
posted = true;
}
log(&format!("reply_buffer: {} bytes, delivered={}", dxf.len(), posted));
}
}