@perspective-dev/viewer 5.1.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 (175) 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/intl/de.css +1 -1
  8. package/dist/css/intl/es.css +1 -1
  9. package/dist/css/intl/fr.css +1 -1
  10. package/dist/css/intl/ja.css +1 -1
  11. package/dist/css/intl/pt.css +1 -1
  12. package/dist/css/intl/zh.css +1 -1
  13. package/dist/css/intl.css +1 -1
  14. package/dist/css/monokai.css +1 -1
  15. package/dist/css/phosphor.css +1 -1
  16. package/dist/css/pro-dark.css +1 -1
  17. package/dist/css/pro.css +1 -1
  18. package/dist/css/solarized-dark.css +1 -1
  19. package/dist/css/solarized.css +1 -1
  20. package/dist/css/themes.css +1 -1
  21. package/dist/css/vaporwave.css +1 -1
  22. package/dist/docs/perspective-docs.json +1 -0
  23. package/dist/esm/column-format.d.ts +19 -28
  24. package/dist/esm/perspective-viewer.d.ts +2 -0
  25. package/dist/esm/perspective-viewer.inline.js +8 -8
  26. package/dist/esm/perspective-viewer.inline.js.map +4 -4
  27. package/dist/esm/perspective-viewer.js +9 -9
  28. package/dist/esm/perspective-viewer.js.map +4 -4
  29. package/dist/esm/providers.d.ts +79 -0
  30. package/dist/esm/ts-rs/CompactDisplay.d.ts +5 -0
  31. package/dist/esm/ts-rs/CurrencyCode.d.ts +1 -0
  32. package/dist/esm/ts-rs/CurrencyDisplay.d.ts +1 -0
  33. package/dist/esm/ts-rs/CurrencyNumberFormatStyle.d.ts +8 -0
  34. package/dist/esm/ts-rs/CurrencySign.d.ts +1 -0
  35. package/dist/esm/ts-rs/CustomDatetimeFormat.d.ts +1 -0
  36. package/dist/esm/ts-rs/CustomDatetimeStyleConfig.d.ts +54 -0
  37. package/dist/esm/ts-rs/CustomNumberFormatConfig.d.ts +34 -0
  38. package/dist/esm/ts-rs/DatetimeColorMode.d.ts +1 -0
  39. package/dist/esm/ts-rs/DatetimeFormatType.d.ts +6 -0
  40. package/dist/esm/ts-rs/FormatMode.d.ts +1 -0
  41. package/dist/esm/ts-rs/FormatUnit.d.ts +1 -0
  42. package/dist/esm/ts-rs/Notation.d.ts +17 -0
  43. package/dist/esm/ts-rs/NumberFormatStyle.d.ts +18 -0
  44. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +41 -1
  45. package/dist/esm/ts-rs/PluginStaticConfig.d.ts +35 -0
  46. package/dist/esm/ts-rs/RestoreOptions.d.ts +19 -0
  47. package/dist/esm/ts-rs/RoundingMode.d.ts +1 -0
  48. package/dist/esm/ts-rs/RoundingPriority.d.ts +1 -0
  49. package/dist/esm/ts-rs/SignDisplay.d.ts +1 -0
  50. package/dist/esm/ts-rs/SimpleDatetimeFormat.d.ts +1 -0
  51. package/dist/esm/ts-rs/SimpleDatetimeStyleConfig.d.ts +24 -0
  52. package/dist/esm/ts-rs/SplitRollupMode.d.ts +9 -0
  53. package/dist/esm/ts-rs/StringColorMode.d.ts +1 -0
  54. package/dist/esm/ts-rs/TrailingZeroDisplay.d.ts +1 -0
  55. package/dist/esm/ts-rs/Unit.d.ts +1 -0
  56. package/dist/esm/ts-rs/UnitDisplay.d.ts +1 -0
  57. package/dist/esm/ts-rs/UnitNumberFormatStyle.d.ts +6 -0
  58. package/dist/esm/ts-rs/UseGrouping.d.ts +1 -0
  59. package/dist/esm/ts-rs/ViewerConfig.d.ts +41 -1
  60. package/dist/esm/ts-rs/ViewerConfigInitial.d.ts +175 -0
  61. package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +63 -0
  62. package/dist/esm/ts-rs/WindowSpec.d.ts +3 -2
  63. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +7 -6
  64. package/dist/wasm/perspective-viewer.d.ts +134 -25
  65. package/dist/wasm/perspective-viewer.js +367 -124
  66. package/dist/wasm/perspective-viewer.wasm +0 -0
  67. package/dist/wasm/perspective-viewer.wasm.d.ts +18 -15
  68. package/package.json +2 -1
  69. package/src/css/_viewer_bundle.css +1 -0
  70. package/src/css/column-selector.css +8 -0
  71. package/src/css/config-selector.css +38 -3
  72. package/src/css/form/code-editor.css +2 -3
  73. package/src/css/form/mirrored-textarea.css +81 -0
  74. package/src/css/viewer.css +328 -0
  75. package/src/rust/agent/client/mod.rs +19 -0
  76. package/src/rust/agent/client/protocol.rs +320 -0
  77. package/src/rust/agent/client/transport.rs +287 -0
  78. package/src/rust/agent/client/turn.rs +174 -0
  79. package/src/rust/agent/config.rs +159 -0
  80. package/src/rust/agent/docs.rs +293 -0
  81. package/src/rust/agent/markdown/block.rs +509 -0
  82. package/src/rust/agent/markdown/entity.rs +52 -0
  83. package/src/rust/agent/markdown/events.rs +71 -0
  84. package/src/rust/agent/markdown/inline.rs +497 -0
  85. package/src/rust/agent/markdown/mod.rs +466 -0
  86. package/src/rust/agent/mod.rs +23 -0
  87. package/src/rust/agent/preamble.md +16 -0
  88. package/src/rust/agent/runtime.rs +201 -0
  89. package/src/rust/agent/slot.rs +236 -0
  90. package/src/rust/agent/tools.rs +1110 -0
  91. package/src/rust/components/chat_panel.rs +289 -0
  92. package/src/rust/components/column_selector/config_selector.rs +76 -7
  93. package/src/rust/components/column_selector.rs +7 -0
  94. package/src/rust/components/containers/dragdrop_list.rs +37 -0
  95. package/src/rust/components/form/code_editor.rs +14 -23
  96. package/src/rust/components/form/debug.rs +8 -5
  97. package/src/rust/components/form/mirrored_textarea.rs +165 -0
  98. package/src/rust/components/form/mod.rs +1 -0
  99. package/src/rust/components/mod.rs +2 -0
  100. package/src/rust/components/settings_panel.rs +80 -2
  101. package/src/rust/components/viewer/panels.rs +9 -14
  102. package/src/rust/components/viewer/render.rs +2 -0
  103. package/src/rust/components/window_editor.rs +75 -103
  104. package/src/rust/config/datetime_column_style/custom.rs +17 -0
  105. package/src/rust/config/datetime_column_style/simple.rs +10 -0
  106. package/src/rust/config/number_string_format.rs +33 -0
  107. package/src/rust/config/options.rs +18 -0
  108. package/src/rust/config/plugin_static_config.rs +120 -5
  109. package/src/rust/config/viewer_config.rs +220 -1
  110. package/src/rust/config/workspace_config.rs +7 -6
  111. package/src/rust/custom_elements/viewer.rs +209 -18
  112. package/src/rust/custom_events.rs +29 -0
  113. package/src/rust/lib.rs +13 -0
  114. package/src/rust/presentation.rs +8 -0
  115. package/src/rust/queries/get_viewer_config.rs +5 -1
  116. package/src/rust/session/column_defaults_update.rs +18 -0
  117. package/src/rust/session/metadata.rs +18 -20
  118. package/src/rust/session/replace_expression_update.rs +1 -0
  119. package/src/rust/tasks/create_panel.rs +18 -8
  120. package/src/rust/tasks/edit_window.rs +1 -1
  121. package/src/rust/tasks/restore_panel.rs +34 -17
  122. package/src/rust/tasks/sync_update_panels.rs +7 -23
  123. package/src/rust/workspace.rs +112 -20
  124. package/src/themes/intl/de.css +16 -0
  125. package/src/themes/intl/es.css +16 -0
  126. package/src/themes/intl/fr.css +16 -0
  127. package/src/themes/intl/ja.css +16 -0
  128. package/src/themes/intl/pt.css +16 -0
  129. package/src/themes/intl/zh.css +16 -0
  130. package/src/themes/intl.css +16 -0
  131. package/src/ts/column-format.ts +67 -46
  132. package/src/ts/perspective-viewer.ts +2 -0
  133. package/src/ts/providers.ts +96 -0
  134. package/src/ts/ts-rs/CompactDisplay.ts +3 -0
  135. package/src/ts/ts-rs/CurrencyCode.ts +3 -0
  136. package/src/ts/ts-rs/CurrencyDisplay.ts +3 -0
  137. package/src/ts/ts-rs/CurrencyNumberFormatStyle.ts +6 -0
  138. package/src/ts/ts-rs/CurrencySign.ts +3 -0
  139. package/src/ts/ts-rs/CustomDatetimeFormat.ts +3 -0
  140. package/src/ts/ts-rs/CustomDatetimeStyleConfig.ts +54 -0
  141. package/src/ts/ts-rs/CustomNumberFormatConfig.ts +25 -0
  142. package/src/ts/ts-rs/DatetimeColorMode.ts +3 -0
  143. package/src/ts/ts-rs/DatetimeFormatType.ts +8 -0
  144. package/src/ts/ts-rs/FormatMode.ts +3 -0
  145. package/src/ts/ts-rs/FormatUnit.ts +3 -0
  146. package/src/ts/ts-rs/Notation.ts +11 -0
  147. package/src/ts/ts-rs/NumberFormatStyle.ts +12 -0
  148. package/src/ts/ts-rs/PanelViewerConfig.ts +47 -1
  149. package/src/ts/ts-rs/PluginStaticConfig.ts +36 -1
  150. package/src/ts/ts-rs/RestoreOptions.ts +20 -0
  151. package/src/ts/ts-rs/RoundingMode.ts +3 -0
  152. package/src/ts/ts-rs/RoundingPriority.ts +3 -0
  153. package/src/ts/ts-rs/SignDisplay.ts +3 -0
  154. package/src/ts/ts-rs/SimpleDatetimeFormat.ts +3 -0
  155. package/src/ts/ts-rs/SimpleDatetimeStyleConfig.ts +25 -0
  156. package/src/ts/ts-rs/SplitRollupMode.ts +11 -0
  157. package/src/ts/ts-rs/StringColorMode.ts +3 -0
  158. package/src/ts/ts-rs/TrailingZeroDisplay.ts +3 -0
  159. package/src/ts/ts-rs/Unit.ts +3 -0
  160. package/src/ts/ts-rs/UnitDisplay.ts +3 -0
  161. package/src/ts/ts-rs/UnitNumberFormatStyle.ts +5 -0
  162. package/src/ts/ts-rs/UseGrouping.ts +3 -0
  163. package/src/ts/ts-rs/ViewerConfig.ts +47 -1
  164. package/src/ts/ts-rs/ViewerConfigInitial.ts +164 -0
  165. package/src/ts/ts-rs/ViewerConfigUpdate.ts +72 -2
  166. package/src/ts/ts-rs/WindowSpec.ts +3 -2
  167. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +7 -6
  168. package/dist/esm/ts-rs/WindowAggregate.d.ts +0 -1
  169. package/src/ts/ts-rs/WindowAggregate.ts +0 -3
  170. /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-02e461f3d7062b26}/inline0.js +0 -0
  171. /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-02e461f3d7062b26}/inline1.js +0 -0
  172. /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-02e461f3d7062b26}/inline2.js +0 -0
  173. /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-02e461f3d7062b26}/inline3.js +0 -0
  174. /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-02e461f3d7062b26}/inline4.js +0 -0
  175. /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-02e461f3d7062b26}/inline5.js +0 -0
@@ -0,0 +1,320 @@
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 OpenAI chat-completions wire shapes — only the fields this client
14
+ //! produces or consumes. Unknown response fields are ignored by serde, so
15
+ //! provider-specific extras (`usage`, `finish_reason`, ids) cost nothing.
16
+ //! The format is pinned by the `test/js/agent/` specs, which assert these
17
+ //! exact shapes against a scripted fake engine.
18
+
19
+ use serde::{Deserialize, Serialize};
20
+ use serde_json::Value;
21
+
22
+ /// One conversation message, tagged by `role`. `Assistant` doubles as the
23
+ /// response-message shape (`choices[n].message`).
24
+ #[derive(Clone, Deserialize, Serialize)]
25
+ #[serde(tag = "role", rename_all = "lowercase")]
26
+ pub enum ChatMessage {
27
+ System {
28
+ content: String,
29
+ },
30
+ User {
31
+ content: String,
32
+ },
33
+ Assistant {
34
+ #[serde(default, skip_serializing_if = "Option::is_none")]
35
+ content: Option<String>,
36
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
37
+ tool_calls: Vec<ToolCall>,
38
+
39
+ /// Reasoning-model "thinking" text, captured for DISPLAY only:
40
+ /// `skip_serializing` is the contract that it is never replayed
41
+ /// into a request (providers reject or mis-tokenize replayed
42
+ /// reasoning). Two wire conventions, one field: DeepSeek-style
43
+ /// `reasoning_content` (also LM Studio/vLLM) and OpenRouter's
44
+ /// `reasoning`.
45
+ #[serde(default, skip_serializing, alias = "reasoning")]
46
+ reasoning_content: Option<String>,
47
+ },
48
+ Tool {
49
+ tool_call_id: String,
50
+ content: String,
51
+ },
52
+ }
53
+
54
+ /// A model-emitted tool invocation, echoed back verbatim in the transcript
55
+ /// so the model can correlate `Tool` results by `id`.
56
+ #[derive(Clone, Deserialize, Serialize)]
57
+ pub struct ToolCall {
58
+ #[serde(default)]
59
+ pub id: String,
60
+ #[serde(default = "function_type", rename = "type")]
61
+ pub kind: String,
62
+ pub function: FunctionCall,
63
+ }
64
+
65
+ /// `arguments` is a JSON-*encoded string* per the OpenAI wire format, not a
66
+ /// JSON object.
67
+ #[derive(Clone, Deserialize, Serialize)]
68
+ pub struct FunctionCall {
69
+ pub name: String,
70
+ #[serde(default)]
71
+ pub arguments: String,
72
+ }
73
+
74
+ fn function_type() -> String {
75
+ "function".to_owned()
76
+ }
77
+
78
+ /// A tool advertised to the model.
79
+ #[derive(Serialize)]
80
+ pub struct ToolDef {
81
+ #[serde(rename = "type")]
82
+ pub kind: &'static str,
83
+ pub function: FunctionDef,
84
+ }
85
+
86
+ #[derive(Serialize)]
87
+ pub struct FunctionDef {
88
+ pub name: &'static str,
89
+ pub description: &'static str,
90
+ pub parameters: Value,
91
+ }
92
+
93
+ #[derive(Serialize)]
94
+ pub struct ChatRequest<'a> {
95
+ pub model: &'a str,
96
+ pub messages: &'a [ChatMessage],
97
+ pub tools: &'a [ToolDef],
98
+
99
+ /// Always `true`; servers that ignore it get the buffered fallback in
100
+ /// `transport.rs`, so streaming needs no configuration surface.
101
+ pub stream: bool,
102
+ }
103
+
104
+ #[derive(Deserialize)]
105
+ pub struct ChatResponse {
106
+ pub choices: Vec<ChatChoice>,
107
+ }
108
+
109
+ #[derive(Deserialize)]
110
+ pub struct ChatChoice {
111
+ pub message: ChatMessage,
112
+ }
113
+
114
+ /// One `stream: true` SSE frame (`data: {...}`) — the delta shapes of the
115
+ /// OpenAI streaming wire format.
116
+ #[derive(Deserialize)]
117
+ pub struct ChatChunk {
118
+ #[serde(default)]
119
+ pub choices: Vec<ChunkChoice>,
120
+ }
121
+
122
+ #[derive(Deserialize)]
123
+ pub struct ChunkChoice {
124
+ #[serde(default)]
125
+ pub delta: ChunkDelta,
126
+ }
127
+
128
+ #[derive(Default, Deserialize)]
129
+ pub struct ChunkDelta {
130
+ #[serde(default)]
131
+ pub content: Option<String>,
132
+ #[serde(default)]
133
+ pub reasoning_content: Option<String>,
134
+ #[serde(default)]
135
+ pub reasoning: Option<String>,
136
+ #[serde(default)]
137
+ pub tool_calls: Vec<ToolCallDelta>,
138
+ }
139
+
140
+ /// Tool-call fragments accumulate BY INDEX: `id`/`name` arrive on the
141
+ /// first fragment, `arguments` arrives as string parts across many.
142
+ #[derive(Deserialize)]
143
+ pub struct ToolCallDelta {
144
+ #[serde(default)]
145
+ pub index: usize,
146
+ #[serde(default)]
147
+ pub id: Option<String>,
148
+ #[serde(default)]
149
+ pub function: Option<FunctionCallDelta>,
150
+ }
151
+
152
+ #[derive(Deserialize)]
153
+ pub struct FunctionCallDelta {
154
+ #[serde(default)]
155
+ pub name: Option<String>,
156
+ #[serde(default)]
157
+ pub arguments: Option<String>,
158
+ }
159
+
160
+ /// Folds a stream of [`ChatChunk`]s into the same
161
+ /// [`ChatMessage::Assistant`] the buffered path produces, so `turn.rs` is
162
+ /// agnostic to how the response arrived. Reasoning takes the first
163
+ /// non-empty of the two wire conventions per delta.
164
+ #[derive(Default)]
165
+ pub struct ChunkAccumulator {
166
+ content: String,
167
+ reasoning: String,
168
+ tool_calls: Vec<ToolCall>,
169
+ }
170
+
171
+ impl ChunkAccumulator {
172
+ pub fn push(&mut self, chunk: ChatChunk) {
173
+ for choice in chunk.choices {
174
+ let delta = choice.delta;
175
+ if let Some(content) = delta.content {
176
+ self.content.push_str(&content);
177
+ }
178
+
179
+ if let Some(reasoning) = delta
180
+ .reasoning_content
181
+ .into_iter()
182
+ .chain(delta.reasoning)
183
+ .find(|x| !x.is_empty())
184
+ {
185
+ self.reasoning.push_str(&reasoning);
186
+ }
187
+
188
+ for fragment in delta.tool_calls {
189
+ if self.tool_calls.len() <= fragment.index {
190
+ self.tool_calls
191
+ .resize_with(fragment.index + 1, || ToolCall {
192
+ id: String::new(),
193
+ kind: function_type(),
194
+ function: FunctionCall {
195
+ name: String::new(),
196
+ arguments: String::new(),
197
+ },
198
+ });
199
+ }
200
+
201
+ let call = &mut self.tool_calls[fragment.index];
202
+ if let Some(id) = fragment.id {
203
+ call.id = id;
204
+ }
205
+
206
+ if let Some(function) = fragment.function {
207
+ if let Some(name) = function.name {
208
+ call.function.name = name;
209
+ }
210
+
211
+ if let Some(arguments) = function.arguments {
212
+ call.function.arguments.push_str(&arguments);
213
+ }
214
+ }
215
+ }
216
+ }
217
+ }
218
+
219
+ /// Accumulated text/reasoning so far, for live-render callbacks.
220
+ pub fn progress(&self) -> (&str, &str) {
221
+ (&self.content, &self.reasoning)
222
+ }
223
+
224
+ pub fn finish(self) -> ChatMessage {
225
+ ChatMessage::Assistant {
226
+ content: (!self.content.is_empty()).then_some(self.content),
227
+ tool_calls: self.tool_calls,
228
+ reasoning_content: (!self.reasoning.is_empty()).then_some(self.reasoning),
229
+ }
230
+ }
231
+ }
232
+
233
+ #[cfg(test)]
234
+ mod tests {
235
+ use super::*;
236
+
237
+ fn chunk(json: &str) -> ChatChunk {
238
+ serde_json::from_str(json).unwrap()
239
+ }
240
+
241
+ #[test]
242
+ fn content_and_both_reasoning_conventions_fold() {
243
+ let mut acc = ChunkAccumulator::default();
244
+ acc.push(chunk(r#"{"choices":[{"delta":{"role":"assistant"}}]}"#));
245
+ acc.push(chunk(
246
+ r#"{"choices":[{"delta":{"reasoning_content":"th"}}]}"#,
247
+ ));
248
+ acc.push(chunk(r#"{"choices":[{"delta":{"reasoning":"ink"}}]}"#));
249
+ acc.push(chunk(r#"{"choices":[{"delta":{"content":"Hel"}}]}"#));
250
+ acc.push(chunk(
251
+ r#"{"choices":[{"delta":{"content":"lo","reasoning":""}}]}"#,
252
+ ));
253
+ assert_eq!(acc.progress(), ("Hello", "think"));
254
+ let ChatMessage::Assistant {
255
+ content,
256
+ tool_calls,
257
+ reasoning_content,
258
+ } = acc.finish()
259
+ else {
260
+ panic!("not assistant");
261
+ };
262
+
263
+ assert_eq!(content.as_deref(), Some("Hello"));
264
+ assert!(tool_calls.is_empty());
265
+ assert_eq!(reasoning_content.as_deref(), Some("think"));
266
+ }
267
+
268
+ #[test]
269
+ fn fragmented_tool_arguments_accumulate_by_index() {
270
+ let mut acc = ChunkAccumulator::default();
271
+ acc.push(chunk(
272
+ r#"{"choices":[{"delta":{"tool_calls":[
273
+ {"index":0,"id":"c0","function":{"name":"get_schema","arguments":""}}]}}]}"#,
274
+ ));
275
+
276
+ // Index 1 opens before index 0 finishes; both argument streams
277
+ // interleave.
278
+ acc.push(chunk(
279
+ r#"{"choices":[{"delta":{"tool_calls":[
280
+ {"index":1,"id":"c1","function":{"name":"set_view_config","arguments":"{\"con"}}]}}]}"#,
281
+ ));
282
+
283
+ acc.push(chunk(
284
+ r#"{"choices":[{"delta":{"tool_calls":[
285
+ {"index":1,"function":{"arguments":"fig\":{}}"}}]}}]}"#,
286
+ ));
287
+
288
+ let ChatMessage::Assistant { tool_calls, .. } = acc.finish() else {
289
+ panic!("not assistant");
290
+ };
291
+
292
+ assert_eq!(tool_calls.len(), 2);
293
+ assert_eq!(tool_calls[0].id, "c0");
294
+ assert_eq!(tool_calls[0].function.name, "get_schema");
295
+ assert_eq!(tool_calls[1].function.arguments, "{\"config\":{}}");
296
+ assert_eq!(tool_calls[1].kind, "function");
297
+ }
298
+
299
+ /// The display-only contract: reasoning deserializes from EITHER wire
300
+ /// field but NEVER serializes back into a request.
301
+ #[test]
302
+ fn assistant_reasoning_reads_both_aliases_and_never_serializes() {
303
+ for field in ["reasoning_content", "reasoning"] {
304
+ let message: ChatMessage = serde_json::from_str(&format!(
305
+ r#"{{"role":"assistant","content":"hi","{field}":"secret"}}"#
306
+ ))
307
+ .unwrap();
308
+
309
+ let ChatMessage::Assistant {
310
+ reasoning_content, ..
311
+ } = &message
312
+ else {
313
+ panic!("not assistant");
314
+ };
315
+
316
+ assert_eq!(reasoning_content.as_deref(), Some("secret"));
317
+ assert!(!serde_json::to_string(&message).unwrap().contains("secret"));
318
+ }
319
+ }
320
+ }
@@ -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
+ }