diff --git a/apps/wordcraft-web/src/api.rs b/apps/wordcraft-web/src/api.rs new file mode 100644 index 0000000..9450a0c --- /dev/null +++ b/apps/wordcraft-web/src/api.rs @@ -0,0 +1,119 @@ +//! Community web command bridge for wordcraft.run (2026-10-17). +//! Uses the existing UI control queue; no network listener or file-upload service. +use serde_json::{Value, json}; +use std::cell::RefCell; +use std::sync::mpsc::{Receiver, Sender, TryRecvError}; +use wasm_bindgen::prelude::*; +use wordcraft_ui_egui::ControlRequest; + +type Waiting = (Receiver, js_sys::Function, js_sys::Function); +thread_local! { + static TX: RefCell>> = const { RefCell::new(None) }; + static WAITING: RefCell> = const { RefCell::new(Vec::new()) }; + static CTX: RefCell> = const { RefCell::new(None) }; +} + +fn from_js(v: &JsValue) -> Result { + if v.is_null() || v.is_undefined() { + return Ok(json!({})); + } + let text = js_sys::JSON::stringify(v)?.as_string().ok_or_else(|| js_sys::Error::new("parameters must be JSON"))?; + if text.len() > 1_048_576 { + return Err(js_sys::Error::new("request exceeds 1 MiB").into()); + } + let value: Value = serde_json::from_str(&text).map_err(|e| js_sys::Error::new(&e.to_string()))?; + if !value.is_object() { + return Err(js_sys::Error::new("parameters must be an object").into()); + } + Ok(value) +} + +fn request(method: String, params: Value) -> js_sys::Promise { + if !matches!(method.as_str(), "engine.execute" | "agent.execute" | "engine.commands" | "agent.state") { + return js_sys::Promise::reject(&js_sys::Error::new("unsupported web method")); + } + if WAITING.with(|w| w.borrow().len() >= 32) { + return js_sys::Promise::reject(&js_sys::Error::new("too many pending requests")); + } + let promise = js_sys::Promise::new(&mut |resolve, reject| { + let (req, rx) = ControlRequest::new(method.clone(), params.clone()); + if TX.with(|t| t.borrow().as_ref().is_some_and(|tx| tx.send(req).is_ok())) { + WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject))); + } else { + let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("WordCraft is not ready")); + } + }); + CTX.with(|c| { + if let Some(ctx) = c.borrow().as_ref() { + ctx.request_repaint(); + } + }); + promise +} + +pub fn poll_replies() { + WAITING.with(|w| { + w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() { + Ok(v) => { + if v.get("ok").and_then(Value::as_bool) == Some(true) { + match js_sys::JSON::parse(&v.get("result").unwrap_or(&Value::Null).to_string()) { + Ok(value) => { + let _ = resolve.call1(&JsValue::NULL, &value); + } + Err(error) => { + let _ = reject.call1(&JsValue::NULL, &error); + } + } + } else { + let error = js_sys::Error::new(v.get("error").and_then(Value::as_str).unwrap_or("command failed")); + let _ = reject.call1(&JsValue::NULL, &error); + } + false + } + Err(TryRecvError::Empty) => true, + Err(TryRecvError::Disconnected) => { + let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("editor closed")); + false + } + }) + }); +} + +pub fn install(ctx: egui::Context) -> Result, String> { + let (tx, rx) = std::sync::mpsc::channel(); + TX.with(|t| *t.borrow_mut() = Some(tx)); + CTX.with(|c| *c.borrow_mut() = Some(ctx)); + let obj = js_sys::Object::new(); + let set = |name: &str, value: JsValue| js_sys::Reflect::set(&obj, &name.into(), &value).map_err(|_| format!("could not install {name}")); + set("apiVersion", "craft-online-1".into())?; + set( + "request", + Closure:: js_sys::Promise>::new(|method: String, params: JsValue| match from_js(¶ms) { + Ok(p) => request(method, p), + Err(e) => js_sys::Promise::reject(&e), + }) + .into_js_value(), + )?; + set( + "execute", + Closure:: js_sys::Promise>::new(|command: String, params: JsValue, expected: JsValue| { + let parsed = from_js(¶ms).and_then(|p| { + let mut value = json!({"command": command, "params": p}); + if !expected.is_undefined() && !expected.is_null() { + value["expected"] = from_js(&expected)?; + } + Ok(value) + }); + match parsed { + Ok(p) => request(if p.get("expected").is_some() { "agent.execute" } else { "engine.execute" }.into(), p), + Err(e) => js_sys::Promise::reject(&e), + } + }) + .into_js_value(), + )?; + set("commands", Closure:: js_sys::Promise>::new(|| request("engine.commands".into(), json!({}))).into_js_value())?; + set("inspect", Closure:: js_sys::Promise>::new(|| request("agent.state".into(), json!({}))).into_js_value())?; + let win = web_sys::window().ok_or("no window")?; + js_sys::Reflect::set(&win, &"wordcraft".into(), &obj).map_err(|_| "could not install window.wordcraft")?; + Ok(rx) +} diff --git a/apps/wordcraft-web/src/main.rs b/apps/wordcraft-web/src/main.rs index 2f4283d..635e3b7 100644 --- a/apps/wordcraft-web/src/main.rs +++ b/apps/wordcraft-web/src/main.rs @@ -9,6 +9,8 @@ //! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(target_arch = "wasm32")] +mod api; #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index f6ccc77..8a1c77b 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -58,7 +58,8 @@ pub fn start() { let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(®ion) }; let mut session = Session::new(doc); session.system_region = region; - let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone())); + let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone())) + .with_control(crate::api::install(cc.egui_ctx.clone())?); app.autosave = false; // No document to show: start on File › Home, as the desktop app does (#766). if !sample { @@ -139,6 +140,7 @@ impl eframe::App for WebShell { } self.settle_picker_saves(); self.app.logic(ctx); + crate::api::poll_replies(); } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8094eeb..6d43342 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -108,11 +108,116 @@ fn click_events(app: &mut WordApp, pos: egui::Pos2, button: egui::PointerButton, /// The reply to a request that needs a document while none is open (#1129). const NO_DOCUMENT: &str = "no document is open; create one (file.new) or open one (file.open) first"; +// Community bridge: explicit body paragraphs, guarded on the same UI thread as the edit. +fn agent_token(app: &WordApp) -> Result { + if !app.has_document() { + return Err(NO_DOCUMENT.into()); + } + Ok(json!({"uid":app.session.document_id().to_string(),"revision":app.session.rev().to_string()})) +} + +fn agent_state(app: &WordApp, p: &Value) -> Result { + let token = agent_token(app)?; + let start = p.get("start").and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).unwrap_or(0); + let count = p.get("count").and_then(Value::as_u64).unwrap_or(40).clamp(1, 40) as usize; + let blocks: Vec = app.session.doc.body.iter().enumerate().skip(start).take(count).map(|(index,b)| { + if let Some(para) = b.as_para() { + let editable = para.objects.is_empty() && para.text.len() <= 20000 + && !app.session.doc.settings.track_changes + && !para.run_ranges().any(|(_,c)| c.ins.is_some() || c.del.is_some() || c.fmt_change.is_some()) + && para.mark.ins.is_none() && para.mark.del.is_none() && para.props.fmt_change.is_none(); + let text = if para.text.len() <= 20000 { para.text.clone() } else { para.text.chars().take(2000).collect() }; + let runs: Vec = para.run_ranges().take(50).map(|(range,props)| json!({"start":range.start,"end":range.end,"props":props})).collect(); + json!({"index":index,"type":"paragraph","text":text,"editable":editable,"truncated":para.text.len()>20000,"style":para.props.style,"runs":runs}) + } else { json!({"index":index,"type":"table","editable":false}) } + }).collect(); + Ok( + json!({"token":token,"document":{"blocks":blocks,"start":start,"totalBlocks":app.session.doc.body.len(),"canUndo":app.session.can_undo(),"canRedo":app.session.can_redo(),"undoDepth":app.session.undo_depth()}}), + ) +} + +fn agent_edit(app: &mut WordApp, p: &Value) -> Result { + let command = p.get("command").and_then(Value::as_str).ok_or("missing command")?; + let params = p.get("params").filter(|v| v.is_object()).ok_or("params must be an object")?; + if command == "edit.undo" { + let result = app.execute("edit.undo", json!({}))?; + return Ok(json!({"result":result,"state":agent_state(app, &json!({}))?})); + } + if !matches!(command, "replace_paragraph" | "format_paragraph") { + return Err("unsupported Agent command".into()); + } + let index = params.get("block").and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).ok_or("block must be a non-negative integer")?; + let para = app.session.doc.body.get(index).and_then(|b| b.as_para()).ok_or("not a body paragraph")?; + if !para.objects.is_empty() + || para.text.len() > 20000 + || app.session.doc.settings.track_changes + || para.run_ranges().any(|(_, c)| c.ins.is_some() || c.del.is_some() || c.fmt_change.is_some()) + || para.mark.ins.is_some() + || para.mark.del.is_some() + || para.props.fmt_change.is_some() + { + return Err("paragraph has embedded objects, tracked changes, or is too large for this Agent".into()); + } + if params.get("expected_text").and_then(Value::as_str) != Some(para.text.as_str()) { + return Err("paragraph text changed; read the document again".into()); + } + let len = para.text.len(); + let (id, args) = if command == "replace_paragraph" { + let text = params.get("text").and_then(Value::as_str).ok_or("text must be a string")?; + if text.len() > 20000 { + return Err("replacement exceeds 20000 UTF-8 bytes".into()); + } + if text == para.text { + return Err("replacement is unchanged".into()); + } + if text.is_empty() { ("text.backspace", json!({})) } else { ("edit.pasteText", json!({"text":text})) } + } else { + if len == 0 { + return Err("format a non-empty paragraph".into()); + } + let id = match params.get("style").and_then(Value::as_str) { + Some("bold") => "format.bold", + Some("italic") => "format.italic", + Some("underline") => "format.underline", + _ => return Err("unsupported formatting style".into()), + }; + let value = params.get("enabled").and_then(Value::as_bool).ok_or("enabled must be boolean")?; + (id, json!({"value":value})) + }; + let previous = app.session.sel.clone(); + app.session.close_typing(); + app.execute("select.range", json!({"anchor":{"block":index,"off":0},"focus":{"block":index,"off":len}}))?; + let result = app.execute(id, args); + app.session.close_typing(); + if result.is_err() { + app.session.sel = previous; + } + Ok(json!({"result":result?,"state":agent_state(app,&json!({"start":index}))?})) +} + pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; + if let Some(expected) = p.get("expected") + && agent_token(app).as_ref().ok() != Some(expected) + { + return err("document changed since inspection; start a new request"); + } let s = |k: &str| p.get(k).and_then(Value::as_str); let f = |k: &str| p.get(k).and_then(Value::as_f64).map(|v| v as f32); match req.method.as_str() { + "agent.state" => match agent_state(app, p) { + Ok(v) => ok(v), + Err(e) => err(e), + }, + "agent.execute" => { + if p.get("expected").is_none() { + return err("expected document state required"); + } + match agent_edit(app, p) { + Ok(v) => ok(v), + Err(e) => err(e), + } + } "engine.execute" | "command" => { let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})); @@ -306,3 +411,35 @@ pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value { } json!({"ok": true, "result": {"path": path, "width": w, "height": h}}) } + +#[cfg(test)] +mod community_agent_tests { + use super::*; + #[test] + fn edits_unicode_paragraph_undoes_and_rejects_stale_state() { + let mut app = + WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Hello 中文 \u{1f431}")), crate::Services::default()); + let token = agent_token(&app).unwrap(); + let req = ControlRequest::new( + "agent.execute", + json!({"command":"replace_paragraph","params":{"block":0,"expected_text":"Hello 中文 \u{1f431}","text":"Revised 中文 \u{1f436}"},"expected":token}), + ) + .0; + let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), &req) else { panic!("immediate reply") }; + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(reply["result"]["state"]["document"]["blocks"][0]["text"], "Revised 中文 \u{1f436}"); + let Outcome::Done(stale) = handle(&mut app, &egui::Context::default(), &req) else { panic!("immediate reply") }; + assert_eq!(stale["ok"], false); + agent_edit(&mut app, &json!({"command":"edit.undo","params":{}})).unwrap(); + assert_eq!(agent_state(&app, &json!({})).unwrap()["document"]["blocks"][0]["text"], "Hello 中文 \u{1f431}"); + agent_edit(&mut app, &json!({"command":"replace_paragraph","params":{"block":0,"expected_text":"Hello 中文 \u{1f431}","text":""}})).unwrap(); + assert_eq!(agent_state(&app, &json!({})).unwrap()["document"]["blocks"][0]["text"], ""); + agent_edit(&mut app, &json!({"command":"edit.undo","params":{}})).unwrap(); + assert_eq!(agent_state(&app, &json!({})).unwrap()["document"]["blocks"][0]["text"], "Hello 中文 \u{1f431}"); + app.execute("file.new", json!({})).unwrap(); + assert_ne!(agent_token(&app).unwrap(), token); + app.session.doc.settings.track_changes = true; + assert_eq!(agent_state(&app, &json!({})).unwrap()["document"]["blocks"][0]["editable"], false); + assert!(agent_edit(&mut app, &json!({"command":"replace_paragraph","params":{"block":0,"expected_text":"","text":"blocked"}})).is_err()); + } +}