@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.
- package/dist/cdn/perspective-viewer.js +9 -9
- package/dist/cdn/perspective-viewer.js.map +4 -4
- package/dist/css/botanical.css +1 -1
- package/dist/css/dracula.css +1 -1
- package/dist/css/gruvbox-dark.css +1 -1
- package/dist/css/gruvbox.css +1 -1
- package/dist/css/intl/de.css +1 -1
- package/dist/css/intl/es.css +1 -1
- package/dist/css/intl/fr.css +1 -1
- package/dist/css/intl/ja.css +1 -1
- package/dist/css/intl/pt.css +1 -1
- package/dist/css/intl/zh.css +1 -1
- package/dist/css/intl.css +1 -1
- package/dist/css/monokai.css +1 -1
- package/dist/css/phosphor.css +1 -1
- package/dist/css/pro-dark.css +1 -1
- package/dist/css/pro.css +1 -1
- package/dist/css/solarized-dark.css +1 -1
- package/dist/css/solarized.css +1 -1
- package/dist/css/themes.css +1 -1
- package/dist/css/vaporwave.css +1 -1
- package/dist/docs/perspective-docs.json +1 -0
- package/dist/esm/column-format.d.ts +19 -28
- package/dist/esm/perspective-viewer.d.ts +2 -0
- package/dist/esm/perspective-viewer.inline.js +8 -8
- package/dist/esm/perspective-viewer.inline.js.map +4 -4
- package/dist/esm/perspective-viewer.js +9 -9
- package/dist/esm/perspective-viewer.js.map +4 -4
- package/dist/esm/providers.d.ts +79 -0
- package/dist/esm/ts-rs/CompactDisplay.d.ts +5 -0
- package/dist/esm/ts-rs/CurrencyCode.d.ts +1 -0
- package/dist/esm/ts-rs/CurrencyDisplay.d.ts +1 -0
- package/dist/esm/ts-rs/CurrencyNumberFormatStyle.d.ts +8 -0
- package/dist/esm/ts-rs/CurrencySign.d.ts +1 -0
- package/dist/esm/ts-rs/CustomDatetimeFormat.d.ts +1 -0
- package/dist/esm/ts-rs/CustomDatetimeStyleConfig.d.ts +54 -0
- package/dist/esm/ts-rs/CustomNumberFormatConfig.d.ts +34 -0
- package/dist/esm/ts-rs/DatetimeColorMode.d.ts +1 -0
- package/dist/esm/ts-rs/DatetimeFormatType.d.ts +6 -0
- package/dist/esm/ts-rs/FormatMode.d.ts +1 -0
- package/dist/esm/ts-rs/FormatUnit.d.ts +1 -0
- package/dist/esm/ts-rs/Notation.d.ts +17 -0
- package/dist/esm/ts-rs/NumberFormatStyle.d.ts +18 -0
- package/dist/esm/ts-rs/PanelViewerConfig.d.ts +41 -1
- package/dist/esm/ts-rs/PluginStaticConfig.d.ts +35 -0
- package/dist/esm/ts-rs/RestoreOptions.d.ts +19 -0
- package/dist/esm/ts-rs/RoundingMode.d.ts +1 -0
- package/dist/esm/ts-rs/RoundingPriority.d.ts +1 -0
- package/dist/esm/ts-rs/SignDisplay.d.ts +1 -0
- package/dist/esm/ts-rs/SimpleDatetimeFormat.d.ts +1 -0
- package/dist/esm/ts-rs/SimpleDatetimeStyleConfig.d.ts +24 -0
- package/dist/esm/ts-rs/SplitRollupMode.d.ts +9 -0
- package/dist/esm/ts-rs/StringColorMode.d.ts +1 -0
- package/dist/esm/ts-rs/TrailingZeroDisplay.d.ts +1 -0
- package/dist/esm/ts-rs/Unit.d.ts +1 -0
- package/dist/esm/ts-rs/UnitDisplay.d.ts +1 -0
- package/dist/esm/ts-rs/UnitNumberFormatStyle.d.ts +6 -0
- package/dist/esm/ts-rs/UseGrouping.d.ts +1 -0
- package/dist/esm/ts-rs/ViewerConfig.d.ts +41 -1
- package/dist/esm/ts-rs/ViewerConfigInitial.d.ts +175 -0
- package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +63 -0
- package/dist/esm/ts-rs/WindowSpec.d.ts +3 -2
- package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +7 -6
- package/dist/wasm/perspective-viewer.d.ts +134 -25
- package/dist/wasm/perspective-viewer.js +367 -124
- package/dist/wasm/perspective-viewer.wasm +0 -0
- package/dist/wasm/perspective-viewer.wasm.d.ts +18 -15
- package/package.json +2 -1
- package/src/css/_viewer_bundle.css +1 -0
- package/src/css/column-selector.css +8 -0
- package/src/css/config-selector.css +38 -3
- package/src/css/form/code-editor.css +2 -3
- package/src/css/form/mirrored-textarea.css +81 -0
- package/src/css/viewer.css +328 -0
- package/src/rust/agent/client/mod.rs +19 -0
- package/src/rust/agent/client/protocol.rs +320 -0
- package/src/rust/agent/client/transport.rs +287 -0
- package/src/rust/agent/client/turn.rs +174 -0
- package/src/rust/agent/config.rs +159 -0
- package/src/rust/agent/docs.rs +293 -0
- package/src/rust/agent/markdown/block.rs +509 -0
- package/src/rust/agent/markdown/entity.rs +52 -0
- package/src/rust/agent/markdown/events.rs +71 -0
- package/src/rust/agent/markdown/inline.rs +497 -0
- package/src/rust/agent/markdown/mod.rs +466 -0
- package/src/rust/agent/mod.rs +23 -0
- package/src/rust/agent/preamble.md +16 -0
- package/src/rust/agent/runtime.rs +201 -0
- package/src/rust/agent/slot.rs +236 -0
- package/src/rust/agent/tools.rs +1110 -0
- package/src/rust/components/chat_panel.rs +289 -0
- package/src/rust/components/column_selector/config_selector.rs +76 -7
- package/src/rust/components/column_selector.rs +7 -0
- package/src/rust/components/containers/dragdrop_list.rs +37 -0
- package/src/rust/components/form/code_editor.rs +14 -23
- package/src/rust/components/form/debug.rs +8 -5
- package/src/rust/components/form/mirrored_textarea.rs +165 -0
- package/src/rust/components/form/mod.rs +1 -0
- package/src/rust/components/mod.rs +2 -0
- package/src/rust/components/settings_panel.rs +80 -2
- package/src/rust/components/viewer/panels.rs +9 -14
- package/src/rust/components/viewer/render.rs +2 -0
- package/src/rust/components/window_editor.rs +75 -103
- package/src/rust/config/datetime_column_style/custom.rs +17 -0
- package/src/rust/config/datetime_column_style/simple.rs +10 -0
- package/src/rust/config/number_string_format.rs +33 -0
- package/src/rust/config/options.rs +18 -0
- package/src/rust/config/plugin_static_config.rs +120 -5
- package/src/rust/config/viewer_config.rs +220 -1
- package/src/rust/config/workspace_config.rs +7 -6
- package/src/rust/custom_elements/viewer.rs +209 -18
- package/src/rust/custom_events.rs +29 -0
- package/src/rust/lib.rs +13 -0
- package/src/rust/presentation.rs +8 -0
- package/src/rust/queries/get_viewer_config.rs +5 -1
- package/src/rust/session/column_defaults_update.rs +18 -0
- package/src/rust/session/metadata.rs +18 -20
- package/src/rust/session/replace_expression_update.rs +1 -0
- package/src/rust/tasks/create_panel.rs +18 -8
- package/src/rust/tasks/edit_window.rs +1 -1
- package/src/rust/tasks/restore_panel.rs +34 -17
- package/src/rust/tasks/sync_update_panels.rs +7 -23
- package/src/rust/workspace.rs +112 -20
- package/src/themes/intl/de.css +16 -0
- package/src/themes/intl/es.css +16 -0
- package/src/themes/intl/fr.css +16 -0
- package/src/themes/intl/ja.css +16 -0
- package/src/themes/intl/pt.css +16 -0
- package/src/themes/intl/zh.css +16 -0
- package/src/themes/intl.css +16 -0
- package/src/ts/column-format.ts +67 -46
- package/src/ts/perspective-viewer.ts +2 -0
- package/src/ts/providers.ts +96 -0
- package/src/ts/ts-rs/CompactDisplay.ts +3 -0
- package/src/ts/ts-rs/CurrencyCode.ts +3 -0
- package/src/ts/ts-rs/CurrencyDisplay.ts +3 -0
- package/src/ts/ts-rs/CurrencyNumberFormatStyle.ts +6 -0
- package/src/ts/ts-rs/CurrencySign.ts +3 -0
- package/src/ts/ts-rs/CustomDatetimeFormat.ts +3 -0
- package/src/ts/ts-rs/CustomDatetimeStyleConfig.ts +54 -0
- package/src/ts/ts-rs/CustomNumberFormatConfig.ts +25 -0
- package/src/ts/ts-rs/DatetimeColorMode.ts +3 -0
- package/src/ts/ts-rs/DatetimeFormatType.ts +8 -0
- package/src/ts/ts-rs/FormatMode.ts +3 -0
- package/src/ts/ts-rs/FormatUnit.ts +3 -0
- package/src/ts/ts-rs/Notation.ts +11 -0
- package/src/ts/ts-rs/NumberFormatStyle.ts +12 -0
- package/src/ts/ts-rs/PanelViewerConfig.ts +47 -1
- package/src/ts/ts-rs/PluginStaticConfig.ts +36 -1
- package/src/ts/ts-rs/RestoreOptions.ts +20 -0
- package/src/ts/ts-rs/RoundingMode.ts +3 -0
- package/src/ts/ts-rs/RoundingPriority.ts +3 -0
- package/src/ts/ts-rs/SignDisplay.ts +3 -0
- package/src/ts/ts-rs/SimpleDatetimeFormat.ts +3 -0
- package/src/ts/ts-rs/SimpleDatetimeStyleConfig.ts +25 -0
- package/src/ts/ts-rs/SplitRollupMode.ts +11 -0
- package/src/ts/ts-rs/StringColorMode.ts +3 -0
- package/src/ts/ts-rs/TrailingZeroDisplay.ts +3 -0
- package/src/ts/ts-rs/Unit.ts +3 -0
- package/src/ts/ts-rs/UnitDisplay.ts +3 -0
- package/src/ts/ts-rs/UnitNumberFormatStyle.ts +5 -0
- package/src/ts/ts-rs/UseGrouping.ts +3 -0
- package/src/ts/ts-rs/ViewerConfig.ts +47 -1
- package/src/ts/ts-rs/ViewerConfigInitial.ts +164 -0
- package/src/ts/ts-rs/ViewerConfigUpdate.ts +72 -2
- package/src/ts/ts-rs/WindowSpec.ts +3 -2
- package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +7 -6
- package/dist/esm/ts-rs/WindowAggregate.d.ts +0 -1
- package/src/ts/ts-rs/WindowAggregate.ts +0 -3
- /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-02e461f3d7062b26}/inline0.js +0 -0
- /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-02e461f3d7062b26}/inline1.js +0 -0
- /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-02e461f3d7062b26}/inline2.js +0 -0
- /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-02e461f3d7062b26}/inline3.js +0 -0
- /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-02e461f3d7062b26}/inline4.js +0 -0
- /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-02e461f3d7062b26}/inline5.js +0 -0
|
@@ -0,0 +1,1110 @@
|
|
|
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
|
+
use std::str::FromStr;
|
|
16
|
+
|
|
17
|
+
use futures::future::LocalBoxFuture;
|
|
18
|
+
use perspective_js::utils::ApiError;
|
|
19
|
+
use serde::Deserialize;
|
|
20
|
+
use serde_json::{Value, json};
|
|
21
|
+
use wasm_bindgen::prelude::*;
|
|
22
|
+
|
|
23
|
+
use super::client::protocol::{FunctionDef, ToolDef};
|
|
24
|
+
use super::docs::{DocsBundle, DocsCell};
|
|
25
|
+
use crate::config::{ColumnConfigSchema, ControlSpec};
|
|
26
|
+
use crate::custom_elements::viewer::PerspectiveViewerElement;
|
|
27
|
+
use crate::queries::{
|
|
28
|
+
get_column_config_schema, get_plugin_config_schema, get_viewer_config, validate_expr,
|
|
29
|
+
};
|
|
30
|
+
use crate::workspace::{Panel, PanelId};
|
|
31
|
+
|
|
32
|
+
/// Host-grantable access classes (`agentConfig({entitlements})`). A tool
|
|
33
|
+
/// whose entitlement is withheld is neither advertised nor dispatchable.
|
|
34
|
+
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
35
|
+
pub enum Entitlement {
|
|
36
|
+
ReadView,
|
|
37
|
+
ConfigureView,
|
|
38
|
+
ManageLayout,
|
|
39
|
+
ReadDocs,
|
|
40
|
+
|
|
41
|
+
// TODO(texodus): unused
|
|
42
|
+
ReadData,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
impl Entitlement {
|
|
46
|
+
pub const VALID: &'static str =
|
|
47
|
+
"read_view, configure_view, manage_layout, read_docs, read_data";
|
|
48
|
+
|
|
49
|
+
/// The default grant: everything except raw data access.
|
|
50
|
+
pub fn default_set() -> Vec<Self> {
|
|
51
|
+
vec![
|
|
52
|
+
Self::ReadView,
|
|
53
|
+
Self::ConfigureView,
|
|
54
|
+
Self::ManageLayout,
|
|
55
|
+
Self::ReadDocs,
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
impl FromStr for Entitlement {
|
|
61
|
+
type Err = String;
|
|
62
|
+
|
|
63
|
+
fn from_str(value: &str) -> Result<Self, String> {
|
|
64
|
+
match value {
|
|
65
|
+
"read_view" => Ok(Self::ReadView),
|
|
66
|
+
"configure_view" => Ok(Self::ConfigureView),
|
|
67
|
+
"manage_layout" => Ok(Self::ManageLayout),
|
|
68
|
+
"read_docs" => Ok(Self::ReadDocs),
|
|
69
|
+
"read_data" => Ok(Self::ReadData),
|
|
70
|
+
other => Err(format!(
|
|
71
|
+
"Unknown entitlement `{other}` - valid entitlements: {}",
|
|
72
|
+
Self::VALID
|
|
73
|
+
)),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// Per-turn tool context, constructed by the runtime.
|
|
79
|
+
pub struct ToolCtx {
|
|
80
|
+
pub docs: Option<Rc<DocsCell>>,
|
|
81
|
+
pub bundle: Option<Rc<Result<DocsBundle, String>>>,
|
|
82
|
+
pub entitlements: Vec<Entitlement>,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
impl ToolCtx {
|
|
86
|
+
fn schemas(&self) -> Option<&HashMap<String, Value>> {
|
|
87
|
+
self.bundle
|
|
88
|
+
.as_ref()
|
|
89
|
+
.and_then(|x| x.as_ref().as_ref().ok())
|
|
90
|
+
.map(|x| &x.schemas)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
pub struct Tool {
|
|
95
|
+
name: &'static str,
|
|
96
|
+
description: &'static str,
|
|
97
|
+
entitlement: Entitlement,
|
|
98
|
+
available: fn(&ToolCtx) -> bool,
|
|
99
|
+
parameters: fn(Option<&HashMap<String, Value>>) -> Value,
|
|
100
|
+
call: for<'a> fn(
|
|
101
|
+
&'a PerspectiveViewerElement,
|
|
102
|
+
&'a ToolCtx,
|
|
103
|
+
Value,
|
|
104
|
+
) -> LocalBoxFuture<'a, Result<Value, ToolError>>,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
fn always(_: &ToolCtx) -> bool {
|
|
108
|
+
true
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
fn panel_property() -> Value {
|
|
112
|
+
json!({
|
|
113
|
+
"type": "string",
|
|
114
|
+
"description": "Panel id from `list_panels`; omit to target the active panel"
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// The `config` property node + root `definitions` for tools taking a
|
|
119
|
+
/// config argument (`key` = the generated schema's type name, e.g.
|
|
120
|
+
/// `"ViewerConfigUpdate"`): the generated schema (from the docs metadata
|
|
121
|
+
/// bundle) when available, else a permissive object. `require` lists
|
|
122
|
+
/// config properties the AGENT contract requires beyond the public type
|
|
123
|
+
/// (unioned into the definition's `required`, stated outright in the
|
|
124
|
+
/// fallback) — the tool body must enforce the same fields at dispatch.
|
|
125
|
+
fn spliced_config(
|
|
126
|
+
schemas: Option<&HashMap<String, Value>>,
|
|
127
|
+
key: &str,
|
|
128
|
+
description: &str,
|
|
129
|
+
require: &[&str],
|
|
130
|
+
) -> (Value, Option<Value>) {
|
|
131
|
+
match schemas.and_then(|x| x.get(key)) {
|
|
132
|
+
Some(schema) => {
|
|
133
|
+
let mut definitions = schema
|
|
134
|
+
.get("definitions")
|
|
135
|
+
.and_then(|x| x.as_object())
|
|
136
|
+
.cloned()
|
|
137
|
+
.unwrap_or_default();
|
|
138
|
+
|
|
139
|
+
let original = definitions.remove(key);
|
|
140
|
+
let mut config = original
|
|
141
|
+
.clone()
|
|
142
|
+
.unwrap_or_else(|| json!({ "type": "object" }));
|
|
143
|
+
|
|
144
|
+
let mut required = config["required"].as_array().cloned().unwrap_or_default();
|
|
145
|
+
for field in require {
|
|
146
|
+
if !required.iter().any(|x| x == field) {
|
|
147
|
+
required.push(json!(field));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if !required.is_empty() {
|
|
152
|
+
config["required"] = Value::Array(required);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
config["description"] = json!(description);
|
|
156
|
+
|
|
157
|
+
// A self-recursive config type still needs its own entry.
|
|
158
|
+
if let Some(original) = original
|
|
159
|
+
&& serde_json::to_string(&definitions)
|
|
160
|
+
.unwrap_or_default()
|
|
161
|
+
.contains(&format!("#/definitions/{key}"))
|
|
162
|
+
{
|
|
163
|
+
definitions.insert(key.to_owned(), original);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let definitions = (!definitions.is_empty()).then(|| Value::Object(definitions));
|
|
167
|
+
(config, definitions)
|
|
168
|
+
},
|
|
169
|
+
None if require.is_empty() => (
|
|
170
|
+
json!({ "type": "object", "description": description }),
|
|
171
|
+
None,
|
|
172
|
+
),
|
|
173
|
+
None => (
|
|
174
|
+
json!({ "type": "object", "description": description, "required": require }),
|
|
175
|
+
None,
|
|
176
|
+
),
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
fn config_parameters(
|
|
181
|
+
schemas: Option<&HashMap<String, Value>>,
|
|
182
|
+
key: &str,
|
|
183
|
+
description: &str,
|
|
184
|
+
with_panel: bool,
|
|
185
|
+
require: &[&str],
|
|
186
|
+
) -> Value {
|
|
187
|
+
let (config, definitions) = spliced_config(schemas, key, description, require);
|
|
188
|
+
let mut properties = json!({ "config": config });
|
|
189
|
+
if with_panel {
|
|
190
|
+
properties["panel"] = panel_property();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let mut parameters = json!({
|
|
194
|
+
"type": "object",
|
|
195
|
+
"properties": properties,
|
|
196
|
+
"required": ["config"]
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if let Some(definitions) = definitions {
|
|
200
|
+
parameters["definitions"] = definitions;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
parameters
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
static TOOLS: &[Tool] = &[
|
|
207
|
+
Tool {
|
|
208
|
+
name: "get_schema",
|
|
209
|
+
description: "Get the column schema (column name -> type) and row count of the data \
|
|
210
|
+
currently loaded in a panel. Call this before configuring the view - column \
|
|
211
|
+
names must match exactly.",
|
|
212
|
+
entitlement: Entitlement::ReadView,
|
|
213
|
+
available: always,
|
|
214
|
+
parameters: |_| {
|
|
215
|
+
json!({
|
|
216
|
+
"type": "object",
|
|
217
|
+
"properties": { "panel": panel_property() }
|
|
218
|
+
})
|
|
219
|
+
},
|
|
220
|
+
call: |elem, ctx, args| Box::pin(elem.tool_get_schema(ctx, args)),
|
|
221
|
+
},
|
|
222
|
+
Tool {
|
|
223
|
+
name: "get_view_config",
|
|
224
|
+
description: "Get a panel's current configuration (plugin, group_by, split_by, columns, \
|
|
225
|
+
filter, sort, aggregates, expressions).",
|
|
226
|
+
entitlement: Entitlement::ReadView,
|
|
227
|
+
available: always,
|
|
228
|
+
parameters: |_| {
|
|
229
|
+
json!({
|
|
230
|
+
"type": "object",
|
|
231
|
+
"properties": { "panel": panel_property() }
|
|
232
|
+
})
|
|
233
|
+
},
|
|
234
|
+
call: |elem, ctx, args| Box::pin(elem.tool_get_view_config(ctx, args)),
|
|
235
|
+
},
|
|
236
|
+
Tool {
|
|
237
|
+
name: "set_view_config",
|
|
238
|
+
description: "Apply a partial configuration update to a panel. Only the fields provided \
|
|
239
|
+
are changed. Expression columns must be declared in `expressions` before \
|
|
240
|
+
being referenced in `columns`/`group_by`/etc. `columns` is POSITIONAL and \
|
|
241
|
+
every plugin reads the positions differently - call `list_plugins` for the \
|
|
242
|
+
active plugin's column roles and for what `group_by`/`split_by` draw in it. \
|
|
243
|
+
Returns the full configuration after the update is applied and rendered, \
|
|
244
|
+
plus `aggregation_changed_types` listing any column whose type CHANGED \
|
|
245
|
+
under aggregation (a `date` on the default `count` aggregate becomes an \
|
|
246
|
+
integer, so an axis bound to it is no longer a date axis).",
|
|
247
|
+
entitlement: Entitlement::ConfigureView,
|
|
248
|
+
available: always,
|
|
249
|
+
parameters: |schemas| {
|
|
250
|
+
config_parameters(
|
|
251
|
+
schemas,
|
|
252
|
+
"ViewerConfigUpdate",
|
|
253
|
+
"A partial ViewerConfigUpdate patch",
|
|
254
|
+
true,
|
|
255
|
+
&[],
|
|
256
|
+
)
|
|
257
|
+
},
|
|
258
|
+
call: |elem, ctx, args| Box::pin(elem.tool_set_view_config(ctx, args)),
|
|
259
|
+
},
|
|
260
|
+
Tool {
|
|
261
|
+
name: "list_plugins",
|
|
262
|
+
description: "List the visualization plugins registered with this viewer, each with the \
|
|
263
|
+
contract it declares: the ROLE each `columns` position fills (`columns[i]` \
|
|
264
|
+
fills `roles[i]`; extra columns repeat the last role), how many columns it \
|
|
265
|
+
requires, and what `group_by` and `split_by` draw in it. Use one of these \
|
|
266
|
+
exact names as the `plugin` field of set_view_config, and read its roles \
|
|
267
|
+
before writing `columns` - the same array means different things in \
|
|
268
|
+
different plugins.",
|
|
269
|
+
entitlement: Entitlement::ReadView,
|
|
270
|
+
available: always,
|
|
271
|
+
parameters: |_| json!({ "type": "object", "properties": {} }),
|
|
272
|
+
call: |elem, ctx, args| Box::pin(elem.tool_list_plugins(ctx, args)),
|
|
273
|
+
},
|
|
274
|
+
Tool {
|
|
275
|
+
name: "validate_expression",
|
|
276
|
+
description: "Validate an ExprTK expression against a panel's table without applying it. \
|
|
277
|
+
ExprTK examples: `\"Sales\" - \"Profit\"`, `if(\"Discount\" > 0.2, 'High', \
|
|
278
|
+
'Low')`, `bucket(\"Order Date\", 'M')`. Column references are \
|
|
279
|
+
double-quoted; string literals are single-quoted. Use this to iterate on an \
|
|
280
|
+
expression before committing it via set_view_config's `expressions` field.",
|
|
281
|
+
entitlement: Entitlement::ReadView,
|
|
282
|
+
available: always,
|
|
283
|
+
parameters: |_| {
|
|
284
|
+
json!({
|
|
285
|
+
"type": "object",
|
|
286
|
+
"properties": {
|
|
287
|
+
"expression": {
|
|
288
|
+
"type": "string",
|
|
289
|
+
"description": "The ExprTK expression to validate"
|
|
290
|
+
},
|
|
291
|
+
"panel": panel_property()
|
|
292
|
+
},
|
|
293
|
+
"required": ["expression"]
|
|
294
|
+
})
|
|
295
|
+
},
|
|
296
|
+
call: |elem, ctx, args| Box::pin(elem.tool_validate_expression(ctx, args)),
|
|
297
|
+
},
|
|
298
|
+
Tool {
|
|
299
|
+
name: "get_style_schema",
|
|
300
|
+
description: "Get the active plugin's DECLARED style schema: the valid `plugin_config` \
|
|
301
|
+
keys, and - when `column` is given - the valid `columns_config` keys for \
|
|
302
|
+
that column. These are value-dependent (they vary by plugin, column type \
|
|
303
|
+
and current state), so call this before writing `plugin_config` or \
|
|
304
|
+
`columns_config` via set_view_config. Plugins that declare no schema return \
|
|
305
|
+
null sections - keys then pass through unvalidated.",
|
|
306
|
+
entitlement: Entitlement::ReadView,
|
|
307
|
+
available: always,
|
|
308
|
+
parameters: |_| {
|
|
309
|
+
json!({
|
|
310
|
+
"type": "object",
|
|
311
|
+
"properties": {
|
|
312
|
+
"column": {
|
|
313
|
+
"type": "string",
|
|
314
|
+
"description": "An active column name (see get_view_config `columns`)"
|
|
315
|
+
},
|
|
316
|
+
"panel": panel_property()
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
},
|
|
320
|
+
call: |elem, ctx, args| Box::pin(elem.tool_get_style_schema(ctx, args)),
|
|
321
|
+
},
|
|
322
|
+
Tool {
|
|
323
|
+
name: "list_panels",
|
|
324
|
+
description: "List the panels (independent side-by-side views) in this viewer's dashboard \
|
|
325
|
+
layout, and which one is active. View tools target the active panel unless \
|
|
326
|
+
given a `panel` argument.",
|
|
327
|
+
entitlement: Entitlement::ReadView,
|
|
328
|
+
available: always,
|
|
329
|
+
parameters: |_| json!({ "type": "object", "properties": {} }),
|
|
330
|
+
call: |elem, ctx, args| Box::pin(elem.tool_list_panels(ctx, args)),
|
|
331
|
+
},
|
|
332
|
+
Tool {
|
|
333
|
+
name: "add_panel",
|
|
334
|
+
description: "Add a new independent panel to the dashboard layout, configured with the \
|
|
335
|
+
given view config - same fields as set_view_config, plus `table`, which is \
|
|
336
|
+
REQUIRED (a new panel must bind a data table). `columns` is also REQUIRED: \
|
|
337
|
+
omitting it would display every column in the table. Compose the panel's \
|
|
338
|
+
COMPLETE configuration (plugin, columns, group_by, expressions, ...) in \
|
|
339
|
+
this one call. Returns the new panel's id and resulting configuration. To \
|
|
340
|
+
duplicate an existing panel, pass a config obtained from get_view_config. \
|
|
341
|
+
`columns` is POSITIONAL - see `list_plugins` for the chosen plugin's roles.",
|
|
342
|
+
entitlement: Entitlement::ManageLayout,
|
|
343
|
+
available: always,
|
|
344
|
+
parameters: |schemas| {
|
|
345
|
+
config_parameters(
|
|
346
|
+
schemas,
|
|
347
|
+
"ViewerConfigInitial",
|
|
348
|
+
"The new panel's ViewerConfigInitial (`table` and `columns` required)",
|
|
349
|
+
false,
|
|
350
|
+
&["table", "columns"],
|
|
351
|
+
)
|
|
352
|
+
},
|
|
353
|
+
call: |elem, ctx, args| Box::pin(elem.tool_add_panel(ctx, args)),
|
|
354
|
+
},
|
|
355
|
+
Tool {
|
|
356
|
+
name: "remove_panel",
|
|
357
|
+
description: "Remove a panel by id, disposing its view. Removing every panel leaves an \
|
|
358
|
+
empty viewer.",
|
|
359
|
+
entitlement: Entitlement::ManageLayout,
|
|
360
|
+
available: always,
|
|
361
|
+
parameters: |_| {
|
|
362
|
+
json!({
|
|
363
|
+
"type": "object",
|
|
364
|
+
"properties": {
|
|
365
|
+
"panel": {
|
|
366
|
+
"type": "string",
|
|
367
|
+
"description": "Panel id from `list_panels`"
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
"required": ["panel"]
|
|
371
|
+
})
|
|
372
|
+
},
|
|
373
|
+
call: |elem, ctx, args| Box::pin(elem.tool_remove_panel(ctx, args)),
|
|
374
|
+
},
|
|
375
|
+
Tool {
|
|
376
|
+
name: "activate_panel",
|
|
377
|
+
description: "Make a panel active: the target of the settings sidebar and of view tools \
|
|
378
|
+
called without a `panel` argument.",
|
|
379
|
+
entitlement: Entitlement::ManageLayout,
|
|
380
|
+
available: always,
|
|
381
|
+
parameters: |_| {
|
|
382
|
+
json!({
|
|
383
|
+
"type": "object",
|
|
384
|
+
"properties": {
|
|
385
|
+
"panel": {
|
|
386
|
+
"type": "string",
|
|
387
|
+
"description": "Panel id from `list_panels`"
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
"required": ["panel"]
|
|
391
|
+
})
|
|
392
|
+
},
|
|
393
|
+
call: |elem, ctx, args| Box::pin(elem.tool_activate_panel(ctx, args)),
|
|
394
|
+
},
|
|
395
|
+
Tool {
|
|
396
|
+
name: "search_docs",
|
|
397
|
+
description: "Search the reference documentation: Perspective features, configuration \
|
|
398
|
+
fields, expression syntax, plugin capabilities, and any host-supplied data \
|
|
399
|
+
definitions. Queries are keyword-matched - prefer concrete terms (e.g. \
|
|
400
|
+
`expressions bucket date`, not full sentences), and re-query with different \
|
|
401
|
+
keywords if the results look irrelevant.",
|
|
402
|
+
entitlement: Entitlement::ReadDocs,
|
|
403
|
+
available: always,
|
|
404
|
+
parameters: |_| {
|
|
405
|
+
json!({
|
|
406
|
+
"type": "object",
|
|
407
|
+
"properties": {
|
|
408
|
+
"query": {
|
|
409
|
+
"type": "string",
|
|
410
|
+
"description": "Keyword search query"
|
|
411
|
+
},
|
|
412
|
+
"limit": {
|
|
413
|
+
"type": "integer",
|
|
414
|
+
"description": "Maximum results (default 4)"
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
"required": ["query"]
|
|
418
|
+
})
|
|
419
|
+
},
|
|
420
|
+
call: |elem, ctx, args| Box::pin(elem.tool_search_docs(ctx, args)),
|
|
421
|
+
},
|
|
422
|
+
];
|
|
423
|
+
|
|
424
|
+
fn granted(tool: &Tool, ctx: &ToolCtx) -> bool {
|
|
425
|
+
ctx.entitlements.contains(&tool.entitlement) && (tool.available)(ctx)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/// The tools advertised to the model for this turn.
|
|
429
|
+
pub fn tool_definitions(ctx: &ToolCtx) -> Vec<ToolDef> {
|
|
430
|
+
TOOLS
|
|
431
|
+
.iter()
|
|
432
|
+
.filter(|x| granted(x, ctx))
|
|
433
|
+
.map(|x| ToolDef {
|
|
434
|
+
kind: "function",
|
|
435
|
+
function: FunctionDef {
|
|
436
|
+
name: x.name,
|
|
437
|
+
description: x.description,
|
|
438
|
+
parameters: (x.parameters)(ctx.schemas()),
|
|
439
|
+
},
|
|
440
|
+
})
|
|
441
|
+
.collect()
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/// Run one tool call. `arguments` is the model's JSON-encoded argument
|
|
445
|
+
/// string; all failures (including undecodable arguments and unknown or
|
|
446
|
+
/// withheld names — indistinguishable, so no capability leak) surface as
|
|
447
|
+
/// `ToolError` for the model to correct. The transcript/CustomEvent
|
|
448
|
+
/// `emit` happens HERE, once, for every tool.
|
|
449
|
+
pub async fn tool_dispatch(
|
|
450
|
+
elem: &PerspectiveViewerElement,
|
|
451
|
+
ctx: &ToolCtx,
|
|
452
|
+
name: &str,
|
|
453
|
+
arguments: &str,
|
|
454
|
+
) -> Result<Value, ToolError> {
|
|
455
|
+
let tool = TOOLS
|
|
456
|
+
.iter()
|
|
457
|
+
.find(|x| x.name == name && granted(x, ctx))
|
|
458
|
+
.ok_or_else(|| ToolError(format!("Unknown tool `{name}`")))?;
|
|
459
|
+
|
|
460
|
+
let args: Value = if arguments.trim().is_empty() {
|
|
461
|
+
json!({})
|
|
462
|
+
} else {
|
|
463
|
+
serde_json::from_str(arguments)?
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
elem.agent_emit(name, &args);
|
|
467
|
+
let result = (tool.call)(elem, ctx, args).await;
|
|
468
|
+
if let Err(err) = &result {
|
|
469
|
+
elem.agent_emit_error(&format!("{err}"));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
result
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/// Tool failures are returned to the model as error tool-results so it can
|
|
476
|
+
/// self-correct (e.g. a rejected `ViewerConfigUpdate` patch).
|
|
477
|
+
#[derive(Debug, thiserror::Error)]
|
|
478
|
+
#[error("{0}")]
|
|
479
|
+
pub struct ToolError(String);
|
|
480
|
+
|
|
481
|
+
/// Columns whose type CHANGED under aggregation, reported alongside an
|
|
482
|
+
/// applied config.
|
|
483
|
+
fn retyped_columns(
|
|
484
|
+
session: &crate::session::Session,
|
|
485
|
+
config: &crate::config::ViewerConfig,
|
|
486
|
+
) -> Vec<Value> {
|
|
487
|
+
let metadata = session.metadata();
|
|
488
|
+
config
|
|
489
|
+
.view_config
|
|
490
|
+
.columns
|
|
491
|
+
.iter()
|
|
492
|
+
.flatten()
|
|
493
|
+
.filter_map(|name| {
|
|
494
|
+
let view_type = metadata.get_column_view_type(name)?;
|
|
495
|
+
let table_type = metadata.get_column_table_type(name)?;
|
|
496
|
+
if view_type == table_type {
|
|
497
|
+
return None;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
Some(json!({
|
|
501
|
+
"name": name,
|
|
502
|
+
"type": view_type,
|
|
503
|
+
"source_type": table_type,
|
|
504
|
+
"aggregate": config.view_config.aggregates.get(name),
|
|
505
|
+
}))
|
|
506
|
+
})
|
|
507
|
+
.collect()
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/// Append a `search_docs` pointer to a config-application error when a docs
|
|
511
|
+
/// corpus is configured — a rejected config is the moment the model most
|
|
512
|
+
/// needs the reference, and self-correction errors are the one channel it
|
|
513
|
+
/// reliably reads (the preamble's docs step alone was not enough in the
|
|
514
|
+
/// field). No-op without docs, where the tool does not exist.
|
|
515
|
+
fn with_docs_hint(ctx: &ToolCtx, err: ToolError) -> ToolError {
|
|
516
|
+
if ctx.docs.is_some() {
|
|
517
|
+
ToolError(format!(
|
|
518
|
+
"{} If a field or value was rejected, its reference documentation is available - call \
|
|
519
|
+
`search_docs` with the failing field or function name.",
|
|
520
|
+
err.0
|
|
521
|
+
))
|
|
522
|
+
} else {
|
|
523
|
+
err
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
impl From<ApiError> for ToolError {
|
|
528
|
+
fn from(err: ApiError) -> Self {
|
|
529
|
+
Self(format!("{err}"))
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
impl From<serde_json::Error> for ToolError {
|
|
534
|
+
fn from(err: serde_json::Error) -> Self {
|
|
535
|
+
Self(format!("{err}"))
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
impl From<JsValue> for ToolError {
|
|
540
|
+
fn from(err: JsValue) -> Self {
|
|
541
|
+
Self(
|
|
542
|
+
err.as_string()
|
|
543
|
+
.or_else(|| {
|
|
544
|
+
js_sys::Reflect::get(&err, &JsValue::from_str("message"))
|
|
545
|
+
.ok()
|
|
546
|
+
.and_then(|x| x.as_string())
|
|
547
|
+
})
|
|
548
|
+
.unwrap_or_else(|| format!("{err:?}")),
|
|
549
|
+
)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
#[derive(Deserialize)]
|
|
554
|
+
struct PanelArgs {
|
|
555
|
+
panel: Option<String>,
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
#[derive(Deserialize)]
|
|
559
|
+
struct SetViewConfigArgs {
|
|
560
|
+
config: Value,
|
|
561
|
+
panel: Option<String>,
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
#[derive(Deserialize)]
|
|
565
|
+
struct ValidateExpressionArgs {
|
|
566
|
+
expression: String,
|
|
567
|
+
panel: Option<String>,
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
#[derive(Deserialize)]
|
|
571
|
+
struct AddPanelArgs {
|
|
572
|
+
config: Value,
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
#[derive(Deserialize)]
|
|
576
|
+
struct PanelNameArgs {
|
|
577
|
+
panel: String,
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
#[derive(Deserialize)]
|
|
581
|
+
struct SearchDocsArgs {
|
|
582
|
+
query: String,
|
|
583
|
+
limit: Option<usize>,
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
#[derive(Deserialize)]
|
|
587
|
+
struct StyleSchemaArgs {
|
|
588
|
+
column: Option<String>,
|
|
589
|
+
panel: Option<String>,
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
fn control_schema_entries(spec: &ControlSpec) -> Vec<(String, Value)> {
|
|
593
|
+
match spec {
|
|
594
|
+
ControlSpec::Enum {
|
|
595
|
+
key,
|
|
596
|
+
variants,
|
|
597
|
+
default,
|
|
598
|
+
} => vec![(
|
|
599
|
+
key.clone(),
|
|
600
|
+
json!({
|
|
601
|
+
"type": "string",
|
|
602
|
+
"enum": variants.iter().map(|x| x.value.clone()).collect::<Vec<_>>(),
|
|
603
|
+
"default": default
|
|
604
|
+
}),
|
|
605
|
+
)],
|
|
606
|
+
ControlSpec::Bool { key, default } => vec![(
|
|
607
|
+
key.clone(),
|
|
608
|
+
json!({ "type": "boolean", "default": default }),
|
|
609
|
+
)],
|
|
610
|
+
ControlSpec::Number {
|
|
611
|
+
key,
|
|
612
|
+
default,
|
|
613
|
+
min,
|
|
614
|
+
max,
|
|
615
|
+
..
|
|
616
|
+
} => {
|
|
617
|
+
let mut node = json!({ "type": "number", "default": default });
|
|
618
|
+
if let Some(min) = min {
|
|
619
|
+
node["minimum"] = json!(min);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if let Some(max) = max {
|
|
623
|
+
node["maximum"] = json!(max);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
vec![(key.clone(), node)]
|
|
627
|
+
},
|
|
628
|
+
ControlSpec::String { key, default, .. } => {
|
|
629
|
+
vec![(key.clone(), json!({ "type": "string", "default": default }))]
|
|
630
|
+
},
|
|
631
|
+
ControlSpec::Color { key, default } => vec![(
|
|
632
|
+
key.clone(),
|
|
633
|
+
json!({ "type": "string", "format": "color", "default": default }),
|
|
634
|
+
)],
|
|
635
|
+
ControlSpec::ColorRange {
|
|
636
|
+
key_pos,
|
|
637
|
+
key_neg,
|
|
638
|
+
default_pos,
|
|
639
|
+
default_neg,
|
|
640
|
+
..
|
|
641
|
+
} => vec![
|
|
642
|
+
(
|
|
643
|
+
key_pos.clone(),
|
|
644
|
+
json!({ "type": "string", "format": "color", "default": default_pos }),
|
|
645
|
+
),
|
|
646
|
+
(
|
|
647
|
+
key_neg.clone(),
|
|
648
|
+
json!({ "type": "string", "format": "color", "default": default_neg }),
|
|
649
|
+
),
|
|
650
|
+
],
|
|
651
|
+
ControlSpec::DatetimeFormat => vec![(
|
|
652
|
+
"date_format".to_owned(),
|
|
653
|
+
json!({ "description": "Datetime display format: a style preset or custom format fields" }),
|
|
654
|
+
)],
|
|
655
|
+
ControlSpec::StringFormat => vec![(
|
|
656
|
+
"format".to_owned(),
|
|
657
|
+
json!({ "description": "String display format" }),
|
|
658
|
+
)],
|
|
659
|
+
ControlSpec::NumberSeriesStyle { .. } => vec![
|
|
660
|
+
(
|
|
661
|
+
"chart_type".to_owned(),
|
|
662
|
+
json!({ "type": "string", "description": "Per-series glyph override" }),
|
|
663
|
+
),
|
|
664
|
+
(
|
|
665
|
+
"stack".to_owned(),
|
|
666
|
+
json!({ "description": "Series stack group" }),
|
|
667
|
+
),
|
|
668
|
+
],
|
|
669
|
+
ControlSpec::Symbols { .. } => vec![(
|
|
670
|
+
"symbols".to_owned(),
|
|
671
|
+
json!({ "type": "object", "description": "Map of column values to symbol names" }),
|
|
672
|
+
)],
|
|
673
|
+
ControlSpec::NumberFormat => vec![(
|
|
674
|
+
"number_format".to_owned(),
|
|
675
|
+
json!({ "type": "object", "description": "Intl.NumberFormat-style options" }),
|
|
676
|
+
)],
|
|
677
|
+
ControlSpec::AggregateDepth => vec![(
|
|
678
|
+
"aggregate_depth".to_owned(),
|
|
679
|
+
json!({ "type": "integer", "description": "Group-by rollup depth override" }),
|
|
680
|
+
)],
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
fn schema_object(schema: &ColumnConfigSchema) -> Value {
|
|
685
|
+
let mut properties = serde_json::Map::new();
|
|
686
|
+
for spec in &schema.fields {
|
|
687
|
+
for (key, node) in control_schema_entries(spec) {
|
|
688
|
+
properties.insert(key, node);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
json!({ "type": "object", "properties": properties })
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const SEARCH_DOCS_LIMIT: usize = 4;
|
|
696
|
+
|
|
697
|
+
/// Tool bodies and their shared helpers — plain (non-bindgen) methods.
|
|
698
|
+
impl PerspectiveViewerElement {
|
|
699
|
+
/// Resolve a tool's panel target: a named panel, or the active panel
|
|
700
|
+
/// when `None`. Unknown names error with the current panel list so the
|
|
701
|
+
/// model can self-correct.
|
|
702
|
+
fn agent_panel(&self, target: Option<&str>) -> Result<Panel, ToolError> {
|
|
703
|
+
let id = target.map(|x| PanelId::from(x.to_owned()));
|
|
704
|
+
self.workspace
|
|
705
|
+
.panel_or_active(id.as_ref())
|
|
706
|
+
.ok_or_else(|| match target {
|
|
707
|
+
Some(name) => ToolError(format!(
|
|
708
|
+
"Unknown panel `{name}` - current panels: {:?}",
|
|
709
|
+
self.agent_panel_names()
|
|
710
|
+
)),
|
|
711
|
+
None => ToolError("No panel - the viewer has no data loaded yet".to_owned()),
|
|
712
|
+
})
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
fn agent_panel_names(&self) -> Vec<String> {
|
|
716
|
+
self.workspace
|
|
717
|
+
.panel_ids()
|
|
718
|
+
.iter()
|
|
719
|
+
.map(|x| x.as_str().to_owned())
|
|
720
|
+
.collect()
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
fn agent_active_name(&self) -> Option<String> {
|
|
724
|
+
self.workspace.active_id().map(|x| x.as_str().to_owned())
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/// Record tool activity on the transcript (chat UI) and notify host
|
|
728
|
+
/// listeners (`perspective-agent-tool`).
|
|
729
|
+
fn agent_emit_error(&self, message: &str) {
|
|
730
|
+
self.presentation.agent.record_tool_error(message);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
fn agent_emit(&self, name: &str, args: &Value) {
|
|
734
|
+
self.presentation.agent.record_tool(name, args);
|
|
735
|
+
let detail: JsValue = json!({ "name": name, "args": args }).to_string().into();
|
|
736
|
+
let event_init = web_sys::CustomEventInit::new();
|
|
737
|
+
event_init.set_bubbles(true);
|
|
738
|
+
event_init.set_detail(&detail);
|
|
739
|
+
if let Ok(event) =
|
|
740
|
+
web_sys::CustomEvent::new_with_event_init_dict("perspective-agent-tool", &event_init)
|
|
741
|
+
{
|
|
742
|
+
let _ = self.elem.dispatch_event(&event);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
async fn tool_get_schema(&self, _ctx: &ToolCtx, args: Value) -> Result<Value, ToolError> {
|
|
747
|
+
let args: PanelArgs = serde_json::from_value(args)?;
|
|
748
|
+
let panel = self.agent_panel(args.panel.as_deref())?;
|
|
749
|
+
let table = panel
|
|
750
|
+
.session
|
|
751
|
+
.get_table()
|
|
752
|
+
.ok_or_else(|| ToolError("No table loaded".to_owned()))?;
|
|
753
|
+
|
|
754
|
+
let schema = table.schema().await.map_err(ApiError::from)?;
|
|
755
|
+
let size = table.size().await.map_err(ApiError::from)?;
|
|
756
|
+
Ok(json!({
|
|
757
|
+
"table": table.get_name(),
|
|
758
|
+
"columns": serde_json::to_value(&schema)?,
|
|
759
|
+
"num_rows": size,
|
|
760
|
+
}))
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
async fn tool_get_view_config(&self, _ctx: &ToolCtx, args: Value) -> Result<Value, ToolError> {
|
|
764
|
+
let args: PanelArgs = serde_json::from_value(args)?;
|
|
765
|
+
let panel = self.agent_panel(args.panel.as_deref())?;
|
|
766
|
+
let config = get_viewer_config(&panel.session, &panel.renderer, &self.presentation).await?;
|
|
767
|
+
Ok(serde_json::to_value(&config)?)
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/// Applies via the public `restore()` with `suppress_errors: true`.
|
|
771
|
+
async fn tool_set_view_config(&self, ctx: &ToolCtx, args: Value) -> Result<Value, ToolError> {
|
|
772
|
+
let args: SetViewConfigArgs = serde_json::from_value(args)?;
|
|
773
|
+
let target = args.panel.as_deref();
|
|
774
|
+
let panel = self.agent_panel(target)?;
|
|
775
|
+
let snapshot =
|
|
776
|
+
get_viewer_config(&panel.session, &panel.renderer, &self.presentation).await?;
|
|
777
|
+
|
|
778
|
+
if let Err(err) = self.agent_restore(target, &args.config).await {
|
|
779
|
+
let rolled = self
|
|
780
|
+
.agent_restore(target, &serde_json::to_value(&snapshot)?)
|
|
781
|
+
.await;
|
|
782
|
+
|
|
783
|
+
let err = match rolled {
|
|
784
|
+
Ok(()) => err,
|
|
785
|
+
Err(rollback_err) => ToolError(format!(
|
|
786
|
+
"{err} (additionally, restoring the prior config failed: {rollback_err})"
|
|
787
|
+
)),
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
return Err(with_docs_hint(ctx, err));
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
let panel = self.agent_panel(target)?;
|
|
794
|
+
let config = get_viewer_config(&panel.session, &panel.renderer, &self.presentation).await?;
|
|
795
|
+
let mut result = serde_json::to_value(&config)?;
|
|
796
|
+
let retyped = retyped_columns(&panel.session, &config);
|
|
797
|
+
if !retyped.is_empty() {
|
|
798
|
+
result["aggregation_changed_types"] = json!(retyped);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
Ok(result)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/// One public-API `restore()` call with `suppress_errors: true`.
|
|
805
|
+
async fn agent_restore(&self, target: Option<&str>, config: &Value) -> Result<(), ToolError> {
|
|
806
|
+
let options = js_sys::Object::new();
|
|
807
|
+
js_sys::Reflect::set(
|
|
808
|
+
&options,
|
|
809
|
+
&JsValue::from_str("suppress_errors"),
|
|
810
|
+
&JsValue::TRUE,
|
|
811
|
+
)?;
|
|
812
|
+
|
|
813
|
+
if let Some(name) = target {
|
|
814
|
+
js_sys::Reflect::set(
|
|
815
|
+
&options,
|
|
816
|
+
&JsValue::from_str("panel"),
|
|
817
|
+
&JsValue::from_str(name),
|
|
818
|
+
)?;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
let js_config = js_sys::JSON::parse(&config.to_string())?;
|
|
822
|
+
let restored = self.restore(js_config.unchecked_into(), Some(options.unchecked_into()));
|
|
823
|
+
let promise: js_sys::Promise = JsValue::from(restored).unchecked_into();
|
|
824
|
+
wasm_bindgen_futures::JsFuture::from(promise).await?;
|
|
825
|
+
Ok(())
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async fn tool_list_plugins(&self, _ctx: &ToolCtx, _args: Value) -> Result<Value, ToolError> {
|
|
829
|
+
let panel = self.agent_panel(None)?;
|
|
830
|
+
let plugins = panel
|
|
831
|
+
.renderer
|
|
832
|
+
.get_all_plugin_configs()
|
|
833
|
+
.iter()
|
|
834
|
+
.map(|config| {
|
|
835
|
+
let mut value = json!({
|
|
836
|
+
"name": config.name,
|
|
837
|
+
"category": config.category,
|
|
838
|
+
"columns": {
|
|
839
|
+
"roles": config.config_column_names,
|
|
840
|
+
"required": config.min_config_columns.unwrap_or(1),
|
|
841
|
+
"extra_columns": config.tail_column_role(),
|
|
842
|
+
},
|
|
843
|
+
"ordering": if config.connects_row_order {
|
|
844
|
+
json!("connects points in row order - `sort` by the \
|
|
845
|
+
X-axis column unless the table already \
|
|
846
|
+
arrives ordered")
|
|
847
|
+
} else {
|
|
848
|
+
Value::Null
|
|
849
|
+
},
|
|
850
|
+
"select_mode": match config.select_mode {
|
|
851
|
+
crate::config::ColumnSelectMode::Select => "select",
|
|
852
|
+
crate::config::ColumnSelectMode::Toggle => "toggle",
|
|
853
|
+
},
|
|
854
|
+
"supports_column_styles": config.can_render_column_styles,
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
if let Some(max) = config.max_columns {
|
|
858
|
+
value["columns"]["max"] = json!(max);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
value["group_by"] = match &config.group_by_role {
|
|
862
|
+
Some(role) => json!(role),
|
|
863
|
+
None => json!("aggregates rows; no visual role"),
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
value["split_by"] = match &config.split_by_role {
|
|
867
|
+
Some(role) => json!(role),
|
|
868
|
+
None => json!("aggregates rows; no visual role"),
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
value
|
|
872
|
+
})
|
|
873
|
+
.collect::<Vec<_>>();
|
|
874
|
+
|
|
875
|
+
Ok(json!({
|
|
876
|
+
"plugins": plugins,
|
|
877
|
+
"columns_are_positional": "`columns[i]` fills the role at `roles[i]`; columns past the last named role repeat `extra_columns`.",
|
|
878
|
+
}))
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async fn tool_validate_expression(
|
|
882
|
+
&self,
|
|
883
|
+
ctx: &ToolCtx,
|
|
884
|
+
args: Value,
|
|
885
|
+
) -> Result<Value, ToolError> {
|
|
886
|
+
let args: ValidateExpressionArgs = serde_json::from_value(args)?;
|
|
887
|
+
let panel = self.agent_panel(args.panel.as_deref())?;
|
|
888
|
+
match validate_expr(&panel.session, &args.expression).await? {
|
|
889
|
+
None => Ok(json!({ "valid": true })),
|
|
890
|
+
Some(err) => {
|
|
891
|
+
let mut result = json!({
|
|
892
|
+
"valid": false,
|
|
893
|
+
"error": err.error_message,
|
|
894
|
+
"line": err.line,
|
|
895
|
+
"column": err.column,
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
if ctx.docs.is_some() {
|
|
899
|
+
result["hint"] = json!(
|
|
900
|
+
"ExprTK syntax and functions are documented - call `search_docs` with the \
|
|
901
|
+
function name or feature, e.g. `expressions bucket`."
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
Ok(result)
|
|
906
|
+
},
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
async fn tool_list_panels(&self, _ctx: &ToolCtx, _args: Value) -> Result<Value, ToolError> {
|
|
911
|
+
Ok(json!({
|
|
912
|
+
"panels": self.agent_panel_names(),
|
|
913
|
+
"active": self.agent_active_name(),
|
|
914
|
+
}))
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async fn tool_add_panel(&self, ctx: &ToolCtx, args: Value) -> Result<Value, ToolError> {
|
|
918
|
+
let args: AddPanelArgs = serde_json::from_value(args)?;
|
|
919
|
+
if let Err(err) =
|
|
920
|
+
serde_json::from_value::<crate::config::ViewerConfigInitial>(args.config.clone())
|
|
921
|
+
{
|
|
922
|
+
let mut message = format!("Invalid panel config: {err}");
|
|
923
|
+
if message.contains("table")
|
|
924
|
+
&& let Some(names) = self.agent_hosted_table_names().await
|
|
925
|
+
{
|
|
926
|
+
message.push_str(&format!(" - available tables: {names:?}"));
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
return Err(with_docs_hint(ctx, ToolError(message)));
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
match args.config.get("columns") {
|
|
933
|
+
Some(Value::Array(columns)) if !columns.is_empty() => {},
|
|
934
|
+
_ => {
|
|
935
|
+
return Err(ToolError(
|
|
936
|
+
"Invalid panel config: a new panel requires a non-empty `columns` list \
|
|
937
|
+
(omitting it would display every column in the table). Choose the columns - \
|
|
938
|
+
including any `expressions` names - this panel should display."
|
|
939
|
+
.to_owned(),
|
|
940
|
+
));
|
|
941
|
+
},
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
let before = self.agent_panel_names();
|
|
945
|
+
let js_config = js_sys::JSON::parse(&args.config.to_string())?;
|
|
946
|
+
let added = self.addPanel(js_config.unchecked_into());
|
|
947
|
+
let result = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(added)).await;
|
|
948
|
+
let id = match result {
|
|
949
|
+
Ok(id) => id
|
|
950
|
+
.as_string()
|
|
951
|
+
.ok_or_else(|| ToolError("addPanel returned no id".to_owned()))?,
|
|
952
|
+
Err(err) => {
|
|
953
|
+
for name in self.agent_panel_names() {
|
|
954
|
+
if !before.contains(&name) {
|
|
955
|
+
let removed = self.removePanel(name);
|
|
956
|
+
let _ =
|
|
957
|
+
wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(removed))
|
|
958
|
+
.await;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
return Err(with_docs_hint(ctx, ToolError::from(err)));
|
|
963
|
+
},
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
let panel = self.agent_panel(Some(&id))?;
|
|
967
|
+
let config = get_viewer_config(&panel.session, &panel.renderer, &self.presentation).await?;
|
|
968
|
+
let mut result = json!({
|
|
969
|
+
"panel": id,
|
|
970
|
+
"config": serde_json::to_value(&config)?,
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
let retyped = retyped_columns(&panel.session, &config);
|
|
974
|
+
if !retyped.is_empty() {
|
|
975
|
+
result["aggregation_changed_types"] = json!(retyped);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
Ok(result)
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/// Best-effort list of the active panel's client's hosted table names,
|
|
982
|
+
/// for self-correction error messages.
|
|
983
|
+
async fn agent_hosted_table_names(&self) -> Option<Vec<String>> {
|
|
984
|
+
let panel = self.agent_panel(None).ok()?;
|
|
985
|
+
let client = panel.session.get_client()?;
|
|
986
|
+
client.get_hosted_table_names().await.ok()
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
async fn tool_remove_panel(&self, _ctx: &ToolCtx, args: Value) -> Result<Value, ToolError> {
|
|
990
|
+
let args: PanelNameArgs = serde_json::from_value(args)?;
|
|
991
|
+
self.agent_panel(Some(&args.panel))?;
|
|
992
|
+
let removed = self.removePanel(args.panel);
|
|
993
|
+
wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(removed)).await?;
|
|
994
|
+
Ok(json!({
|
|
995
|
+
"panels": self.agent_panel_names(),
|
|
996
|
+
"active": self.agent_active_name(),
|
|
997
|
+
}))
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
async fn tool_activate_panel(&self, _ctx: &ToolCtx, args: Value) -> Result<Value, ToolError> {
|
|
1001
|
+
let args: PanelNameArgs = serde_json::from_value(args)?;
|
|
1002
|
+
self.agent_panel(Some(&args.panel))?;
|
|
1003
|
+
let activated = self.setActivePanel(args.panel);
|
|
1004
|
+
wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(activated)).await?;
|
|
1005
|
+
Ok(json!({ "active": self.agent_active_name() }))
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
async fn tool_get_style_schema(&self, _ctx: &ToolCtx, args: Value) -> Result<Value, ToolError> {
|
|
1009
|
+
let args: StyleSchemaArgs = serde_json::from_value(args)?;
|
|
1010
|
+
let panel = self.agent_panel(args.panel.as_deref())?;
|
|
1011
|
+
let viewer_config =
|
|
1012
|
+
get_viewer_config(&panel.session, &panel.renderer, &self.presentation).await?;
|
|
1013
|
+
|
|
1014
|
+
let columns_config = serde_json::to_value(&viewer_config.columns_config)?;
|
|
1015
|
+
let plugin_schema = {
|
|
1016
|
+
let view_config = panel.session.get_view_config();
|
|
1017
|
+
get_plugin_config_schema(&panel.renderer, &view_config).ok()
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
let column_schema = match &args.column {
|
|
1021
|
+
None => None,
|
|
1022
|
+
Some(column) => {
|
|
1023
|
+
let metadata = panel.session.metadata();
|
|
1024
|
+
if metadata.get_column_view_type(column).is_none() {
|
|
1025
|
+
let view_config = panel.session.get_view_config();
|
|
1026
|
+
let active = view_config
|
|
1027
|
+
.columns
|
|
1028
|
+
.iter()
|
|
1029
|
+
.flatten()
|
|
1030
|
+
.cloned()
|
|
1031
|
+
.collect::<Vec<_>>();
|
|
1032
|
+
|
|
1033
|
+
return Err(ToolError(format!(
|
|
1034
|
+
"Unknown or inactive column `{column}` - active columns: {active:?}"
|
|
1035
|
+
)));
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
let current = columns_config.get(column).and_then(|x| x.as_object());
|
|
1039
|
+
let view_config = panel.session.get_view_config();
|
|
1040
|
+
Some(
|
|
1041
|
+
get_column_config_schema(
|
|
1042
|
+
&panel.renderer,
|
|
1043
|
+
&view_config,
|
|
1044
|
+
&metadata,
|
|
1045
|
+
column,
|
|
1046
|
+
current,
|
|
1047
|
+
None,
|
|
1048
|
+
)
|
|
1049
|
+
.ok(),
|
|
1050
|
+
)
|
|
1051
|
+
},
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
let mut out = json!({
|
|
1055
|
+
"plugin": viewer_config.plugin,
|
|
1056
|
+
"plugin_config": plugin_schema.as_ref().map(schema_object),
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
if let Some(column) = &args.column {
|
|
1060
|
+
out["column"] = json!(column);
|
|
1061
|
+
out["column_config"] = json!(column_schema.flatten().as_ref().map(schema_object));
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
if out["plugin_config"].is_null() && out["column_config"].is_null() {
|
|
1065
|
+
out["note"] = json!(
|
|
1066
|
+
"The active plugin declares no style schema; `plugin_config` and `columns_config` \
|
|
1067
|
+
keys pass through unvalidated."
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
Ok(out)
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
async fn tool_search_docs(&self, ctx: &ToolCtx, args: Value) -> Result<Value, ToolError> {
|
|
1075
|
+
let args: SearchDocsArgs = serde_json::from_value(args)?;
|
|
1076
|
+
let Some(bundle) = ctx.bundle.as_ref() else {
|
|
1077
|
+
return Ok(json!({
|
|
1078
|
+
"results": [],
|
|
1079
|
+
"hint": "No documentation is configured for this viewer",
|
|
1080
|
+
}));
|
|
1081
|
+
};
|
|
1082
|
+
|
|
1083
|
+
match bundle.as_ref() {
|
|
1084
|
+
Err(err) => Err(ToolError(format!("Docs failed to load: {err}"))),
|
|
1085
|
+
Ok(bundle) => {
|
|
1086
|
+
let results = bundle
|
|
1087
|
+
.index
|
|
1088
|
+
.search(&args.query, args.limit.unwrap_or(SEARCH_DOCS_LIMIT))
|
|
1089
|
+
.iter()
|
|
1090
|
+
.map(|chunk| {
|
|
1091
|
+
json!({
|
|
1092
|
+
"title": chunk.title,
|
|
1093
|
+
"path": chunk.path,
|
|
1094
|
+
"text": chunk.text,
|
|
1095
|
+
})
|
|
1096
|
+
})
|
|
1097
|
+
.collect::<Vec<_>>();
|
|
1098
|
+
|
|
1099
|
+
if results.is_empty() {
|
|
1100
|
+
Ok(json!({
|
|
1101
|
+
"results": [],
|
|
1102
|
+
"hint": "No matches - retry with different, more specific keywords",
|
|
1103
|
+
}))
|
|
1104
|
+
} else {
|
|
1105
|
+
Ok(json!({ "results": results }))
|
|
1106
|
+
}
|
|
1107
|
+
},
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|