@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,320 @@
|
|
|
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 OpenAI chat-completions wire shapes — only the fields this client
|
|
14
|
+
//! produces or consumes. Unknown response fields are ignored by serde, so
|
|
15
|
+
//! provider-specific extras (`usage`, `finish_reason`, ids) cost nothing.
|
|
16
|
+
//! The format is pinned by the `test/js/agent/` specs, which assert these
|
|
17
|
+
//! exact shapes against a scripted fake engine.
|
|
18
|
+
|
|
19
|
+
use serde::{Deserialize, Serialize};
|
|
20
|
+
use serde_json::Value;
|
|
21
|
+
|
|
22
|
+
/// One conversation message, tagged by `role`. `Assistant` doubles as the
|
|
23
|
+
/// response-message shape (`choices[n].message`).
|
|
24
|
+
#[derive(Clone, Deserialize, Serialize)]
|
|
25
|
+
#[serde(tag = "role", rename_all = "lowercase")]
|
|
26
|
+
pub enum ChatMessage {
|
|
27
|
+
System {
|
|
28
|
+
content: String,
|
|
29
|
+
},
|
|
30
|
+
User {
|
|
31
|
+
content: String,
|
|
32
|
+
},
|
|
33
|
+
Assistant {
|
|
34
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
35
|
+
content: Option<String>,
|
|
36
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
37
|
+
tool_calls: Vec<ToolCall>,
|
|
38
|
+
|
|
39
|
+
/// Reasoning-model "thinking" text, captured for DISPLAY only:
|
|
40
|
+
/// `skip_serializing` is the contract that it is never replayed
|
|
41
|
+
/// into a request (providers reject or mis-tokenize replayed
|
|
42
|
+
/// reasoning). Two wire conventions, one field: DeepSeek-style
|
|
43
|
+
/// `reasoning_content` (also LM Studio/vLLM) and OpenRouter's
|
|
44
|
+
/// `reasoning`.
|
|
45
|
+
#[serde(default, skip_serializing, alias = "reasoning")]
|
|
46
|
+
reasoning_content: Option<String>,
|
|
47
|
+
},
|
|
48
|
+
Tool {
|
|
49
|
+
tool_call_id: String,
|
|
50
|
+
content: String,
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// A model-emitted tool invocation, echoed back verbatim in the transcript
|
|
55
|
+
/// so the model can correlate `Tool` results by `id`.
|
|
56
|
+
#[derive(Clone, Deserialize, Serialize)]
|
|
57
|
+
pub struct ToolCall {
|
|
58
|
+
#[serde(default)]
|
|
59
|
+
pub id: String,
|
|
60
|
+
#[serde(default = "function_type", rename = "type")]
|
|
61
|
+
pub kind: String,
|
|
62
|
+
pub function: FunctionCall,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// `arguments` is a JSON-*encoded string* per the OpenAI wire format, not a
|
|
66
|
+
/// JSON object.
|
|
67
|
+
#[derive(Clone, Deserialize, Serialize)]
|
|
68
|
+
pub struct FunctionCall {
|
|
69
|
+
pub name: String,
|
|
70
|
+
#[serde(default)]
|
|
71
|
+
pub arguments: String,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
fn function_type() -> String {
|
|
75
|
+
"function".to_owned()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// A tool advertised to the model.
|
|
79
|
+
#[derive(Serialize)]
|
|
80
|
+
pub struct ToolDef {
|
|
81
|
+
#[serde(rename = "type")]
|
|
82
|
+
pub kind: &'static str,
|
|
83
|
+
pub function: FunctionDef,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#[derive(Serialize)]
|
|
87
|
+
pub struct FunctionDef {
|
|
88
|
+
pub name: &'static str,
|
|
89
|
+
pub description: &'static str,
|
|
90
|
+
pub parameters: Value,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#[derive(Serialize)]
|
|
94
|
+
pub struct ChatRequest<'a> {
|
|
95
|
+
pub model: &'a str,
|
|
96
|
+
pub messages: &'a [ChatMessage],
|
|
97
|
+
pub tools: &'a [ToolDef],
|
|
98
|
+
|
|
99
|
+
/// Always `true`; servers that ignore it get the buffered fallback in
|
|
100
|
+
/// `transport.rs`, so streaming needs no configuration surface.
|
|
101
|
+
pub stream: bool,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#[derive(Deserialize)]
|
|
105
|
+
pub struct ChatResponse {
|
|
106
|
+
pub choices: Vec<ChatChoice>,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
#[derive(Deserialize)]
|
|
110
|
+
pub struct ChatChoice {
|
|
111
|
+
pub message: ChatMessage,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// One `stream: true` SSE frame (`data: {...}`) — the delta shapes of the
|
|
115
|
+
/// OpenAI streaming wire format.
|
|
116
|
+
#[derive(Deserialize)]
|
|
117
|
+
pub struct ChatChunk {
|
|
118
|
+
#[serde(default)]
|
|
119
|
+
pub choices: Vec<ChunkChoice>,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#[derive(Deserialize)]
|
|
123
|
+
pub struct ChunkChoice {
|
|
124
|
+
#[serde(default)]
|
|
125
|
+
pub delta: ChunkDelta,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#[derive(Default, Deserialize)]
|
|
129
|
+
pub struct ChunkDelta {
|
|
130
|
+
#[serde(default)]
|
|
131
|
+
pub content: Option<String>,
|
|
132
|
+
#[serde(default)]
|
|
133
|
+
pub reasoning_content: Option<String>,
|
|
134
|
+
#[serde(default)]
|
|
135
|
+
pub reasoning: Option<String>,
|
|
136
|
+
#[serde(default)]
|
|
137
|
+
pub tool_calls: Vec<ToolCallDelta>,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/// Tool-call fragments accumulate BY INDEX: `id`/`name` arrive on the
|
|
141
|
+
/// first fragment, `arguments` arrives as string parts across many.
|
|
142
|
+
#[derive(Deserialize)]
|
|
143
|
+
pub struct ToolCallDelta {
|
|
144
|
+
#[serde(default)]
|
|
145
|
+
pub index: usize,
|
|
146
|
+
#[serde(default)]
|
|
147
|
+
pub id: Option<String>,
|
|
148
|
+
#[serde(default)]
|
|
149
|
+
pub function: Option<FunctionCallDelta>,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#[derive(Deserialize)]
|
|
153
|
+
pub struct FunctionCallDelta {
|
|
154
|
+
#[serde(default)]
|
|
155
|
+
pub name: Option<String>,
|
|
156
|
+
#[serde(default)]
|
|
157
|
+
pub arguments: Option<String>,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/// Folds a stream of [`ChatChunk`]s into the same
|
|
161
|
+
/// [`ChatMessage::Assistant`] the buffered path produces, so `turn.rs` is
|
|
162
|
+
/// agnostic to how the response arrived. Reasoning takes the first
|
|
163
|
+
/// non-empty of the two wire conventions per delta.
|
|
164
|
+
#[derive(Default)]
|
|
165
|
+
pub struct ChunkAccumulator {
|
|
166
|
+
content: String,
|
|
167
|
+
reasoning: String,
|
|
168
|
+
tool_calls: Vec<ToolCall>,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
impl ChunkAccumulator {
|
|
172
|
+
pub fn push(&mut self, chunk: ChatChunk) {
|
|
173
|
+
for choice in chunk.choices {
|
|
174
|
+
let delta = choice.delta;
|
|
175
|
+
if let Some(content) = delta.content {
|
|
176
|
+
self.content.push_str(&content);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if let Some(reasoning) = delta
|
|
180
|
+
.reasoning_content
|
|
181
|
+
.into_iter()
|
|
182
|
+
.chain(delta.reasoning)
|
|
183
|
+
.find(|x| !x.is_empty())
|
|
184
|
+
{
|
|
185
|
+
self.reasoning.push_str(&reasoning);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for fragment in delta.tool_calls {
|
|
189
|
+
if self.tool_calls.len() <= fragment.index {
|
|
190
|
+
self.tool_calls
|
|
191
|
+
.resize_with(fragment.index + 1, || ToolCall {
|
|
192
|
+
id: String::new(),
|
|
193
|
+
kind: function_type(),
|
|
194
|
+
function: FunctionCall {
|
|
195
|
+
name: String::new(),
|
|
196
|
+
arguments: String::new(),
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let call = &mut self.tool_calls[fragment.index];
|
|
202
|
+
if let Some(id) = fragment.id {
|
|
203
|
+
call.id = id;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if let Some(function) = fragment.function {
|
|
207
|
+
if let Some(name) = function.name {
|
|
208
|
+
call.function.name = name;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if let Some(arguments) = function.arguments {
|
|
212
|
+
call.function.arguments.push_str(&arguments);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/// Accumulated text/reasoning so far, for live-render callbacks.
|
|
220
|
+
pub fn progress(&self) -> (&str, &str) {
|
|
221
|
+
(&self.content, &self.reasoning)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
pub fn finish(self) -> ChatMessage {
|
|
225
|
+
ChatMessage::Assistant {
|
|
226
|
+
content: (!self.content.is_empty()).then_some(self.content),
|
|
227
|
+
tool_calls: self.tool_calls,
|
|
228
|
+
reasoning_content: (!self.reasoning.is_empty()).then_some(self.reasoning),
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
#[cfg(test)]
|
|
234
|
+
mod tests {
|
|
235
|
+
use super::*;
|
|
236
|
+
|
|
237
|
+
fn chunk(json: &str) -> ChatChunk {
|
|
238
|
+
serde_json::from_str(json).unwrap()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#[test]
|
|
242
|
+
fn content_and_both_reasoning_conventions_fold() {
|
|
243
|
+
let mut acc = ChunkAccumulator::default();
|
|
244
|
+
acc.push(chunk(r#"{"choices":[{"delta":{"role":"assistant"}}]}"#));
|
|
245
|
+
acc.push(chunk(
|
|
246
|
+
r#"{"choices":[{"delta":{"reasoning_content":"th"}}]}"#,
|
|
247
|
+
));
|
|
248
|
+
acc.push(chunk(r#"{"choices":[{"delta":{"reasoning":"ink"}}]}"#));
|
|
249
|
+
acc.push(chunk(r#"{"choices":[{"delta":{"content":"Hel"}}]}"#));
|
|
250
|
+
acc.push(chunk(
|
|
251
|
+
r#"{"choices":[{"delta":{"content":"lo","reasoning":""}}]}"#,
|
|
252
|
+
));
|
|
253
|
+
assert_eq!(acc.progress(), ("Hello", "think"));
|
|
254
|
+
let ChatMessage::Assistant {
|
|
255
|
+
content,
|
|
256
|
+
tool_calls,
|
|
257
|
+
reasoning_content,
|
|
258
|
+
} = acc.finish()
|
|
259
|
+
else {
|
|
260
|
+
panic!("not assistant");
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
assert_eq!(content.as_deref(), Some("Hello"));
|
|
264
|
+
assert!(tool_calls.is_empty());
|
|
265
|
+
assert_eq!(reasoning_content.as_deref(), Some("think"));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#[test]
|
|
269
|
+
fn fragmented_tool_arguments_accumulate_by_index() {
|
|
270
|
+
let mut acc = ChunkAccumulator::default();
|
|
271
|
+
acc.push(chunk(
|
|
272
|
+
r#"{"choices":[{"delta":{"tool_calls":[
|
|
273
|
+
{"index":0,"id":"c0","function":{"name":"get_schema","arguments":""}}]}}]}"#,
|
|
274
|
+
));
|
|
275
|
+
|
|
276
|
+
// Index 1 opens before index 0 finishes; both argument streams
|
|
277
|
+
// interleave.
|
|
278
|
+
acc.push(chunk(
|
|
279
|
+
r#"{"choices":[{"delta":{"tool_calls":[
|
|
280
|
+
{"index":1,"id":"c1","function":{"name":"set_view_config","arguments":"{\"con"}}]}}]}"#,
|
|
281
|
+
));
|
|
282
|
+
|
|
283
|
+
acc.push(chunk(
|
|
284
|
+
r#"{"choices":[{"delta":{"tool_calls":[
|
|
285
|
+
{"index":1,"function":{"arguments":"fig\":{}}"}}]}}]}"#,
|
|
286
|
+
));
|
|
287
|
+
|
|
288
|
+
let ChatMessage::Assistant { tool_calls, .. } = acc.finish() else {
|
|
289
|
+
panic!("not assistant");
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
assert_eq!(tool_calls.len(), 2);
|
|
293
|
+
assert_eq!(tool_calls[0].id, "c0");
|
|
294
|
+
assert_eq!(tool_calls[0].function.name, "get_schema");
|
|
295
|
+
assert_eq!(tool_calls[1].function.arguments, "{\"config\":{}}");
|
|
296
|
+
assert_eq!(tool_calls[1].kind, "function");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/// The display-only contract: reasoning deserializes from EITHER wire
|
|
300
|
+
/// field but NEVER serializes back into a request.
|
|
301
|
+
#[test]
|
|
302
|
+
fn assistant_reasoning_reads_both_aliases_and_never_serializes() {
|
|
303
|
+
for field in ["reasoning_content", "reasoning"] {
|
|
304
|
+
let message: ChatMessage = serde_json::from_str(&format!(
|
|
305
|
+
r#"{{"role":"assistant","content":"hi","{field}":"secret"}}"#
|
|
306
|
+
))
|
|
307
|
+
.unwrap();
|
|
308
|
+
|
|
309
|
+
let ChatMessage::Assistant {
|
|
310
|
+
reasoning_content, ..
|
|
311
|
+
} = &message
|
|
312
|
+
else {
|
|
313
|
+
panic!("not assistant");
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
assert_eq!(reasoning_content.as_deref(), Some("secret"));
|
|
317
|
+
assert!(!serde_json::to_string(&message).unwrap().contains("secret"));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -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
|
+
}
|