@perspective-dev/viewer 5.0.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/dist/cdn/perspective-viewer.js +9 -9
  2. package/dist/cdn/perspective-viewer.js.map +4 -4
  3. package/dist/css/botanical.css +1 -1
  4. package/dist/css/dracula.css +1 -1
  5. package/dist/css/gruvbox-dark.css +1 -1
  6. package/dist/css/gruvbox.css +1 -1
  7. package/dist/css/icons.css +1 -1
  8. package/dist/css/intl/de.css +1 -1
  9. package/dist/css/intl/es.css +1 -1
  10. package/dist/css/intl/fr.css +1 -1
  11. package/dist/css/intl/ja.css +1 -1
  12. package/dist/css/intl/pt.css +1 -1
  13. package/dist/css/intl/zh.css +1 -1
  14. package/dist/css/intl.css +1 -1
  15. package/dist/css/monokai.css +1 -1
  16. package/dist/css/phosphor.css +1 -1
  17. package/dist/css/pro-dark.css +1 -1
  18. package/dist/css/pro.css +1 -1
  19. package/dist/css/solarized-dark.css +1 -1
  20. package/dist/css/solarized.css +1 -1
  21. package/dist/css/themes.css +1 -1
  22. package/dist/css/vaporwave.css +1 -1
  23. package/dist/docs/perspective-docs.json +1 -0
  24. package/dist/esm/column-format.d.ts +19 -28
  25. package/dist/esm/perspective-viewer.d.ts +2 -0
  26. package/dist/esm/perspective-viewer.inline.js +8 -8
  27. package/dist/esm/perspective-viewer.inline.js.map +4 -4
  28. package/dist/esm/perspective-viewer.js +9 -9
  29. package/dist/esm/perspective-viewer.js.map +4 -4
  30. package/dist/esm/providers.d.ts +79 -0
  31. package/dist/esm/ts-rs/CompactDisplay.d.ts +5 -0
  32. package/dist/esm/ts-rs/CurrencyCode.d.ts +1 -0
  33. package/dist/esm/ts-rs/CurrencyDisplay.d.ts +1 -0
  34. package/dist/esm/ts-rs/CurrencyNumberFormatStyle.d.ts +8 -0
  35. package/dist/esm/ts-rs/CurrencySign.d.ts +1 -0
  36. package/dist/esm/ts-rs/CustomDatetimeFormat.d.ts +1 -0
  37. package/dist/esm/ts-rs/CustomDatetimeStyleConfig.d.ts +54 -0
  38. package/dist/esm/ts-rs/CustomNumberFormatConfig.d.ts +34 -0
  39. package/dist/esm/ts-rs/DatetimeColorMode.d.ts +1 -0
  40. package/dist/esm/ts-rs/DatetimeFormatType.d.ts +6 -0
  41. package/dist/esm/ts-rs/FormatMode.d.ts +1 -0
  42. package/dist/esm/ts-rs/FormatUnit.d.ts +1 -0
  43. package/dist/esm/ts-rs/Notation.d.ts +17 -0
  44. package/dist/esm/ts-rs/NumberFormatStyle.d.ts +18 -0
  45. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +43 -1
  46. package/dist/esm/ts-rs/PluginStaticConfig.d.ts +35 -0
  47. package/dist/esm/ts-rs/RestoreOptions.d.ts +19 -0
  48. package/dist/esm/ts-rs/RoundingMode.d.ts +1 -0
  49. package/dist/esm/ts-rs/RoundingPriority.d.ts +1 -0
  50. package/dist/esm/ts-rs/SignDisplay.d.ts +1 -0
  51. package/dist/esm/ts-rs/SimpleDatetimeFormat.d.ts +1 -0
  52. package/dist/esm/ts-rs/SimpleDatetimeStyleConfig.d.ts +24 -0
  53. package/dist/esm/ts-rs/SplitRollupMode.d.ts +9 -0
  54. package/dist/esm/ts-rs/StringColorMode.d.ts +1 -0
  55. package/dist/esm/ts-rs/TrailingZeroDisplay.d.ts +1 -0
  56. package/dist/esm/ts-rs/Unit.d.ts +1 -0
  57. package/dist/esm/ts-rs/UnitDisplay.d.ts +1 -0
  58. package/dist/esm/ts-rs/UnitNumberFormatStyle.d.ts +6 -0
  59. package/dist/esm/ts-rs/UseGrouping.d.ts +1 -0
  60. package/dist/esm/ts-rs/ViewerConfig.d.ts +43 -1
  61. package/dist/esm/ts-rs/ViewerConfigInitial.d.ts +175 -0
  62. package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +72 -0
  63. package/dist/esm/ts-rs/WindowSort.d.ts +7 -0
  64. package/dist/esm/ts-rs/WindowSortDir.d.ts +6 -0
  65. package/dist/esm/ts-rs/WindowSpec.d.ts +47 -0
  66. package/dist/esm/ts-rs/Windows.d.ts +10 -0
  67. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +7 -6
  68. package/dist/wasm/perspective-viewer.d.ts +222 -26
  69. package/dist/wasm/perspective-viewer.js +378 -125
  70. package/dist/wasm/perspective-viewer.wasm +0 -0
  71. package/dist/wasm/perspective-viewer.wasm.d.ts +18 -15
  72. package/package.json +2 -1
  73. package/src/css/_viewer_bundle.css +1 -0
  74. package/src/css/column-selector.css +9 -0
  75. package/src/css/column-settings-panel.css +67 -2
  76. package/src/css/column-style.css +4 -3
  77. package/src/css/config-selector.css +60 -4
  78. package/src/css/containers/context-menu.css +12 -6
  79. package/src/css/containers/tabs.css +2 -2
  80. package/src/css/form/code-editor.css +2 -3
  81. package/src/css/form/mirrored-textarea.css +81 -0
  82. package/src/css/viewer.css +336 -0
  83. package/src/rust/agent/client/mod.rs +19 -0
  84. package/src/rust/agent/client/protocol.rs +320 -0
  85. package/src/rust/agent/client/transport.rs +287 -0
  86. package/src/rust/agent/client/turn.rs +174 -0
  87. package/src/rust/agent/config.rs +159 -0
  88. package/src/rust/agent/docs.rs +293 -0
  89. package/src/rust/agent/markdown/block.rs +509 -0
  90. package/src/rust/agent/markdown/entity.rs +52 -0
  91. package/src/rust/agent/markdown/events.rs +71 -0
  92. package/src/rust/agent/markdown/inline.rs +497 -0
  93. package/src/rust/agent/markdown/mod.rs +466 -0
  94. package/src/rust/agent/mod.rs +23 -0
  95. package/src/rust/agent/preamble.md +16 -0
  96. package/src/rust/agent/runtime.rs +201 -0
  97. package/src/rust/agent/slot.rs +236 -0
  98. package/src/rust/agent/tools.rs +1110 -0
  99. package/src/rust/components/chat_panel.rs +289 -0
  100. package/src/rust/components/column_selector/active_column.rs +21 -27
  101. package/src/rust/components/column_selector/column_selector_column_row.rs +73 -0
  102. package/src/rust/components/column_selector/config_selector.rs +83 -8
  103. package/src/rust/components/column_selector/expr_edit_button.rs +7 -1
  104. package/src/rust/components/column_selector/inactive_column.rs +8 -1
  105. package/src/rust/components/column_selector.rs +27 -3
  106. package/src/rust/components/column_settings_sidebar/style_tab.rs +2 -1
  107. package/src/rust/components/column_settings_sidebar/window_tab.rs +34 -0
  108. package/src/rust/components/column_settings_sidebar.rs +163 -16
  109. package/src/rust/components/containers/dragdrop_list.rs +110 -3
  110. package/src/rust/components/containers/sidebar.rs +99 -22
  111. package/src/rust/components/containers/split_panel.rs +6 -0
  112. package/src/rust/components/editable_header.rs +2 -0
  113. package/src/rust/components/form/code_editor.rs +14 -23
  114. package/src/rust/components/form/debug.rs +8 -5
  115. package/src/rust/components/form/mirrored_textarea.rs +165 -0
  116. package/src/rust/components/form/mod.rs +1 -0
  117. package/src/rust/components/main_panel.rs +5 -0
  118. package/src/rust/components/mod.rs +3 -0
  119. package/src/rust/components/panel_menu.rs +7 -0
  120. package/src/rust/components/panel_tab.rs +5 -0
  121. package/src/rust/components/portal.rs +7 -0
  122. package/src/rust/components/settings_panel.rs +80 -2
  123. package/src/rust/components/viewer/msg.rs +2 -0
  124. package/src/rust/components/viewer/panels.rs +9 -14
  125. package/src/rust/components/viewer/render.rs +14 -0
  126. package/src/rust/components/viewer/settings.rs +33 -1
  127. package/src/rust/components/viewer.rs +2 -0
  128. package/src/rust/components/window_editor.rs +985 -0
  129. package/src/rust/config/datetime_column_style/custom.rs +17 -0
  130. package/src/rust/config/datetime_column_style/simple.rs +10 -0
  131. package/src/rust/config/number_string_format.rs +33 -0
  132. package/src/rust/config/options.rs +18 -0
  133. package/src/rust/config/plugin_static_config.rs +120 -5
  134. package/src/rust/config/viewer_config.rs +220 -1
  135. package/src/rust/config/workspace_config.rs +7 -6
  136. package/src/rust/custom_elements/viewer.rs +209 -18
  137. package/src/rust/custom_events.rs +29 -0
  138. package/src/rust/lib.rs +17 -1
  139. package/src/rust/presentation/column_locator.rs +16 -1
  140. package/src/rust/presentation.rs +8 -0
  141. package/src/rust/queries/columns_iter_set.rs +7 -1
  142. package/src/rust/queries/get_viewer_config.rs +5 -1
  143. package/src/rust/renderer/dispatch.rs +85 -58
  144. package/src/rust/renderer.rs +34 -1
  145. package/src/rust/session/column_defaults_update.rs +18 -0
  146. package/src/rust/session/drag_drop_update.rs +18 -0
  147. package/src/rust/session/metadata.rs +64 -9
  148. package/src/rust/session/replace_expression_update.rs +2 -0
  149. package/src/rust/session.rs +7 -5
  150. package/src/rust/tasks/create_panel.rs +18 -8
  151. package/src/rust/tasks/edit_window.rs +155 -0
  152. package/src/rust/tasks/mod.rs +2 -0
  153. package/src/rust/tasks/restore_panel.rs +34 -17
  154. package/src/rust/tasks/sync_update_panels.rs +7 -23
  155. package/src/rust/utils/browser/dragdrop.rs +12 -0
  156. package/src/rust/utils/browser/tests/debounce.rs +49 -0
  157. package/src/rust/utils/debounce.rs +76 -27
  158. package/src/rust/workspace.rs +112 -20
  159. package/src/svg/pin-icon.svg +5 -0
  160. package/src/themes/icons.css +1 -0
  161. package/src/themes/intl/de.css +23 -0
  162. package/src/themes/intl/es.css +23 -0
  163. package/src/themes/intl/fr.css +23 -0
  164. package/src/themes/intl/ja.css +23 -0
  165. package/src/themes/intl/pt.css +23 -0
  166. package/src/themes/intl/zh.css +23 -0
  167. package/src/themes/intl.css +26 -1
  168. package/src/ts/column-format.ts +67 -46
  169. package/src/ts/perspective-viewer.ts +2 -0
  170. package/src/ts/providers.ts +96 -0
  171. package/src/ts/ts-rs/CompactDisplay.ts +3 -0
  172. package/src/ts/ts-rs/CurrencyCode.ts +3 -0
  173. package/src/ts/ts-rs/CurrencyDisplay.ts +3 -0
  174. package/src/ts/ts-rs/CurrencyNumberFormatStyle.ts +6 -0
  175. package/src/ts/ts-rs/CurrencySign.ts +3 -0
  176. package/src/ts/ts-rs/CustomDatetimeFormat.ts +3 -0
  177. package/src/ts/ts-rs/CustomDatetimeStyleConfig.ts +54 -0
  178. package/src/ts/ts-rs/CustomNumberFormatConfig.ts +25 -0
  179. package/src/ts/ts-rs/DatetimeColorMode.ts +3 -0
  180. package/src/ts/ts-rs/DatetimeFormatType.ts +8 -0
  181. package/src/ts/ts-rs/FormatMode.ts +3 -0
  182. package/src/ts/ts-rs/FormatUnit.ts +3 -0
  183. package/src/ts/ts-rs/Notation.ts +11 -0
  184. package/src/ts/ts-rs/NumberFormatStyle.ts +12 -0
  185. package/src/ts/ts-rs/PanelViewerConfig.ts +48 -1
  186. package/src/ts/ts-rs/PluginStaticConfig.ts +36 -1
  187. package/src/ts/ts-rs/RestoreOptions.ts +20 -0
  188. package/src/ts/ts-rs/RoundingMode.ts +3 -0
  189. package/src/ts/ts-rs/RoundingPriority.ts +3 -0
  190. package/src/ts/ts-rs/SignDisplay.ts +3 -0
  191. package/src/ts/ts-rs/SimpleDatetimeFormat.ts +3 -0
  192. package/src/ts/ts-rs/SimpleDatetimeStyleConfig.ts +25 -0
  193. package/src/ts/ts-rs/SplitRollupMode.ts +11 -0
  194. package/src/ts/ts-rs/StringColorMode.ts +3 -0
  195. package/src/ts/ts-rs/TrailingZeroDisplay.ts +3 -0
  196. package/src/ts/ts-rs/Unit.ts +3 -0
  197. package/src/ts/ts-rs/UnitDisplay.ts +3 -0
  198. package/src/ts/ts-rs/UnitNumberFormatStyle.ts +5 -0
  199. package/src/ts/ts-rs/UseGrouping.ts +3 -0
  200. package/src/ts/ts-rs/ViewerConfig.ts +48 -1
  201. package/src/ts/ts-rs/ViewerConfigInitial.ts +164 -0
  202. package/src/ts/ts-rs/ViewerConfigUpdate.ts +81 -2
  203. package/src/ts/ts-rs/WindowSort.ts +9 -0
  204. package/src/ts/ts-rs/WindowSortDir.ts +8 -0
  205. package/src/ts/ts-rs/WindowSpec.ts +47 -0
  206. package/src/ts/ts-rs/Windows.ts +10 -0
  207. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +7 -6
  208. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline0.js +0 -0
  209. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline1.js +0 -0
  210. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline2.js +0 -0
  211. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline3.js +0 -0
  212. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline4.js +0 -0
  213. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline5.js +0 -0
@@ -0,0 +1,287 @@
1
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ // ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ // ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+ //! How completion requests move: browser `fetch()` for HTTP providers, or a
14
+ //! host-supplied duck-typed engine object (WebLLM's `MLCEngine`, or the
15
+ //! scripted fake the Playwright specs inject) — the same seam either way,
16
+ //! so the offline specs exercise the identical request/response path the
17
+ //! HTTP providers use.
18
+ //!
19
+ //! Every request asks for `stream: true` and every response path degrades
20
+ //! automatically, so streaming needs no configuration surface: an HTTP
21
+ //! response that is not `text/event-stream` and an engine result that is
22
+ //! not async-iterable are both consumed as complete buffered responses.
23
+ //! Either way the caller receives ONE folded [`ChatMessage`], with
24
+ //! incremental progress reported through `on_delta`.
25
+
26
+ use perspective_js::utils::{ApiError, ApiResult};
27
+ use wasm_bindgen::prelude::*;
28
+ use wasm_bindgen_futures::JsFuture;
29
+
30
+ use super::protocol::{ChatChunk, ChatMessage, ChatRequest, ChatResponse, ChunkAccumulator};
31
+
32
+ /// Live progress callback: `(content_so_far, reasoning_so_far)`.
33
+ pub type OnDelta<'a> = &'a dyn Fn(&str, &str);
34
+
35
+ /// A configured connection to a completions endpoint. Static dispatch — two
36
+ /// variants don't warrant a trait object.
37
+ pub enum AgentTransport {
38
+ /// POST to an OpenAI-compatible `/chat/completions` URL with the given
39
+ /// extra request headers (auth etc.).
40
+ Fetch {
41
+ url: String,
42
+ headers: Vec<(String, String)>,
43
+ },
44
+
45
+ /// An in-page object exposing `chat.completions.create(request)`.
46
+ /// No bytes leave the page.
47
+ Engine(JsValue),
48
+ }
49
+
50
+ impl AgentTransport {
51
+ /// One completion request, folded to the response message. Cancellation
52
+ /// is by dropping the returned future (see [`AbortGuard`]).
53
+ pub async fn create(
54
+ &self,
55
+ request: &ChatRequest<'_>,
56
+ on_delta: OnDelta<'_>,
57
+ ) -> ApiResult<ChatMessage> {
58
+ let body = serde_json::to_string(request)?;
59
+ match self {
60
+ Self::Fetch { url, headers } => fetch_completion(url, headers, &body, on_delta).await,
61
+ Self::Engine(engine) => engine_completion(engine, &body, on_delta).await,
62
+ }
63
+ }
64
+ }
65
+
66
+ /// Parse a complete (non-streamed) chat-completions response body.
67
+ fn from_response_text(text: &str) -> ApiResult<ChatMessage> {
68
+ let response: ChatResponse = serde_json::from_str(text)
69
+ .map_err(|x| ApiError::from(format!("Malformed completion response: {x}")))?;
70
+
71
+ response
72
+ .choices
73
+ .into_iter()
74
+ .next()
75
+ .map(|x| x.message)
76
+ .ok_or_else(|| ApiError::from("Completion response has no choices"))
77
+ }
78
+
79
+ /// Aborts the in-flight browser fetch when dropped, wiring the viewer's
80
+ /// future-cancellation model (`AgentSlot`'s `Abortable`, the chat Stop
81
+ /// button) through to the network layer. The fetch `signal` also covers the
82
+ /// response body stream, so aborting mid-SSE kills the socket — no separate
83
+ /// reader cancel is needed. Aborting an already-settled fetch is a no-op,
84
+ /// so the guard needs no disarm path.
85
+ struct AbortGuard(web_sys::AbortController);
86
+
87
+ impl Drop for AbortGuard {
88
+ fn drop(&mut self) {
89
+ self.0.abort();
90
+ }
91
+ }
92
+
93
+ async fn fetch_completion(
94
+ url: &str,
95
+ headers: &[(String, String)],
96
+ body: &str,
97
+ on_delta: OnDelta<'_>,
98
+ ) -> ApiResult<ChatMessage> {
99
+ let controller = web_sys::AbortController::new()?;
100
+ let _guard = AbortGuard(controller.clone());
101
+ let request_headers = web_sys::Headers::new()?;
102
+ request_headers.append("content-type", "application/json")?;
103
+ for (name, value) in headers {
104
+ request_headers.append(name, value)?;
105
+ }
106
+
107
+ let init = web_sys::RequestInit::new();
108
+ init.set_method("POST");
109
+ init.set_body(&JsValue::from_str(body));
110
+ init.set_headers(&request_headers);
111
+ init.set_signal(Some(&controller.signal()));
112
+ let request = web_sys::Request::new_with_str_and_init(url, &init)?;
113
+ let window = web_sys::window().ok_or_else(|| ApiError::from("No `window`"))?;
114
+ let response: web_sys::Response = JsFuture::from(window.fetch_with_request(&request))
115
+ .await?
116
+ .unchecked_into();
117
+
118
+ if !response.ok() {
119
+ let text = JsFuture::from(response.text()?)
120
+ .await?
121
+ .as_string()
122
+ .unwrap_or_default();
123
+
124
+ return Err(ApiError::from(format!(
125
+ "HTTP {} {}: {}",
126
+ response.status(),
127
+ response.status_text(),
128
+ text
129
+ )));
130
+ }
131
+
132
+ let content_type = response
133
+ .headers()
134
+ .get("content-type")?
135
+ .unwrap_or_default()
136
+ .to_lowercase();
137
+
138
+ if content_type.starts_with("text/event-stream") {
139
+ stream_sse(&response, on_delta).await
140
+ } else {
141
+ // The server ignored `stream: true` — buffered fallback.
142
+ let text = JsFuture::from(response.text()?)
143
+ .await?
144
+ .as_string()
145
+ .unwrap_or_default();
146
+
147
+ from_response_text(&text)
148
+ }
149
+ }
150
+
151
+ /// Consume a `text/event-stream` response body, folding each `data:` chunk.
152
+ async fn stream_sse(response: &web_sys::Response, on_delta: OnDelta<'_>) -> ApiResult<ChatMessage> {
153
+ let stream = response
154
+ .body()
155
+ .ok_or_else(|| ApiError::from("Streaming response has no body"))?;
156
+
157
+ let reader: web_sys::ReadableStreamDefaultReader = stream.get_reader().unchecked_into();
158
+ let decoder = web_sys::TextDecoder::new()?;
159
+ let options = web_sys::TextDecodeOptions::new();
160
+ options.set_stream(true);
161
+ let mut buf = String::new();
162
+ let mut acc = ChunkAccumulator::default();
163
+ 'read: loop {
164
+ let result = JsFuture::from(reader.read()).await?;
165
+ let done = js_sys::Reflect::get(&result, &JsValue::from_str("done"))?
166
+ .as_bool()
167
+ .unwrap_or(true);
168
+
169
+ if done {
170
+ break;
171
+ }
172
+
173
+ let value: js_sys::Uint8Array =
174
+ js_sys::Reflect::get(&result, &JsValue::from_str("value"))?.unchecked_into();
175
+
176
+ buf.push_str(&decoder.decode_with_u8_array_and_options(&value.to_vec(), &options)?);
177
+ for payload in drain_sse_data(&mut buf) {
178
+ if payload == "[DONE]" {
179
+ break 'read;
180
+ }
181
+
182
+ let chunk: ChatChunk = serde_json::from_str(&payload)
183
+ .map_err(|x| ApiError::from(format!("Malformed completion chunk: {x}")))?;
184
+
185
+ acc.push(chunk);
186
+ let (content, reasoning) = acc.progress();
187
+ on_delta(content, reasoning);
188
+ }
189
+ }
190
+
191
+ Ok(acc.finish())
192
+ }
193
+
194
+ /// Drain complete SSE frames from `buf`, returning each frame's joined
195
+ /// `data:` payload (multi-`data:` frames join with `\n` per the SSE spec);
196
+ /// the incomplete tail stays in `buf`. Comment lines and other fields are
197
+ /// ignored; CRLF is normalized — a `\r`/`\n` pair split across reads
198
+ /// reunites in `buf` before the next drain sees it.
199
+ fn drain_sse_data(buf: &mut String) -> Vec<String> {
200
+ if buf.contains("\r\n") {
201
+ *buf = buf.replace("\r\n", "\n");
202
+ }
203
+
204
+ let mut out = vec![];
205
+ while let Some(pos) = buf.find("\n\n") {
206
+ let frame: String = buf.drain(..pos + 2).collect();
207
+ let mut payload = String::new();
208
+ for line in frame.lines() {
209
+ if let Some(rest) = line.strip_prefix("data:") {
210
+ if !payload.is_empty() {
211
+ payload.push('\n');
212
+ }
213
+
214
+ payload.push_str(rest.strip_prefix(' ').unwrap_or(rest));
215
+ }
216
+ }
217
+
218
+ if !payload.is_empty() {
219
+ out.push(payload);
220
+ }
221
+ }
222
+
223
+ out
224
+ }
225
+
226
+ /// JSON round-trip through `JSON.parse`/`JSON.stringify` so the engine sees
227
+ /// plain JS objects (what WebLLM and the spec fakes expect) and replies
228
+ /// deserialize through the same serde path as HTTP responses. A result
229
+ /// bearing `Symbol.asyncIterator` (WebLLM's `stream: true` shape) is
230
+ /// iterated as chunks; anything else is one complete response.
231
+ async fn engine_completion(
232
+ engine: &JsValue,
233
+ body: &str,
234
+ on_delta: OnDelta<'_>,
235
+ ) -> ApiResult<ChatMessage> {
236
+ let request = js_sys::JSON::parse(body)?;
237
+ let chat = js_sys::Reflect::get(engine, &JsValue::from_str("chat"))?;
238
+ let completions = js_sys::Reflect::get(&chat, &JsValue::from_str("completions"))?;
239
+ let create: js_sys::Function =
240
+ js_sys::Reflect::get(&completions, &JsValue::from_str("create"))?
241
+ .dyn_into()
242
+ .map_err(|_| ApiError::from("`engine.chat.completions.create` is not a function"))?;
243
+
244
+ let result = create.call1(&completions, &request)?;
245
+ let result = JsFuture::from(js_sys::Promise::resolve(&result)).await?;
246
+ let iterator_fn = js_sys::Reflect::get(&result, js_sys::Symbol::async_iterator().as_ref())?;
247
+ if !iterator_fn.is_function() {
248
+ let text = js_sys::JSON::stringify(&result)?
249
+ .as_string()
250
+ .ok_or_else(|| ApiError::from("Engine returned an unserializable response"))?;
251
+
252
+ return from_response_text(&text);
253
+ }
254
+
255
+ let iterator = iterator_fn
256
+ .unchecked_into::<js_sys::Function>()
257
+ .call0(&result)?;
258
+ let next: js_sys::Function = js_sys::Reflect::get(&iterator, &JsValue::from_str("next"))?
259
+ .dyn_into()
260
+ .map_err(|_| ApiError::from("Engine stream iterator has no `next`"))?;
261
+
262
+ let mut acc = ChunkAccumulator::default();
263
+ loop {
264
+ let step = JsFuture::from(js_sys::Promise::resolve(&next.call0(&iterator)?)).await?;
265
+ let done = js_sys::Reflect::get(&step, &JsValue::from_str("done"))?
266
+ .as_bool()
267
+ .unwrap_or(true);
268
+
269
+ if done {
270
+ break;
271
+ }
272
+
273
+ let value = js_sys::Reflect::get(&step, &JsValue::from_str("value"))?;
274
+ let text = js_sys::JSON::stringify(&value)?
275
+ .as_string()
276
+ .ok_or_else(|| ApiError::from("Engine returned an unserializable chunk"))?;
277
+
278
+ let chunk: ChatChunk = serde_json::from_str(&text)
279
+ .map_err(|x| ApiError::from(format!("Malformed completion chunk: {x}")))?;
280
+
281
+ acc.push(chunk);
282
+ let (content, reasoning) = acc.progress();
283
+ on_delta(content, reasoning);
284
+ }
285
+
286
+ Ok(acc.finish())
287
+ }
@@ -0,0 +1,174 @@
1
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ // ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ // ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+ //! The agentic loop: model request → dispatch any tool calls → repeat, until
14
+ //! the model answers in text or the turn budget runs out.
15
+
16
+ use perspective_js::utils::ApiError;
17
+ use serde_json::json;
18
+
19
+ use super::protocol::{ChatMessage, ChatRequest};
20
+ use super::transport::{AgentTransport, OnDelta};
21
+ use crate::agent::config::SystemRole;
22
+ use crate::agent::tools::{ToolCtx, tool_definitions, tool_dispatch};
23
+ use crate::custom_elements::viewer::PerspectiveViewerElement;
24
+
25
+ /// How a turn ended without a final assistant answer. The distinction
26
+ /// matters to the caller's commit decision: on [`TurnError::Budget`] the
27
+ /// transcript is *coherent* (every dispatched tool call has its result) and
28
+ /// the dispatched tools may have mutated the viewer, so the transcript must
29
+ /// be COMMITTED — discarding it would leave the next prompt blind to
30
+ /// mutations that persist on screen (e.g. re-adding a panel the model does
31
+ /// not know it created). [`TurnError::Api`] (transport/protocol failure)
32
+ /// leaves the transcript mid-request and is discarded as before.
33
+ pub enum TurnError {
34
+ Budget { max_turns: usize },
35
+ Api(ApiError),
36
+ }
37
+
38
+ impl From<ApiError> for TurnError {
39
+ fn from(err: ApiError) -> Self {
40
+ Self::Api(err)
41
+ }
42
+ }
43
+
44
+ impl From<TurnError> for ApiError {
45
+ fn from(err: TurnError) -> Self {
46
+ match err {
47
+ TurnError::Budget { max_turns } => ApiError::from(format!(
48
+ "Turn budget exhausted after {max_turns} model requests (see `maxTurns`)"
49
+ )),
50
+ TurnError::Api(err) => err,
51
+ }
52
+ }
53
+ }
54
+
55
+ /// Appended (transiently — never committed to the durable transcript) to
56
+ /// the turn's final budgeted request, so the model answers instead of
57
+ /// starting tool work it cannot finish.
58
+ const BUDGET_NUDGE: &str = "This is the final model request of this turn's budget. Do not call \
59
+ more tools - answer now with a short summary of what was done and \
60
+ what, if anything, remains.";
61
+
62
+ /// Run one conversational turn, mutating `messages` (system prompt at index
63
+ /// 0, then transcript) in place with everything the model and tools said.
64
+ /// Tool *failures* are fed back to the model as error tool-results so it can
65
+ /// self-correct; transport and protocol failures abort the turn mid-request,
66
+ /// while turn-budget exhaustion ends it with a coherent transcript — see
67
+ /// [`TurnError`] for the commit consequences.
68
+ #[allow(clippy::too_many_arguments)]
69
+ pub async fn run_turn(
70
+ transport: &AgentTransport,
71
+ model: &str,
72
+ max_turns: usize,
73
+ elem: &PerspectiveViewerElement,
74
+ ctx: &ToolCtx,
75
+ messages: &mut Vec<ChatMessage>,
76
+ system_role: SystemRole,
77
+ on_delta: OnDelta<'_>,
78
+ ) -> Result<String, TurnError> {
79
+ let defs = tool_definitions(ctx);
80
+ for turn in 0..max_turns {
81
+ // Each model request streams into a fresh tail — reset the live
82
+ // view so a prior request's partial text does not linger across
83
+ // tool rounds.
84
+ on_delta("", "");
85
+ let nudged;
86
+ let request_messages: &[ChatMessage] = if turn + 1 == max_turns && max_turns > 1 {
87
+ let mut with_nudge = messages.clone();
88
+
89
+ // Follows the preamble's placement: an engine that rejects a
90
+ // system message alongside `tools` rejects THIS one too, and
91
+ // it is appended to the very request that most needs to
92
+ // succeed.
93
+ with_nudge.push(match system_role {
94
+ SystemRole::System => ChatMessage::System {
95
+ content: BUDGET_NUDGE.to_owned(),
96
+ },
97
+ SystemRole::User => ChatMessage::User {
98
+ content: BUDGET_NUDGE.to_owned(),
99
+ },
100
+ });
101
+
102
+ nudged = with_nudge;
103
+ &nudged
104
+ } else {
105
+ messages
106
+ };
107
+
108
+ let request = ChatRequest {
109
+ model,
110
+ messages: request_messages,
111
+ tools: &defs,
112
+ stream: true,
113
+ };
114
+
115
+ let message = transport.create(&request, on_delta).await?;
116
+ let ChatMessage::Assistant {
117
+ content,
118
+ tool_calls,
119
+ reasoning_content,
120
+ } = message
121
+ else {
122
+ return Err(
123
+ ApiError::from("Completion response message role is not `assistant`").into(),
124
+ );
125
+ };
126
+
127
+ if tool_calls.is_empty() {
128
+ let text = content.unwrap_or_default();
129
+
130
+ // The buffered fallback never fired `on_delta` — surface the
131
+ // final text (and any reasoning) so the live view converges on
132
+ // what history records regardless of transport path.
133
+ on_delta(&text, reasoning_content.as_deref().unwrap_or_default());
134
+ messages.push(ChatMessage::Assistant {
135
+ content: Some(text.clone()),
136
+ tool_calls: vec![],
137
+ reasoning_content,
138
+ });
139
+
140
+ return Ok(text);
141
+ }
142
+
143
+ messages.push(ChatMessage::Assistant {
144
+ content,
145
+ tool_calls: tool_calls.clone(),
146
+ reasoning_content,
147
+ });
148
+
149
+ // TODO: There is deliberately no duplicate-call breaker here: a
150
+ // tool call identical (name + arguments) to one already made this
151
+ // turn is re-dispatched verbatim, so a model stuck at a fixed
152
+ // point burns budget until the nudge/exhaustion path ends the
153
+ // turn (observed in the field: qwen looping `validate_expression`
154
+ // to `maxTurns`). If nudge + budget prove insufficient, the
155
+ // breaker is: synthesize an error result ("already called with
156
+ // identical arguments; the result will not change") without
157
+ // dispatching.
158
+ for call in tool_calls {
159
+ let content =
160
+ match tool_dispatch(elem, ctx, &call.function.name, &call.function.arguments).await
161
+ {
162
+ Ok(value) => value.to_string(),
163
+ Err(err) => json!({ "error": format!("{err}") }).to_string(),
164
+ };
165
+
166
+ messages.push(ChatMessage::Tool {
167
+ tool_call_id: call.id,
168
+ content,
169
+ });
170
+ }
171
+ }
172
+
173
+ Err(TurnError::Budget { max_turns })
174
+ }
@@ -0,0 +1,159 @@
1
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ // ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ // ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+ //! `agentConfig()` parsing. There is no provider abstraction in the core —
14
+ //! the client speaks one protocol (OpenAI chat-completions) over primitive
15
+ //! connection fields (`url` + `headers` + `apiKey`, or an in-page
16
+ //! `engine`), and "providers" are plain JSON presets in the JS package
17
+ //! spread into the config (`{...providers.anthropic, apiKey}`).
18
+
19
+ use std::collections::HashMap;
20
+
21
+ use perspective_js::utils::{ApiError, ApiResult};
22
+ use serde::Deserialize;
23
+ use wasm_bindgen::prelude::*;
24
+
25
+ use super::tools::Entitlement;
26
+
27
+ /// The serde-compatible subset of the `agentConfig()` argument; the
28
+ /// `engine` and `docs` fields are live JS values (an engine object; a
29
+ /// fetch `Response`/buffer/promise or entry array) and are extracted
30
+ /// separately by reference.
31
+ #[derive(Deserialize)]
32
+ #[serde(rename_all = "camelCase")]
33
+ struct AgentConfigFields {
34
+ /// Cosmetic label for the chat badge (presets set this).
35
+ name: Option<String>,
36
+
37
+ /// Full chat-completions endpoint URL, e.g.
38
+ /// `https://api.anthropic.com/v1/chat/completions`.
39
+ url: Option<String>,
40
+
41
+ /// Extra request headers sent verbatim (e.g. Anthropic's
42
+ /// `anthropic-dangerous-direct-browser-access`).
43
+ headers: Option<HashMap<String, String>>,
44
+
45
+ /// Sugar for `Authorization: Bearer <apiKey>` — verified accepted by
46
+ /// every supported OpenAI-compat endpoint (incl. Anthropic + Gemini).
47
+ api_key: Option<String>,
48
+
49
+ model: Option<String>,
50
+ system_prompt: Option<String>,
51
+ system_role: Option<SystemRole>,
52
+ max_turns: Option<usize>,
53
+
54
+ /// Access grants (snake_case [`Entitlement`] names); omitted ⇒ the
55
+ /// default set (everything except `read_data`).
56
+ entitlements: Option<Vec<String>>,
57
+ }
58
+
59
+ /// Where the preamble (plus any `systemPrompt`) is placed in a request.
60
+ ///
61
+ /// Some engines refuse to accept a system message alongside `tools`
62
+ /// because they need to own the system prompt themselves — WebLLM's
63
+ /// Hermes function-calling rejects the whole request with
64
+ /// `CustomSystemPromptError`. [`SystemRole::User`] folds the same text
65
+ /// into the conversation's first USER message instead, which those
66
+ /// engines accept and which reads identically to the model.
67
+ #[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)]
68
+ #[serde(rename_all = "lowercase")]
69
+ pub enum SystemRole {
70
+ #[default]
71
+ System,
72
+ User,
73
+ }
74
+
75
+ /// Parsed `viewer.agentConfig({...})` argument.
76
+ pub struct AgentConfig {
77
+ pub name: Option<String>,
78
+ pub url: Option<String>,
79
+ pub headers: Option<HashMap<String, String>>,
80
+ pub api_key: Option<String>,
81
+ pub model: Option<String>,
82
+ pub system_prompt: Option<String>,
83
+ pub system_role: SystemRole,
84
+ pub max_turns: Option<usize>,
85
+ pub entitlements: Vec<Entitlement>,
86
+ pub engine: Option<JsValue>,
87
+ pub docs: Option<JsValue>,
88
+ }
89
+
90
+ impl AgentConfig {
91
+ pub fn from_js(config: &JsValue) -> ApiResult<Self> {
92
+ let fields: AgentConfigFields = serde_wasm_bindgen::from_value(config.clone())?;
93
+ let engine = js_sys::Reflect::get(config, &JsValue::from_str("engine"))
94
+ .ok()
95
+ .filter(|x| !x.is_undefined() && !x.is_null());
96
+
97
+ let docs = js_sys::Reflect::get(config, &JsValue::from_str("docs"))
98
+ .ok()
99
+ .filter(|x| !x.is_undefined() && !x.is_null() && x.as_bool() != Some(false));
100
+
101
+ match (&fields.url, &engine) {
102
+ (Some(_), Some(_)) => {
103
+ return Err(ApiError::from("Pass either `url` or `engine`, not both"));
104
+ },
105
+ (None, None) => {
106
+ return Err(ApiError::from(
107
+ "Either `url` (a chat-completions endpoint, e.g. a spread provider preset) or \
108
+ `engine` (an in-page engine object) is required",
109
+ ));
110
+ },
111
+ _ => (),
112
+ }
113
+
114
+ let entitlements = match fields.entitlements {
115
+ None => Entitlement::default_set(),
116
+ Some(names) => names
117
+ .iter()
118
+ .map(|x| x.parse())
119
+ .collect::<Result<Vec<_>, _>>()
120
+ .map_err(ApiError::from)?,
121
+ };
122
+
123
+ Ok(Self {
124
+ name: fields.name,
125
+ url: fields.url,
126
+ headers: fields.headers,
127
+ api_key: fields.api_key,
128
+ model: fields.model,
129
+ system_prompt: fields.system_prompt,
130
+ system_role: fields.system_role.unwrap_or_default(),
131
+ max_turns: fields.max_turns,
132
+ entitlements,
133
+ engine,
134
+ docs,
135
+ })
136
+ }
137
+
138
+ /// The model id sent on every completion request. Local servers and
139
+ /// in-page engines generally answer with whatever model they have
140
+ /// loaded, so the fallback is a placeholder.
141
+ pub fn model_name(&self) -> &str {
142
+ self.model.as_deref().unwrap_or("default")
143
+ }
144
+
145
+ /// Chat-badge text: the preset `name`, else the endpoint host, else
146
+ /// `"engine"`.
147
+ pub fn label_name(&self) -> String {
148
+ if let Some(name) = &self.name {
149
+ return name.clone();
150
+ }
151
+
152
+ self.url
153
+ .as_deref()
154
+ .and_then(|x| x.split("//").nth(1))
155
+ .and_then(|x| x.split('/').next())
156
+ .map(str::to_owned)
157
+ .unwrap_or_else(|| "engine".to_owned())
158
+ }
159
+ }