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.
|
/// to the host page instead of triggering a browser download.
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
CmmsSaveResult(Result<(), String>),
|
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
|
/// CMMS embedding (not upstream): result of "Export to CAM" - POSTing a
|
||||||
/// freshly-built STL as a new attachment and navigating to Kiri:Moto.
|
/// freshly-built STL as a new attachment and navigating to Kiri:Moto.
|
||||||
/// A successful result already navigated away, so this only ever
|
/// A successful result already navigated away, so this only ever
|
||||||
|
|
@ -3894,6 +3900,15 @@ impl OpenCADStudio {
|
||||||
Task::none()
|
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,
|
s,
|
||||||
Task::batch([focus, fonts, patrons, videos, discussions, thumbs_fetch, cmms_load]),
|
Task::batch([focus, fonts, patrons, videos, discussions, thumbs_fetch, cmms_load]),
|
||||||
|
|
|
||||||
|
|
@ -867,6 +867,66 @@ impl OpenCADStudio {
|
||||||
Task::none()
|
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
|
// A success already navigated the browser to the CAM page
|
||||||
// (post_stl_export_to_host does this itself once the save
|
// (post_stl_export_to_host does this itself once the save
|
||||||
// response comes back) - only a failure is ever actually seen
|
// 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);
|
iced::time::every(std::time::Duration::from_millis(300)).map(|_| Message::PollWebFonts);
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
let web_fonts = Subscription::none();
|
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
|
// Periodic autosave to a `.sv$` recovery file (native only). SAVETIME is
|
||||||
// the interval in minutes; 0 disables it.
|
// the interval in minutes; 0 disables it.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
|
@ -2194,6 +2207,7 @@ impl OpenCADStudio {
|
||||||
thumbnail_capture,
|
thumbnail_capture,
|
||||||
caret_blink,
|
caret_blink,
|
||||||
web_fonts,
|
web_fonts,
|
||||||
|
cmms_ai_bridge,
|
||||||
autosave,
|
autosave,
|
||||||
plugin_drain,
|
plugin_drain,
|
||||||
single_instance,
|
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.
|
/// Reopen a browser-private recent copy without showing the file picker.
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub async fn open_recent_web(
|
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(())
|
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,
|
/// Short platform string for bug reports: OS + architecture on the desktop,
|
||||||
/// the browser user-agent on the web.
|
/// the browser user-agent on the web.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue