oxedyne/daimond/src/wasm/doc.rs
6.5 KiB, 1 run
created by r2519314175:979, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! The document panel's edge — a thin binding to the JS driver `window.DaimondDoc`. |
| 2 | //! |
| 3 | //! WHY THIS MODULE EXISTS, WHICH IS NOT THE SAME AS WHAT IT DOES. Asked to compile a Typst |
| 4 | //! source and display the PDF, a daimon answered: |
| 5 | //! |
| 6 | //! > I do see a pre-existing thinking.pdf (1.08 MB) ... but I cannot display a PDF inline here |
| 7 | //! > — the file tools return raw bytes for it rather than a rendered view. |
| 8 | //! |
| 9 | //! Every clause of that is true about its TOOLBOX and false about Daimond. `www/js/viewer.js` |
| 10 | //! has handed `application/pdf` to the browser's own document viewer since the `doc` tier was |
| 11 | //! written, with the security argument for doing so measured and recorded beside it. What was |
| 12 | //! missing was a way for the model to SAY SO: it held eleven tools that return bytes and none |
| 13 | //! that put a file in front of a person, so it reasoned from the toolbox to a limitation of the |
| 14 | //! app, reported that limitation to the user, and spent the turn apologising for it. |
| 15 | //! |
| 16 | //! A working surface the model cannot reach is a surface the model will deny. |
| 17 | //! |
| 18 | //! **The driver is handed a PATH and never bytes.** A view given content is a snapshot: when the |
| 19 | //! file is written again -- a recompile, a rebuild -- there is nowhere for the new bytes to land, |
| 20 | //! and the only way to refresh it is to build a second view. A view that names a workspace file |
| 21 | //! reads that file whenever it is drawn, so showing the same path again is the whole of how a |
| 22 | //! rebuilt document reaches the reader. |
| 23 | //! |
| 24 | //! The driver resolves with `viewer.js`'s own verdict about what it drew. Composing that |
| 25 | //! sentence from a table here instead would put a second opinion about which formats have a |
| 26 | //! viewer into a second language, and the two would disagree the first time a format moved tier. |
| 27 | |
| 28 | use crate::llm::{ |
| 29 | extract_json_bool, |
| 30 | extract_json_number, |
| 31 | extract_json_string, |
| 32 | }; |
| 33 | use crate::tools::Shown; |
| 34 | use crate::wasm::js_str; |
| 35 | |
| 36 | use oxedyne_fe2o3_core::prelude::*; |
| 37 | |
| 38 | use wasm_bindgen::prelude::wasm_bindgen; |
| 39 | use wasm_bindgen::{JsCast, JsValue}; |
| 40 | use wasm_bindgen_futures::JsFuture; |
| 41 | |
| 42 | |
| 43 | #[wasm_bindgen] |
| 44 | extern "C" { |
| 45 | |
| 46 | /// The driver object `www/js/viewer.js` installs at `window.DaimondDoc`. |
| 47 | #[wasm_bindgen(js_name = DaimondDoc)] |
| 48 | type Panel; |
| 49 | |
| 50 | /// Put a workspace file in the document panel, at `page` for a paged format. |
| 51 | /// |
| 52 | /// `page` is a `JsValue` so it can be `undefined`, which is the driver's word for "the top" |
| 53 | /// and is not the same as page 1 asked for deliberately. |
| 54 | /// |
| 55 | /// `owner` names the conversation asking, so the driver can decline to take a screen that |
| 56 | /// belongs to another one. An empty string means the caller could not say, and the driver |
| 57 | /// reads that as the user's own act. |
| 58 | #[wasm_bindgen(method)] |
| 59 | fn show(this: &Panel, path: &str, page: JsValue, owner: &str) -> js_sys::Promise; |
| 60 | } |
| 61 | |
| 62 | |
| 63 | /// Reach the driver object on `window`, or refuse in the model's language. |
| 64 | fn panel() -> Outcome<Panel> { |
| 65 | let win = res!(web_sys::window() |
| 66 | .ok_or_else(|| err!("Showing a file needs a browser window."; System, Missing))); |
| 67 | let obj = res!(js_sys::Reflect::get(&win, &JsValue::from_str("DaimondDoc")) |
| 68 | .map_err(|e| err!("Reading window.DaimondDoc failed: {}.", js_str(&e); System, Missing))); |
| 69 | if obj.is_undefined() || obj.is_null() { |
| 70 | return Err(err!( |
| 71 | "Daimond's document panel is not loaded in this page, so there is nothing to show a \ |
| 72 | file in. Tell the user, and describe what you would have shown them instead."; |
| 73 | System, Missing)); |
| 74 | } |
| 75 | Ok(obj.unchecked_into::<Panel>()) |
| 76 | } |
| 77 | |
| 78 | /// The `message` of a rejected JS `Error`, verbatim. A refusal from the driver is written for |
| 79 | /// the model to read and act on, so nothing here rewords it. |
| 80 | fn refusal(e: &JsValue) -> String { |
| 81 | match js_sys::Reflect::get(e, &JsValue::from_str("message")) { |
| 82 | Ok(m) => m.as_string().unwrap_or_else(|| js_str(e)), |
| 83 | Err(_) => js_str(e), |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | /// Show `path` in the document panel, and report what the user is now looking at. |
| 88 | /// |
| 89 | /// # Arguments |
| 90 | /// * `path` - The workspace-relative path, already scoped and already checked by the guard. |
| 91 | /// * `page` - Which page to open a paged document at, or `None` for the top. |
| 92 | /// * `owner` - The Diamond whose daimon is asking, or empty for a chat or the user. |
| 93 | pub async fn show(path: &str, page: Option<u32>, owner: &str) -> Outcome<Shown> { |
| 94 | let p = res!(panel()); |
| 95 | let at = match page { |
| 96 | Some(n) => JsValue::from_f64(n as f64), |
| 97 | None => JsValue::UNDEFINED, |
| 98 | }; |
| 99 | let v = match JsFuture::from(p.show(path, at, owner)).await { |
| 100 | Ok(v) => v, |
| 101 | Err(e) => return Err(err!("{}", refusal(&e); IO, Invalid)), |
| 102 | }; |
| 103 | let json = match v.as_string() { |
| 104 | Some(s) => s, |
| 105 | None => match js_sys::JSON::stringify(&v) { |
| 106 | Ok(s) => String::from(s), |
| 107 | Err(_) => return Err(err!( |
| 108 | "The document panel answered with something that cannot be read, so what it drew \ |
| 109 | is unknown. Do not tell the user what they are looking at."; Invalid, Data)), |
| 110 | }, |
| 111 | }; |
| 112 | // A verdict with no tier in it is not a verdict. Reporting it as a successful show would have |
| 113 | // the model describe a screen nothing has said anything about. |
| 114 | let tier = match extract_json_string(&json, "tier") { |
| 115 | Some(t) if !t.trim().is_empty() => t, |
| 116 | _ => return Err(err!( |
| 117 | "The document panel did not say what it drew, so what the user is looking at is \ |
| 118 | unknown."; Invalid, Data)), |
| 119 | }; |
| 120 | Ok(Shown { |
| 121 | tier, |
| 122 | media: extract_json_string(&json, "media").unwrap_or_default(), |
| 123 | label: extract_json_string(&json, "label").unwrap_or_default(), |
| 124 | size: extract_json_number(&json, "size").unwrap_or(0), |
| 125 | // The panel's answer and not the argument above: a show with no page named leaves a |
| 126 | // document where it was last aimed, so these two differ on every re-show. |
| 127 | page: match extract_json_number(&json, "page") { |
| 128 | Some(n) if n > 0 => Some(n as u32), |
| 129 | _ => None, |
| 130 | }, |
| 131 | disagree: extract_json_bool(&json, "disagree").unwrap_or(false), |
| 132 | named: extract_json_string(&json, "named").unwrap_or_default(), |
| 133 | found: extract_json_string(&json, "found").unwrap_or_default(), |
| 134 | // Absent means shown. An older bundle's driver answers no `shown` at all, and the tool |
| 135 | // predates the field: reading a missing one as "not shown" would have every show in that |
| 136 | // bundle report a failure that did not happen. |
| 137 | shown: extract_json_bool(&json, "shown").unwrap_or(true), |
| 138 | }) |
| 139 | } |