@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,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
|
+
}
|