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:
parent
c38bd6e8f5
commit
8908a20902
5 changed files with 259 additions and 0 deletions
|
|
@ -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]),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
136
src/sys.rs
136
src/sys.rs
|
|
@ -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"))]
|
||||
|
|
|
|||
Loading…
Reference in a new issue