@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,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
+ }
@@ -0,0 +1,293 @@
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
+ use std::collections::HashMap;
14
+ use std::rc::Rc;
15
+
16
+ use serde::Deserialize;
17
+ use serde_json::Value;
18
+ use wasm_bindgen::prelude::*;
19
+ use wasm_bindgen_futures::JsFuture;
20
+
21
+ /// One retrievable unit. `title`/`path` are optional in host-supplied
22
+ /// entries; oversized `text` is split at paragraph boundaries on ingest so
23
+ /// hosts never need to understand chunking.
24
+ #[derive(Clone, Deserialize)]
25
+ pub struct Chunk {
26
+ #[serde(default)]
27
+ pub title: String,
28
+ #[serde(default)]
29
+ pub path: String,
30
+ pub text: String,
31
+ }
32
+
33
+ const SPLIT_OVER: usize = 2500;
34
+
35
+ /// Words too common to discriminate; deliberately tiny — the caller is a
36
+ /// model that writes keyword-style queries.
37
+ const STOP_WORDS: &[&str] = &["the", "a", "an", "of", "to", "is", "in", "and", "or", "for"];
38
+
39
+ /// Lowercase alphanumeric-and-`_` tokens; identifiers containing `_` also
40
+ /// emit their parts, so `split_by` matches both the exact API token and the
41
+ /// natural-language query "split by".
42
+ fn tokenize(text: &str) -> Vec<String> {
43
+ let mut tokens = vec![];
44
+ for raw in text
45
+ .to_lowercase()
46
+ .split(|x: char| !x.is_alphanumeric() && x != '_')
47
+ {
48
+ if raw.is_empty() || STOP_WORDS.contains(&raw) {
49
+ continue;
50
+ }
51
+
52
+ tokens.push(raw.to_owned());
53
+ if raw.contains('_') {
54
+ for part in raw.split('_').filter(|x| !x.is_empty()) {
55
+ tokens.push(part.to_owned());
56
+ }
57
+ }
58
+ }
59
+
60
+ tokens
61
+ }
62
+
63
+ pub struct DocsIndex {
64
+ chunks: Vec<Chunk>,
65
+ postings: HashMap<String, Vec<(usize, f64)>>,
66
+ lens: Vec<f64>,
67
+ avg_len: f64,
68
+ }
69
+
70
+ impl DocsIndex {
71
+ pub fn build(entries: Vec<Chunk>) -> Self {
72
+ let chunks = normalize(entries);
73
+ let mut postings: HashMap<String, Vec<(usize, f64)>> = HashMap::new();
74
+ let mut lens = Vec::with_capacity(chunks.len());
75
+ for (idx, chunk) in chunks.iter().enumerate() {
76
+ let tokens = tokenize(&format!("{} {}", chunk.title, chunk.text));
77
+ lens.push(tokens.len() as f64);
78
+ let mut counts: HashMap<String, f64> = HashMap::new();
79
+ for token in tokens {
80
+ *counts.entry(token).or_default() += 1.0;
81
+ }
82
+
83
+ for (token, count) in counts {
84
+ postings.entry(token).or_default().push((idx, count));
85
+ }
86
+ }
87
+
88
+ let avg_len = (lens.iter().sum::<f64>() / lens.len().max(1) as f64).max(1.0);
89
+ Self {
90
+ chunks,
91
+ postings,
92
+ lens,
93
+ avg_len,
94
+ }
95
+ }
96
+
97
+ /// BM25 (k1 = 1.2, b = 0.75) top-`limit` chunks for a keyword query.
98
+ pub fn search(&self, query: &str, limit: usize) -> Vec<&Chunk> {
99
+ const K1: f64 = 1.2;
100
+ const B: f64 = 0.75;
101
+ let n = self.chunks.len() as f64;
102
+ let mut terms = tokenize(query);
103
+ terms.sort();
104
+ terms.dedup();
105
+ let mut scores: HashMap<usize, f64> = HashMap::new();
106
+ for term in terms {
107
+ let Some(posting) = self.postings.get(&term) else {
108
+ continue;
109
+ };
110
+
111
+ let df = posting.len() as f64;
112
+ let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
113
+ for (idx, tf) in posting {
114
+ let norm = K1 * (1.0 - B + B * self.lens[*idx] / self.avg_len);
115
+ *scores.entry(*idx).or_default() += idf * tf / (tf + norm);
116
+ }
117
+ }
118
+
119
+ let mut ranked = scores.into_iter().collect::<Vec<_>>();
120
+ ranked.sort_by(|x, y| y.1.total_cmp(&x.1).then(x.0.cmp(&y.0)));
121
+ ranked
122
+ .into_iter()
123
+ .take(limit)
124
+ .map(|(idx, _)| &self.chunks[idx])
125
+ .collect()
126
+ }
127
+ }
128
+
129
+ /// Default titles from a text prefix; split oversized entries at paragraph
130
+ /// boundaries (a single paragraph longer than the limit stays whole).
131
+ fn normalize(entries: Vec<Chunk>) -> Vec<Chunk> {
132
+ let mut out = vec![];
133
+ for mut chunk in entries {
134
+ if chunk.title.is_empty() {
135
+ chunk.title = chunk.text.chars().take(60).collect::<String>();
136
+ }
137
+
138
+ if chunk.text.len() <= SPLIT_OVER {
139
+ out.push(chunk);
140
+ continue;
141
+ }
142
+
143
+ let mut part = String::new();
144
+ for para in chunk.text.split("\n\n") {
145
+ if !part.is_empty() && part.len() + para.len() > SPLIT_OVER {
146
+ out.push(Chunk {
147
+ text: std::mem::take(&mut part),
148
+ ..chunk.clone()
149
+ });
150
+ }
151
+
152
+ if !part.is_empty() {
153
+ part.push_str("\n\n");
154
+ }
155
+
156
+ part.push_str(para);
157
+ }
158
+
159
+ if !part.is_empty() {
160
+ out.push(Chunk {
161
+ text: part,
162
+ ..chunk
163
+ });
164
+ }
165
+ }
166
+
167
+ out
168
+ }
169
+
170
+ /// One resolved `docs` source: tool-parameter schemas, the searchable
171
+ /// corpus index, and an optional host preamble override.
172
+ pub struct DocsBundle {
173
+ pub schemas: HashMap<String, Value>,
174
+ pub index: DocsIndex,
175
+ pub preamble: Option<String>,
176
+ }
177
+
178
+ /// The two accepted source shapes: a bare entry array (host inline
179
+ /// entries), or the metadata-bundle object.
180
+ #[derive(Deserialize)]
181
+ #[serde(untagged)]
182
+ enum RawDocs {
183
+ Entries(Vec<Chunk>),
184
+ Bundle {
185
+ #[serde(default)]
186
+ schemas: HashMap<String, Value>,
187
+
188
+ #[serde(default)]
189
+ chunks: Vec<Chunk>,
190
+
191
+ #[serde(default)]
192
+ preamble: Option<String>,
193
+ },
194
+ }
195
+
196
+ /// The lazily-resolved, cached bundle for one configured `docs` source.
197
+ /// Owned by `AgentRuntime` (so it persists across `prompt()` calls) and
198
+ /// shared with each turn's `ToolCtx`.
199
+ pub struct DocsCell {
200
+ source: JsValue,
201
+ cache: async_lock::Mutex<Option<Rc<Result<DocsBundle, String>>>>,
202
+ }
203
+
204
+ impl DocsCell {
205
+ pub fn new(source: JsValue) -> Self {
206
+ Self {
207
+ source,
208
+ cache: async_lock::Mutex::new(None),
209
+ }
210
+ }
211
+
212
+ pub async fn bundle(&self) -> Rc<Result<DocsBundle, String>> {
213
+ let mut cache = self.cache.lock().await;
214
+ if let Some(cached) = &*cache {
215
+ return cached.clone();
216
+ }
217
+
218
+ let result = Rc::new(resolve(&self.source).await.map(|raw| {
219
+ let (schemas, chunks, preamble) = match raw {
220
+ RawDocs::Entries(chunks) => (HashMap::new(), chunks, None),
221
+ RawDocs::Bundle {
222
+ schemas,
223
+ chunks,
224
+ preamble,
225
+ } => (schemas, chunks, preamble),
226
+ };
227
+
228
+ DocsBundle {
229
+ schemas,
230
+ index: DocsIndex::build(chunks),
231
+ preamble,
232
+ }
233
+ }));
234
+
235
+ *cache = Some(result.clone());
236
+ result
237
+ }
238
+ }
239
+
240
+ fn js_error(err: JsValue) -> String {
241
+ err.as_string()
242
+ .or_else(|| {
243
+ js_sys::Reflect::get(&err, &JsValue::from_str("message"))
244
+ .ok()
245
+ .and_then(|x| x.as_string())
246
+ })
247
+ .unwrap_or_else(|| format!("{err:?}"))
248
+ }
249
+
250
+ /// Resolve the `docs` source union to a parsed raw bundle.
251
+ async fn resolve(source: &JsValue) -> Result<RawDocs, String> {
252
+ let value = JsFuture::from(js_sys::Promise::resolve(source))
253
+ .await
254
+ .map_err(js_error)?;
255
+
256
+ let text = if js_sys::Array::is_array(&value) {
257
+ js_sys::JSON::stringify(&value)
258
+ .map_err(js_error)?
259
+ .as_string()
260
+ .ok_or_else(|| "Unserializable `docs` array".to_owned())?
261
+ } else if let Some(text) = value.as_string() {
262
+ text
263
+ } else if value.is_instance_of::<web_sys::Response>() {
264
+ let response: web_sys::Response = value.unchecked_into();
265
+ if !response.ok() {
266
+ return Err(format!(
267
+ "HTTP {} {}",
268
+ response.status(),
269
+ response.status_text()
270
+ ));
271
+ }
272
+
273
+ JsFuture::from(response.text().map_err(js_error)?)
274
+ .await
275
+ .map_err(js_error)?
276
+ .as_string()
277
+ .unwrap_or_default()
278
+ } else if value.is_instance_of::<js_sys::Uint8Array>()
279
+ || value.is_instance_of::<js_sys::ArrayBuffer>()
280
+ {
281
+ let bytes = js_sys::Uint8Array::new(&value).to_vec();
282
+ String::from_utf8(bytes).map_err(|x| format!("Docs source is not UTF-8: {x}"))?
283
+ } else if value.is_object() {
284
+ js_sys::JSON::stringify(&value)
285
+ .map_err(js_error)?
286
+ .as_string()
287
+ .ok_or_else(|| "Unserializable `docs` object".to_owned())?
288
+ } else {
289
+ return Err("Unsupported `docs` type".to_owned());
290
+ };
291
+
292
+ serde_json::from_str(&text).map_err(|x| format!("Invalid docs JSON: {x}"))
293
+ }