@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,201 @@
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::rc::Rc;
14
+
15
+ use perspective_js::utils::{ApiError, ApiResult};
16
+ use wasm_bindgen::prelude::*;
17
+
18
+ use super::client::{AgentTransport, ChatMessage, OnDelta, TurnError, run_turn};
19
+ use super::config::{AgentConfig, SystemRole};
20
+ use super::docs::DocsCell;
21
+ use super::tools::ToolCtx;
22
+ use crate::custom_elements::viewer::PerspectiveViewerElement;
23
+
24
+ /// The agent's system preamble — the ONE copy, authored as markdown and
25
+ /// embedded at compile time. This crate knows nothing of its structure:
26
+ /// [`AgentRuntime::preamble`] only chooses between this and a
27
+ /// bundle-supplied override, then appends the host's `systemPrompt`.
28
+ ///
29
+ /// Its `search_docs` step is unconditional, which is why `search_docs` is
30
+ /// advertised unconditionally too (an unconfigured corpus searches EMPTY
31
+ /// rather than removing the tool) — one prompt, one tool surface, no
32
+ /// phantom-tool instructions either way.
33
+ const PREAMBLE: &str = include_str!("preamble.md");
34
+
35
+ /// The model-request budget per `prompt()` call — schema lookup + a few
36
+ /// config patches + expression iteration fit comfortably; overridable via
37
+ /// config `maxTurns`.
38
+ const DEFAULT_MAX_TURNS: usize = 16;
39
+
40
+ /// One configured agent: connection + conversation history + the tool
41
+ /// handle.
42
+ /// Owned by the element; created by `agentConfig()` and shared by every
43
+ /// `prompt()` call. The history `Mutex` serializes concurrent prompts.
44
+ pub struct AgentRuntime {
45
+ config: AgentConfig,
46
+ history: async_lock::Mutex<Vec<ChatMessage>>,
47
+ elem: PerspectiveViewerElement,
48
+ docs: Option<Rc<DocsCell>>,
49
+ }
50
+
51
+ impl AgentRuntime {
52
+ pub fn new(config: &JsValue, elem: PerspectiveViewerElement) -> ApiResult<Self> {
53
+ let config = AgentConfig::from_js(config)?;
54
+ let docs = config.docs.clone().map(|x| Rc::new(DocsCell::new(x)));
55
+ Ok(Self {
56
+ config,
57
+ history: async_lock::Mutex::new(vec![]),
58
+ elem,
59
+ docs,
60
+ })
61
+ }
62
+
63
+ /// Run one conversational turn (which may span many model requests and
64
+ /// tool calls) and return the final assistant text. History commits on
65
+ /// success — and on turn-budget exhaustion, whose transcript is coherent
66
+ /// and whose tool mutations persist in the viewer (see
67
+ /// [`TurnError::Budget`]); a transport-failed or aborted turn leaves the
68
+ /// conversation as it was.
69
+ pub async fn prompt(&self, prompt: String, on_delta: OnDelta<'_>) -> ApiResult<String> {
70
+ let mut history = self.history.lock().await;
71
+ let transport = self.transport()?;
72
+
73
+ // Resolve the docs metadata bundle BEFORE the first request so the
74
+ // turn's tool definitions carry the generated parameter schemas
75
+ // deterministically. A failed load does not abort the turn — the
76
+ // error is cached and surfaces through `search_docs`.
77
+ let bundle = match &self.docs {
78
+ Some(docs) => Some(docs.bundle().await),
79
+ None => None,
80
+ };
81
+
82
+ let ctx = ToolCtx {
83
+ docs: self.docs.clone(),
84
+ bundle,
85
+ entitlements: self.config.entitlements.clone(),
86
+ };
87
+
88
+ // The preamble is a SYSTEM message by default, but some engines
89
+ // reject one outright when `tools` is present because they need
90
+ // to own the system prompt (WebLLM's Hermes function calling
91
+ // throws `CustomSystemPromptError` on ANY system message, then
92
+ // substitutes its own carrying the tool definitions). Those
93
+ // configure `systemRole: "user"`, which folds the same text into
94
+ // the conversation's opening user turn — sent once, then carried
95
+ // by history like any other message, which is why nothing is
96
+ // stripped on commit below.
97
+ let preamble = self.preamble(&ctx);
98
+ let mut messages = Vec::with_capacity(history.len() + 2);
99
+ let preamble_len = match self.config.system_role {
100
+ SystemRole::System => {
101
+ messages.push(ChatMessage::System {
102
+ content: preamble.clone(),
103
+ });
104
+
105
+ 1
106
+ },
107
+ SystemRole::User => 0,
108
+ };
109
+
110
+ messages.extend(history.iter().cloned());
111
+ let content = if preamble_len == 0 && history.is_empty() {
112
+ format!("{preamble}\n\n{prompt}")
113
+ } else {
114
+ prompt
115
+ };
116
+
117
+ messages.push(ChatMessage::User { content });
118
+ let result = run_turn(
119
+ &transport,
120
+ self.config.model_name(),
121
+ self.config.max_turns.unwrap_or(DEFAULT_MAX_TURNS),
122
+ &self.elem,
123
+ &ctx,
124
+ &mut messages,
125
+ self.config.system_role,
126
+ on_delta,
127
+ )
128
+ .await;
129
+
130
+ match result {
131
+ Ok(text) => {
132
+ *history = messages.split_off(preamble_len);
133
+ Ok(text)
134
+ },
135
+ Err(err @ TurnError::Budget { .. }) => {
136
+ *history = messages.split_off(preamble_len);
137
+ Err(err.into())
138
+ },
139
+ Err(err) => Err(err.into()),
140
+ }
141
+ }
142
+
143
+ /// Clear the conversation history.
144
+ pub async fn reset(&self) {
145
+ self.history.lock().await.clear();
146
+ }
147
+
148
+ /// Connection/model badge text, e.g. `"anthropic · claude-haiku-4-5"`.
149
+ pub fn label(&self) -> String {
150
+ format!(
151
+ "{} · {}",
152
+ self.config.label_name(),
153
+ self.config.model_name()
154
+ )
155
+ }
156
+
157
+ /// Connection fields → transport. `url` XOR `engine` was validated at
158
+ /// configuration; `apiKey` is Bearer sugar and `headers` pass verbatim.
159
+ fn transport(&self) -> ApiResult<AgentTransport> {
160
+ if let Some(engine) = &self.config.engine {
161
+ return Ok(AgentTransport::Engine(engine.clone()));
162
+ }
163
+
164
+ let url = self
165
+ .config
166
+ .url
167
+ .clone()
168
+ .ok_or_else(|| ApiError::from("`url` or `engine` is required"))?;
169
+
170
+ let mut headers = self
171
+ .config
172
+ .headers
173
+ .clone()
174
+ .map(|x| x.into_iter().collect::<Vec<_>>())
175
+ .unwrap_or_default();
176
+
177
+ if let Some(key) = &self.config.api_key {
178
+ headers.push(("authorization".to_owned(), format!("Bearer {key}")));
179
+ }
180
+
181
+ Ok(AgentTransport::Fetch { url, headers })
182
+ }
183
+
184
+ fn preamble(&self, ctx: &ToolCtx) -> String {
185
+ let mut preamble = ctx
186
+ .bundle
187
+ .as_ref()
188
+ .and_then(|x| x.as_ref().as_ref().ok())
189
+ .and_then(|x| x.preamble.as_deref())
190
+ .unwrap_or(PREAMBLE)
191
+ .trim_end()
192
+ .to_owned();
193
+
194
+ if let Some(extra) = &self.config.system_prompt {
195
+ preamble.push_str("\n\n");
196
+ preamble.push_str(extra);
197
+ }
198
+
199
+ preamble
200
+ }
201
+ }
@@ -0,0 +1,236 @@
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
+ //! [`AgentSlot`] — the shared agent model hung off `Presentation`. Holds the
14
+ //! configured [`AgentRuntime`] plus the UI-facing conversation state
15
+ //! (transcript, busy flag, abort handle) OUTSIDE the component tree, so the
16
+ //! chat panel is a pure view that can unmount (tab switch, settings close)
17
+ //! without losing the conversation or interrupting a running turn.
18
+
19
+ use std::cell::{Cell, RefCell};
20
+ use std::rc::Rc;
21
+
22
+ use futures::future::{AbortHandle, Abortable};
23
+ use perspective_js::utils::{ApiError, ApiResult};
24
+
25
+ use super::runtime::AgentRuntime;
26
+ use crate::utils::PubSub;
27
+
28
+ /// One rendered transcript entry. `Tool` entries are appended live as the
29
+ /// agent works, giving the chat UI its activity feed.
30
+ #[derive(Clone, PartialEq)]
31
+ pub enum ChatEntry {
32
+ User(String),
33
+ Assistant {
34
+ text: String,
35
+
36
+ /// Reasoning-model "thinking" captured during the turn —
37
+ /// display-only (never part of model history).
38
+ reasoning: Option<String>,
39
+ },
40
+ Tool {
41
+ name: String,
42
+ args: String,
43
+
44
+ /// Set after dispatch when the tool failed; the chip renders in
45
+ /// the theme error color with this text in its tooltip.
46
+ error: Option<String>,
47
+ },
48
+ Error(String),
49
+ }
50
+
51
+ /// The in-flight turn's live tail: `(content, reasoning)` accumulated so
52
+ /// far by the streaming transport. View-state only — a turn that aborts
53
+ /// mid-stream discards it, and history is written exclusively from the
54
+ /// turn's final result.
55
+ type PendingTail = (String, String);
56
+
57
+ #[derive(Clone, Default)]
58
+ pub struct AgentSlot {
59
+ runtime: Rc<RefCell<Option<Rc<AgentRuntime>>>>,
60
+ transcript: Rc<RefCell<Vec<ChatEntry>>>,
61
+ pending: Rc<RefCell<Option<PendingTail>>>,
62
+ busy: Rc<Cell<bool>>,
63
+ abort: Rc<RefCell<Option<AbortHandle>>>,
64
+
65
+ /// Fires on any observable change: configuration, transcript append,
66
+ /// streaming delta, busy-flag flip.
67
+ pub on_update: Rc<PubSub<()>>,
68
+ }
69
+
70
+ impl PartialEq for AgentSlot {
71
+ fn eq(&self, rhs: &Self) -> bool {
72
+ Rc::ptr_eq(&self.runtime, &rhs.runtime)
73
+ }
74
+ }
75
+
76
+ impl AgentSlot {
77
+ /// Install (or replace) the runtime, discarding any in-flight turn and
78
+ /// the prior conversation.
79
+ pub fn configure(&self, runtime: AgentRuntime) {
80
+ self.abort_in_flight();
81
+ *self.runtime.borrow_mut() = Some(Rc::new(runtime));
82
+ self.transcript.borrow_mut().clear();
83
+ *self.pending.borrow_mut() = None;
84
+ self.on_update.emit(());
85
+ }
86
+
87
+ pub fn is_configured(&self) -> bool {
88
+ self.runtime.borrow().is_some()
89
+ }
90
+
91
+ pub fn is_busy(&self) -> bool {
92
+ self.busy.get()
93
+ }
94
+
95
+ /// Provider/model badge text for the chat UI.
96
+ pub fn label(&self) -> Option<String> {
97
+ self.runtime.borrow().as_ref().map(|x| x.label())
98
+ }
99
+
100
+ pub fn transcript(&self) -> Vec<ChatEntry> {
101
+ self.transcript.borrow().clone()
102
+ }
103
+
104
+ /// Append a tool-activity entry (called by the tools themselves).
105
+ pub fn record_tool(&self, name: &str, args: &serde_json::Value) {
106
+ self.transcript.borrow_mut().push(ChatEntry::Tool {
107
+ name: name.to_owned(),
108
+ args: args.to_string(),
109
+ error: None,
110
+ });
111
+
112
+ self.on_update.emit(());
113
+ }
114
+
115
+ /// Mark the most recent tool entry failed (called by the dispatcher
116
+ /// after the tool body settles — dispatch is sequential, so the last
117
+ /// `Tool` entry is the one that just ran).
118
+ pub fn record_tool_error(&self, message: &str) {
119
+ if let Some(ChatEntry::Tool { error, .. }) = self
120
+ .transcript
121
+ .borrow_mut()
122
+ .iter_mut()
123
+ .rev()
124
+ .find(|x| matches!(x, ChatEntry::Tool { .. }))
125
+ {
126
+ *error = Some(message.to_owned());
127
+ }
128
+
129
+ self.on_update.emit(());
130
+ }
131
+
132
+ /// The in-flight turn's streamed `(content, reasoning)`, if any.
133
+ pub fn pending(&self) -> Option<(String, String)> {
134
+ self.pending.borrow().clone()
135
+ }
136
+
137
+ /// Run one agent turn with transcript bookkeeping — the single execution
138
+ /// path shared by the chat UI and the headless `viewer.agentPrompt()`.
139
+ /// Errors (including cancellation via [`Self::stop`]) land in the
140
+ /// transcript AND propagate to the caller. Rejects if a turn is already
141
+ /// running.
142
+ pub async fn run_prompt(&self, prompt: String) -> ApiResult<String> {
143
+ let runtime = self
144
+ .runtime
145
+ .borrow()
146
+ .clone()
147
+ .ok_or_else(|| ApiError::from("`agentConfig()` has not been called"))?;
148
+
149
+ if self.busy.replace(true) {
150
+ return Err(ApiError::from("A prompt is already running"));
151
+ }
152
+
153
+ let (handle, registration) = AbortHandle::new_pair();
154
+ *self.abort.borrow_mut() = Some(handle);
155
+ self.transcript
156
+ .borrow_mut()
157
+ .push(ChatEntry::User(prompt.clone()));
158
+
159
+ self.on_update.emit(());
160
+ let on_delta = {
161
+ let pending = self.pending.clone();
162
+ let on_update = self.on_update.clone();
163
+ move |text: &str, reasoning: &str| {
164
+ *pending.borrow_mut() = Some((text.to_owned(), reasoning.to_owned()));
165
+ on_update.emit(());
166
+ }
167
+ };
168
+
169
+ let result = Abortable::new(runtime.prompt(prompt, &on_delta), registration).await;
170
+ *self.abort.borrow_mut() = None;
171
+ self.busy.set(false);
172
+
173
+ // The tail is view-state; the final entry is written from the
174
+ // turn's RESULT, keeping only the tail's reasoning (which has no
175
+ // other channel to the transcript).
176
+ let reasoning = self
177
+ .pending
178
+ .borrow_mut()
179
+ .take()
180
+ .map(|(_, x)| x)
181
+ .filter(|x| !x.is_empty());
182
+
183
+ let result = match result {
184
+ Ok(Ok(text)) => {
185
+ self.transcript.borrow_mut().push(ChatEntry::Assistant {
186
+ text: text.clone(),
187
+ reasoning,
188
+ });
189
+
190
+ Ok(text)
191
+ },
192
+ Ok(Err(err)) => {
193
+ self.transcript
194
+ .borrow_mut()
195
+ .push(ChatEntry::Error(format!("{err}")));
196
+
197
+ Err(err)
198
+ },
199
+ Err(futures::future::Aborted) => {
200
+ self.transcript
201
+ .borrow_mut()
202
+ .push(ChatEntry::Error("Stopped".to_owned()));
203
+
204
+ Err(ApiError::from("Stopped"))
205
+ },
206
+ };
207
+
208
+ self.on_update.emit(());
209
+ result
210
+ }
211
+
212
+ /// Cancel the in-flight turn (no-op when idle).
213
+ pub fn stop(&self) {
214
+ self.abort_in_flight();
215
+ }
216
+
217
+ /// Clear the conversation (model history + transcript), keeping the
218
+ /// configuration.
219
+ pub async fn reset(&self) {
220
+ self.abort_in_flight();
221
+ let runtime = self.runtime.borrow().clone();
222
+ if let Some(runtime) = runtime {
223
+ runtime.reset().await;
224
+ }
225
+
226
+ self.transcript.borrow_mut().clear();
227
+ *self.pending.borrow_mut() = None;
228
+ self.on_update.emit(());
229
+ }
230
+
231
+ fn abort_in_flight(&self) {
232
+ if let Some(handle) = self.abort.borrow_mut().take() {
233
+ handle.abort();
234
+ }
235
+ }
236
+ }