@perspective-dev/viewer 5.0.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/icons.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 +43 -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 +43 -1
- package/dist/esm/ts-rs/ViewerConfigInitial.d.ts +175 -0
- package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +72 -0
- package/dist/esm/ts-rs/WindowSort.d.ts +7 -0
- package/dist/esm/ts-rs/WindowSortDir.d.ts +6 -0
- package/dist/esm/ts-rs/WindowSpec.d.ts +47 -0
- package/dist/esm/ts-rs/Windows.d.ts +10 -0
- package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +7 -6
- package/dist/wasm/perspective-viewer.d.ts +222 -26
- package/dist/wasm/perspective-viewer.js +378 -125
- 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 +9 -0
- package/src/css/column-settings-panel.css +67 -2
- package/src/css/column-style.css +4 -3
- package/src/css/config-selector.css +60 -4
- package/src/css/containers/context-menu.css +12 -6
- package/src/css/containers/tabs.css +2 -2
- package/src/css/form/code-editor.css +2 -3
- package/src/css/form/mirrored-textarea.css +81 -0
- package/src/css/viewer.css +336 -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/active_column.rs +21 -27
- package/src/rust/components/column_selector/column_selector_column_row.rs +73 -0
- package/src/rust/components/column_selector/config_selector.rs +83 -8
- package/src/rust/components/column_selector/expr_edit_button.rs +7 -1
- package/src/rust/components/column_selector/inactive_column.rs +8 -1
- package/src/rust/components/column_selector.rs +27 -3
- package/src/rust/components/column_settings_sidebar/style_tab.rs +2 -1
- package/src/rust/components/column_settings_sidebar/window_tab.rs +34 -0
- package/src/rust/components/column_settings_sidebar.rs +163 -16
- package/src/rust/components/containers/dragdrop_list.rs +110 -3
- package/src/rust/components/containers/sidebar.rs +99 -22
- package/src/rust/components/containers/split_panel.rs +6 -0
- package/src/rust/components/editable_header.rs +2 -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/main_panel.rs +5 -0
- package/src/rust/components/mod.rs +3 -0
- package/src/rust/components/panel_menu.rs +7 -0
- package/src/rust/components/panel_tab.rs +5 -0
- package/src/rust/components/portal.rs +7 -0
- package/src/rust/components/settings_panel.rs +80 -2
- package/src/rust/components/viewer/msg.rs +2 -0
- package/src/rust/components/viewer/panels.rs +9 -14
- package/src/rust/components/viewer/render.rs +14 -0
- package/src/rust/components/viewer/settings.rs +33 -1
- package/src/rust/components/viewer.rs +2 -0
- package/src/rust/components/window_editor.rs +985 -0
- 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 +17 -1
- package/src/rust/presentation/column_locator.rs +16 -1
- package/src/rust/presentation.rs +8 -0
- package/src/rust/queries/columns_iter_set.rs +7 -1
- package/src/rust/queries/get_viewer_config.rs +5 -1
- package/src/rust/renderer/dispatch.rs +85 -58
- package/src/rust/renderer.rs +34 -1
- package/src/rust/session/column_defaults_update.rs +18 -0
- package/src/rust/session/drag_drop_update.rs +18 -0
- package/src/rust/session/metadata.rs +64 -9
- package/src/rust/session/replace_expression_update.rs +2 -0
- package/src/rust/session.rs +7 -5
- package/src/rust/tasks/create_panel.rs +18 -8
- package/src/rust/tasks/edit_window.rs +155 -0
- package/src/rust/tasks/mod.rs +2 -0
- package/src/rust/tasks/restore_panel.rs +34 -17
- package/src/rust/tasks/sync_update_panels.rs +7 -23
- package/src/rust/utils/browser/dragdrop.rs +12 -0
- package/src/rust/utils/browser/tests/debounce.rs +49 -0
- package/src/rust/utils/debounce.rs +76 -27
- package/src/rust/workspace.rs +112 -20
- package/src/svg/pin-icon.svg +5 -0
- package/src/themes/icons.css +1 -0
- package/src/themes/intl/de.css +23 -0
- package/src/themes/intl/es.css +23 -0
- package/src/themes/intl/fr.css +23 -0
- package/src/themes/intl/ja.css +23 -0
- package/src/themes/intl/pt.css +23 -0
- package/src/themes/intl/zh.css +23 -0
- package/src/themes/intl.css +26 -1
- 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 +48 -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 +48 -1
- package/src/ts/ts-rs/ViewerConfigInitial.ts +164 -0
- package/src/ts/ts-rs/ViewerConfigUpdate.ts +81 -2
- package/src/ts/ts-rs/WindowSort.ts +9 -0
- package/src/ts/ts-rs/WindowSortDir.ts +8 -0
- package/src/ts/ts-rs/WindowSpec.ts +47 -0
- package/src/ts/ts-rs/Windows.ts +10 -0
- package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +7 -6
- /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline0.js +0 -0
- /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline1.js +0 -0
- /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline2.js +0 -0
- /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline3.js +0 -0
- /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline4.js +0 -0
- /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline5.js +0 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
|
2
|
+
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
|
3
|
+
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
|
4
|
+
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
|
5
|
+
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
|
6
|
+
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
|
7
|
+
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
|
8
|
+
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
|
9
|
+
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
|
10
|
+
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
|
11
|
+
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
|
12
|
+
|
|
13
|
+
//! How completion requests move: browser `fetch()` for HTTP providers, or a
|
|
14
|
+
//! host-supplied duck-typed engine object (WebLLM's `MLCEngine`, or the
|
|
15
|
+
//! scripted fake the Playwright specs inject) — the same seam either way,
|
|
16
|
+
//! so the offline specs exercise the identical request/response path the
|
|
17
|
+
//! HTTP providers use.
|
|
18
|
+
//!
|
|
19
|
+
//! Every request asks for `stream: true` and every response path degrades
|
|
20
|
+
//! automatically, so streaming needs no configuration surface: an HTTP
|
|
21
|
+
//! response that is not `text/event-stream` and an engine result that is
|
|
22
|
+
//! not async-iterable are both consumed as complete buffered responses.
|
|
23
|
+
//! Either way the caller receives ONE folded [`ChatMessage`], with
|
|
24
|
+
//! incremental progress reported through `on_delta`.
|
|
25
|
+
|
|
26
|
+
use perspective_js::utils::{ApiError, ApiResult};
|
|
27
|
+
use wasm_bindgen::prelude::*;
|
|
28
|
+
use wasm_bindgen_futures::JsFuture;
|
|
29
|
+
|
|
30
|
+
use super::protocol::{ChatChunk, ChatMessage, ChatRequest, ChatResponse, ChunkAccumulator};
|
|
31
|
+
|
|
32
|
+
/// Live progress callback: `(content_so_far, reasoning_so_far)`.
|
|
33
|
+
pub type OnDelta<'a> = &'a dyn Fn(&str, &str);
|
|
34
|
+
|
|
35
|
+
/// A configured connection to a completions endpoint. Static dispatch — two
|
|
36
|
+
/// variants don't warrant a trait object.
|
|
37
|
+
pub enum AgentTransport {
|
|
38
|
+
/// POST to an OpenAI-compatible `/chat/completions` URL with the given
|
|
39
|
+
/// extra request headers (auth etc.).
|
|
40
|
+
Fetch {
|
|
41
|
+
url: String,
|
|
42
|
+
headers: Vec<(String, String)>,
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
/// An in-page object exposing `chat.completions.create(request)`.
|
|
46
|
+
/// No bytes leave the page.
|
|
47
|
+
Engine(JsValue),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
impl AgentTransport {
|
|
51
|
+
/// One completion request, folded to the response message. Cancellation
|
|
52
|
+
/// is by dropping the returned future (see [`AbortGuard`]).
|
|
53
|
+
pub async fn create(
|
|
54
|
+
&self,
|
|
55
|
+
request: &ChatRequest<'_>,
|
|
56
|
+
on_delta: OnDelta<'_>,
|
|
57
|
+
) -> ApiResult<ChatMessage> {
|
|
58
|
+
let body = serde_json::to_string(request)?;
|
|
59
|
+
match self {
|
|
60
|
+
Self::Fetch { url, headers } => fetch_completion(url, headers, &body, on_delta).await,
|
|
61
|
+
Self::Engine(engine) => engine_completion(engine, &body, on_delta).await,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Parse a complete (non-streamed) chat-completions response body.
|
|
67
|
+
fn from_response_text(text: &str) -> ApiResult<ChatMessage> {
|
|
68
|
+
let response: ChatResponse = serde_json::from_str(text)
|
|
69
|
+
.map_err(|x| ApiError::from(format!("Malformed completion response: {x}")))?;
|
|
70
|
+
|
|
71
|
+
response
|
|
72
|
+
.choices
|
|
73
|
+
.into_iter()
|
|
74
|
+
.next()
|
|
75
|
+
.map(|x| x.message)
|
|
76
|
+
.ok_or_else(|| ApiError::from("Completion response has no choices"))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// Aborts the in-flight browser fetch when dropped, wiring the viewer's
|
|
80
|
+
/// future-cancellation model (`AgentSlot`'s `Abortable`, the chat Stop
|
|
81
|
+
/// button) through to the network layer. The fetch `signal` also covers the
|
|
82
|
+
/// response body stream, so aborting mid-SSE kills the socket — no separate
|
|
83
|
+
/// reader cancel is needed. Aborting an already-settled fetch is a no-op,
|
|
84
|
+
/// so the guard needs no disarm path.
|
|
85
|
+
struct AbortGuard(web_sys::AbortController);
|
|
86
|
+
|
|
87
|
+
impl Drop for AbortGuard {
|
|
88
|
+
fn drop(&mut self) {
|
|
89
|
+
self.0.abort();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async fn fetch_completion(
|
|
94
|
+
url: &str,
|
|
95
|
+
headers: &[(String, String)],
|
|
96
|
+
body: &str,
|
|
97
|
+
on_delta: OnDelta<'_>,
|
|
98
|
+
) -> ApiResult<ChatMessage> {
|
|
99
|
+
let controller = web_sys::AbortController::new()?;
|
|
100
|
+
let _guard = AbortGuard(controller.clone());
|
|
101
|
+
let request_headers = web_sys::Headers::new()?;
|
|
102
|
+
request_headers.append("content-type", "application/json")?;
|
|
103
|
+
for (name, value) in headers {
|
|
104
|
+
request_headers.append(name, value)?;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let init = web_sys::RequestInit::new();
|
|
108
|
+
init.set_method("POST");
|
|
109
|
+
init.set_body(&JsValue::from_str(body));
|
|
110
|
+
init.set_headers(&request_headers);
|
|
111
|
+
init.set_signal(Some(&controller.signal()));
|
|
112
|
+
let request = web_sys::Request::new_with_str_and_init(url, &init)?;
|
|
113
|
+
let window = web_sys::window().ok_or_else(|| ApiError::from("No `window`"))?;
|
|
114
|
+
let response: web_sys::Response = JsFuture::from(window.fetch_with_request(&request))
|
|
115
|
+
.await?
|
|
116
|
+
.unchecked_into();
|
|
117
|
+
|
|
118
|
+
if !response.ok() {
|
|
119
|
+
let text = JsFuture::from(response.text()?)
|
|
120
|
+
.await?
|
|
121
|
+
.as_string()
|
|
122
|
+
.unwrap_or_default();
|
|
123
|
+
|
|
124
|
+
return Err(ApiError::from(format!(
|
|
125
|
+
"HTTP {} {}: {}",
|
|
126
|
+
response.status(),
|
|
127
|
+
response.status_text(),
|
|
128
|
+
text
|
|
129
|
+
)));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let content_type = response
|
|
133
|
+
.headers()
|
|
134
|
+
.get("content-type")?
|
|
135
|
+
.unwrap_or_default()
|
|
136
|
+
.to_lowercase();
|
|
137
|
+
|
|
138
|
+
if content_type.starts_with("text/event-stream") {
|
|
139
|
+
stream_sse(&response, on_delta).await
|
|
140
|
+
} else {
|
|
141
|
+
// The server ignored `stream: true` — buffered fallback.
|
|
142
|
+
let text = JsFuture::from(response.text()?)
|
|
143
|
+
.await?
|
|
144
|
+
.as_string()
|
|
145
|
+
.unwrap_or_default();
|
|
146
|
+
|
|
147
|
+
from_response_text(&text)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/// Consume a `text/event-stream` response body, folding each `data:` chunk.
|
|
152
|
+
async fn stream_sse(response: &web_sys::Response, on_delta: OnDelta<'_>) -> ApiResult<ChatMessage> {
|
|
153
|
+
let stream = response
|
|
154
|
+
.body()
|
|
155
|
+
.ok_or_else(|| ApiError::from("Streaming response has no body"))?;
|
|
156
|
+
|
|
157
|
+
let reader: web_sys::ReadableStreamDefaultReader = stream.get_reader().unchecked_into();
|
|
158
|
+
let decoder = web_sys::TextDecoder::new()?;
|
|
159
|
+
let options = web_sys::TextDecodeOptions::new();
|
|
160
|
+
options.set_stream(true);
|
|
161
|
+
let mut buf = String::new();
|
|
162
|
+
let mut acc = ChunkAccumulator::default();
|
|
163
|
+
'read: loop {
|
|
164
|
+
let result = JsFuture::from(reader.read()).await?;
|
|
165
|
+
let done = js_sys::Reflect::get(&result, &JsValue::from_str("done"))?
|
|
166
|
+
.as_bool()
|
|
167
|
+
.unwrap_or(true);
|
|
168
|
+
|
|
169
|
+
if done {
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let value: js_sys::Uint8Array =
|
|
174
|
+
js_sys::Reflect::get(&result, &JsValue::from_str("value"))?.unchecked_into();
|
|
175
|
+
|
|
176
|
+
buf.push_str(&decoder.decode_with_u8_array_and_options(&value.to_vec(), &options)?);
|
|
177
|
+
for payload in drain_sse_data(&mut buf) {
|
|
178
|
+
if payload == "[DONE]" {
|
|
179
|
+
break 'read;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let chunk: ChatChunk = serde_json::from_str(&payload)
|
|
183
|
+
.map_err(|x| ApiError::from(format!("Malformed completion chunk: {x}")))?;
|
|
184
|
+
|
|
185
|
+
acc.push(chunk);
|
|
186
|
+
let (content, reasoning) = acc.progress();
|
|
187
|
+
on_delta(content, reasoning);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
Ok(acc.finish())
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/// Drain complete SSE frames from `buf`, returning each frame's joined
|
|
195
|
+
/// `data:` payload (multi-`data:` frames join with `\n` per the SSE spec);
|
|
196
|
+
/// the incomplete tail stays in `buf`. Comment lines and other fields are
|
|
197
|
+
/// ignored; CRLF is normalized — a `\r`/`\n` pair split across reads
|
|
198
|
+
/// reunites in `buf` before the next drain sees it.
|
|
199
|
+
fn drain_sse_data(buf: &mut String) -> Vec<String> {
|
|
200
|
+
if buf.contains("\r\n") {
|
|
201
|
+
*buf = buf.replace("\r\n", "\n");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let mut out = vec![];
|
|
205
|
+
while let Some(pos) = buf.find("\n\n") {
|
|
206
|
+
let frame: String = buf.drain(..pos + 2).collect();
|
|
207
|
+
let mut payload = String::new();
|
|
208
|
+
for line in frame.lines() {
|
|
209
|
+
if let Some(rest) = line.strip_prefix("data:") {
|
|
210
|
+
if !payload.is_empty() {
|
|
211
|
+
payload.push('\n');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
payload.push_str(rest.strip_prefix(' ').unwrap_or(rest));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if !payload.is_empty() {
|
|
219
|
+
out.push(payload);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
out
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/// JSON round-trip through `JSON.parse`/`JSON.stringify` so the engine sees
|
|
227
|
+
/// plain JS objects (what WebLLM and the spec fakes expect) and replies
|
|
228
|
+
/// deserialize through the same serde path as HTTP responses. A result
|
|
229
|
+
/// bearing `Symbol.asyncIterator` (WebLLM's `stream: true` shape) is
|
|
230
|
+
/// iterated as chunks; anything else is one complete response.
|
|
231
|
+
async fn engine_completion(
|
|
232
|
+
engine: &JsValue,
|
|
233
|
+
body: &str,
|
|
234
|
+
on_delta: OnDelta<'_>,
|
|
235
|
+
) -> ApiResult<ChatMessage> {
|
|
236
|
+
let request = js_sys::JSON::parse(body)?;
|
|
237
|
+
let chat = js_sys::Reflect::get(engine, &JsValue::from_str("chat"))?;
|
|
238
|
+
let completions = js_sys::Reflect::get(&chat, &JsValue::from_str("completions"))?;
|
|
239
|
+
let create: js_sys::Function =
|
|
240
|
+
js_sys::Reflect::get(&completions, &JsValue::from_str("create"))?
|
|
241
|
+
.dyn_into()
|
|
242
|
+
.map_err(|_| ApiError::from("`engine.chat.completions.create` is not a function"))?;
|
|
243
|
+
|
|
244
|
+
let result = create.call1(&completions, &request)?;
|
|
245
|
+
let result = JsFuture::from(js_sys::Promise::resolve(&result)).await?;
|
|
246
|
+
let iterator_fn = js_sys::Reflect::get(&result, js_sys::Symbol::async_iterator().as_ref())?;
|
|
247
|
+
if !iterator_fn.is_function() {
|
|
248
|
+
let text = js_sys::JSON::stringify(&result)?
|
|
249
|
+
.as_string()
|
|
250
|
+
.ok_or_else(|| ApiError::from("Engine returned an unserializable response"))?;
|
|
251
|
+
|
|
252
|
+
return from_response_text(&text);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let iterator = iterator_fn
|
|
256
|
+
.unchecked_into::<js_sys::Function>()
|
|
257
|
+
.call0(&result)?;
|
|
258
|
+
let next: js_sys::Function = js_sys::Reflect::get(&iterator, &JsValue::from_str("next"))?
|
|
259
|
+
.dyn_into()
|
|
260
|
+
.map_err(|_| ApiError::from("Engine stream iterator has no `next`"))?;
|
|
261
|
+
|
|
262
|
+
let mut acc = ChunkAccumulator::default();
|
|
263
|
+
loop {
|
|
264
|
+
let step = JsFuture::from(js_sys::Promise::resolve(&next.call0(&iterator)?)).await?;
|
|
265
|
+
let done = js_sys::Reflect::get(&step, &JsValue::from_str("done"))?
|
|
266
|
+
.as_bool()
|
|
267
|
+
.unwrap_or(true);
|
|
268
|
+
|
|
269
|
+
if done {
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let value = js_sys::Reflect::get(&step, &JsValue::from_str("value"))?;
|
|
274
|
+
let text = js_sys::JSON::stringify(&value)?
|
|
275
|
+
.as_string()
|
|
276
|
+
.ok_or_else(|| ApiError::from("Engine returned an unserializable chunk"))?;
|
|
277
|
+
|
|
278
|
+
let chunk: ChatChunk = serde_json::from_str(&text)
|
|
279
|
+
.map_err(|x| ApiError::from(format!("Malformed completion chunk: {x}")))?;
|
|
280
|
+
|
|
281
|
+
acc.push(chunk);
|
|
282
|
+
let (content, reasoning) = acc.progress();
|
|
283
|
+
on_delta(content, reasoning);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
Ok(acc.finish())
|
|
287
|
+
}
|
|
@@ -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
|
+
}
|