@oceanum/oceanumlab 4.6.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/LICENSE +29 -0
- package/README.md +116 -0
- package/lib/DatameshUI.d.ts +5 -0
- package/lib/DatameshUI.js +8 -0
- package/lib/DatameshUI.js.map +1 -0
- package/lib/DatameshWidget.d.ts +59 -0
- package/lib/DatameshWidget.js +573 -0
- package/lib/DatameshWidget.js.map +1 -0
- package/lib/DatasourceItem.d.ts +12 -0
- package/lib/DatasourceItem.js +47 -0
- package/lib/DatasourceItem.js.map +1 -0
- package/lib/aiLoop.d.ts +60 -0
- package/lib/aiLoop.js +85 -0
- package/lib/aiLoop.js.map +1 -0
- package/lib/chatRouter.d.ts +64 -0
- package/lib/chatRouter.js +225 -0
- package/lib/chatRouter.js.map +1 -0
- package/lib/constants.d.ts +14 -0
- package/lib/constants.js +15 -0
- package/lib/constants.js.map +1 -0
- package/lib/conversationPin.d.ts +68 -0
- package/lib/conversationPin.js +120 -0
- package/lib/conversationPin.js.map +1 -0
- package/lib/handler.d.ts +8 -0
- package/lib/handler.js +36 -0
- package/lib/handler.js.map +1 -0
- package/lib/index.d.ts +16 -0
- package/lib/index.js +273 -0
- package/lib/index.js.map +1 -0
- package/lib/kernelHandoff.d.ts +61 -0
- package/lib/kernelHandoff.js +236 -0
- package/lib/kernelHandoff.js.map +1 -0
- package/lib/notebookContext.d.ts +48 -0
- package/lib/notebookContext.js +118 -0
- package/lib/notebookContext.js.map +1 -0
- package/lib/notebookHost.d.ts +6 -0
- package/lib/notebookHost.js +72 -0
- package/lib/notebookHost.js.map +1 -0
- package/lib/notebookRun.d.ts +37 -0
- package/lib/notebookRun.js +70 -0
- package/lib/notebookRun.js.map +1 -0
- package/lib/progress.d.ts +43 -0
- package/lib/progress.js +103 -0
- package/lib/progress.js.map +1 -0
- package/lib/sse.d.ts +26 -0
- package/lib/sse.js +112 -0
- package/lib/sse.js.map +1 -0
- package/package.json +151 -0
- package/schema/datamesh-connect.json +35 -0
- package/src/DatameshUI.tsx +15 -0
- package/src/DatameshWidget.tsx +909 -0
- package/src/DatasourceItem.tsx +124 -0
- package/src/__tests__/aiLoop.spec.ts +304 -0
- package/src/__tests__/chatRouter.spec.ts +315 -0
- package/src/__tests__/conversationPin.spec.ts +236 -0
- package/src/__tests__/kernelHandoff.spec.ts +106 -0
- package/src/__tests__/notebookContext.spec.ts +97 -0
- package/src/__tests__/notebookHost.spec.ts +195 -0
- package/src/__tests__/notebookRun.spec.ts +95 -0
- package/src/__tests__/notebookSnapshot.spec.ts +62 -0
- package/src/__tests__/oceanumlab.spec.ts +9 -0
- package/src/__tests__/sse.spec.ts +304 -0
- package/src/aiLoop.ts +140 -0
- package/src/chatRouter.ts +329 -0
- package/src/constants.ts +16 -0
- package/src/conversationPin.ts +148 -0
- package/src/global.d.ts +5 -0
- package/src/handler.ts +46 -0
- package/src/index.ts +327 -0
- package/src/kernelHandoff.ts +284 -0
- package/src/notebookContext.ts +144 -0
- package/src/notebookHost.ts +97 -0
- package/src/notebookRun.ts +104 -0
- package/src/progress.ts +121 -0
- package/src/sse.ts +127 -0
- package/src/svg.d.ts +4 -0
- package/style/base.css +5 -0
- package/style/icons/oceanum-dark.svg +1 -0
- package/style/icons/oceanum-light.svg +1 -0
- package/style/icons/oceanum.png +0 -0
- package/style/icons/oceanum.svg +1 -0
- package/style/index.css +477 -0
- package/style/index.js +1 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { Notebook } from '@jupyterlab/notebook';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A notebook cell reduced to what the chat context needs.
|
|
5
|
+
*/
|
|
6
|
+
export interface IContextCell {
|
|
7
|
+
kind: 'code' | 'markdown';
|
|
8
|
+
source: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** What a request carries from the conversation's notebook. */
|
|
12
|
+
export interface INotebookSnapshot {
|
|
13
|
+
cells: IContextCell[];
|
|
14
|
+
/** The selected cell, which a code answer may replace. None when closed. */
|
|
15
|
+
selected: { source: string; isCode: boolean } | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Mirrors oceanum-ai's app/config.py. Past either limit the server rejects the
|
|
19
|
+
// whole request, so the chat fails outright rather than losing some context.
|
|
20
|
+
export const MAX_NOTEBOOK_CELLS = 500;
|
|
21
|
+
export const MAX_CELL_CHARS = 100000;
|
|
22
|
+
|
|
23
|
+
// The server also caps the WHOLE request body at 2 MiB (MAX_REQUEST_BYTES),
|
|
24
|
+
// and that body carries chat history and, on follow-up rounds, run outputs as
|
|
25
|
+
// well as the cells. Markdown may take the cells up to half of it.
|
|
26
|
+
export const MAX_CELLS_BYTES = 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
// An inline image: the one thing in markdown that is routinely huge. Any media
|
|
29
|
+
// type, any `;param` or `;param=value` (charset, base64, utf8), then the data
|
|
30
|
+
// up to the first space, bracket or quote.
|
|
31
|
+
const DATA_URI =
|
|
32
|
+
/data:[\w.+-]+\/[\w.+-]+(?:;[\w.+-]+(?:=[\w.+-]+)?)*,[^\s)"']+/g;
|
|
33
|
+
|
|
34
|
+
/** An open notebook's code and markdown cells, and its selected cell. */
|
|
35
|
+
export function snapshotOf(notebook: Notebook): INotebookSnapshot {
|
|
36
|
+
const cells: IContextCell[] = [];
|
|
37
|
+
for (const cell of notebook.widgets) {
|
|
38
|
+
const type = cell.model.type;
|
|
39
|
+
if (type === 'code' || type === 'markdown') {
|
|
40
|
+
cells.push({ kind: type, source: cell.model.sharedModel.getSource() });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const active = notebook.activeCell;
|
|
44
|
+
return {
|
|
45
|
+
cells,
|
|
46
|
+
selected: active
|
|
47
|
+
? {
|
|
48
|
+
source: active.model.sharedModel.getSource(),
|
|
49
|
+
isCode: active.model.type === 'code'
|
|
50
|
+
}
|
|
51
|
+
: null
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A closed notebook's cells, read from its file.
|
|
57
|
+
*
|
|
58
|
+
* Read rather than opened again: re-opening the tab is for an answer with
|
|
59
|
+
* cells to place, not for asking a question. A closed notebook has no
|
|
60
|
+
* selected cell. Anything unexpected in the file reads as no cells rather
|
|
61
|
+
* than an error -- context is best-effort.
|
|
62
|
+
*/
|
|
63
|
+
export function snapshotFromIpynb(content: unknown): INotebookSnapshot {
|
|
64
|
+
const raw = (content as { cells?: unknown } | null)?.cells;
|
|
65
|
+
const cells: IContextCell[] = [];
|
|
66
|
+
if (Array.isArray(raw)) {
|
|
67
|
+
for (const cell of raw) {
|
|
68
|
+
const { cell_type: type, source } = (cell ?? {}) as {
|
|
69
|
+
cell_type?: unknown;
|
|
70
|
+
source?: unknown;
|
|
71
|
+
};
|
|
72
|
+
if (type !== 'code' && type !== 'markdown') {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
// nbformat allows the source as one string or as a list of lines.
|
|
76
|
+
const text = Array.isArray(source)
|
|
77
|
+
? source.join('')
|
|
78
|
+
: typeof source === 'string'
|
|
79
|
+
? source
|
|
80
|
+
: '';
|
|
81
|
+
cells.push({ kind: type, source: text });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { cells, selected: null };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The notebook as `notebookCells`: every non-empty code and markdown cell, in
|
|
89
|
+
* order.
|
|
90
|
+
*
|
|
91
|
+
* The server renders all of them inside ONE ```python block, each headed
|
|
92
|
+
* `# Cell N`, so markdown sent raw would read there as broken code. It goes as
|
|
93
|
+
* comment lines under a jupytext-style `# %% [markdown]` marker instead: still
|
|
94
|
+
* valid Python, and unambiguous to the model.
|
|
95
|
+
*
|
|
96
|
+
* Markdown is also what can push a request past the server's limits, and those
|
|
97
|
+
* reject the whole request -- costing the user the chat for the sake of some
|
|
98
|
+
* prose. So markdown gives way first: inline images are dropped from it, an
|
|
99
|
+
* oversized markdown cell is left out, and when the markdown would take the
|
|
100
|
+
* cells past the server's cell count or past MAX_CELLS_BYTES, only the code is
|
|
101
|
+
* sent -- which is what was sent before markdown was included.
|
|
102
|
+
*/
|
|
103
|
+
export function formatNotebookCells(cells: readonly IContextCell[]): string[] {
|
|
104
|
+
const code: string[] = [];
|
|
105
|
+
const all: string[] = [];
|
|
106
|
+
for (const cell of cells) {
|
|
107
|
+
if (!cell.source.trim()) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (cell.kind === 'code') {
|
|
111
|
+
code.push(cell.source);
|
|
112
|
+
all.push(cell.source);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const markdown = asMarkdownComment(cell.source);
|
|
116
|
+
if (markdown.length <= MAX_CELL_CHARS) {
|
|
117
|
+
all.push(markdown);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (all.length > MAX_NOTEBOOK_CELLS || totalBytes(all) > MAX_CELLS_BYTES) {
|
|
121
|
+
return code;
|
|
122
|
+
}
|
|
123
|
+
return all;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function asMarkdownComment(source: string): string {
|
|
127
|
+
const lines = source
|
|
128
|
+
.replace(DATA_URI, 'data:…')
|
|
129
|
+
.split(/\r?\n/)
|
|
130
|
+
.map(line => (line ? `# ${line}` : '#'));
|
|
131
|
+
return ['# %% [markdown]', ...lines].join('\n');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** UTF-8 size, which is what the server's byte limit counts. */
|
|
135
|
+
function totalBytes(texts: readonly string[]): number {
|
|
136
|
+
let bytes = 0;
|
|
137
|
+
for (const text of texts) {
|
|
138
|
+
for (const char of text) {
|
|
139
|
+
const point = char.codePointAt(0) ?? 0;
|
|
140
|
+
bytes += point < 0x80 ? 1 : point < 0x800 ? 2 : point < 0x10000 ? 3 : 4;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return bytes;
|
|
144
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { JupyterFrontEnd } from '@jupyterlab/application';
|
|
2
|
+
import type { INotebookTracker } from '@jupyterlab/notebook';
|
|
3
|
+
import { NotebookPanel } from '@jupyterlab/notebook';
|
|
4
|
+
import type { INotebookHost } from './conversationPin';
|
|
5
|
+
|
|
6
|
+
// Longest wait for a notebook to open, be created, or load. The pin takes one
|
|
7
|
+
// step at a time, so a step that never finished would stall every chat.
|
|
8
|
+
export const NOTEBOOK_WAIT_MS = 15000;
|
|
9
|
+
|
|
10
|
+
/** `promise`, or null if it fails or has not settled within `ms`. */
|
|
11
|
+
function within<T>(promise: Promise<T>, ms: number): Promise<T | null> {
|
|
12
|
+
return new Promise<T | null>(resolve => {
|
|
13
|
+
const timer = setTimeout(() => resolve(null), ms);
|
|
14
|
+
promise.then(
|
|
15
|
+
value => {
|
|
16
|
+
clearTimeout(timer);
|
|
17
|
+
resolve(value);
|
|
18
|
+
},
|
|
19
|
+
() => {
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
resolve(null);
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The notebook a command opened, once its contents have loaded; null if the
|
|
29
|
+
* command opened something else, or either step failed or took over `ms`.
|
|
30
|
+
*
|
|
31
|
+
* `docmanager:open` and `notebook:create-new` hand back the panel before the
|
|
32
|
+
* file has been read, and reading it REPLACES every cell: an answer placed
|
|
33
|
+
* before then is wiped when the file arrives.
|
|
34
|
+
*/
|
|
35
|
+
async function loaded(
|
|
36
|
+
opened: Promise<unknown>,
|
|
37
|
+
ms: number
|
|
38
|
+
): Promise<NotebookPanel | null> {
|
|
39
|
+
const widget = await within(opened, ms);
|
|
40
|
+
if (!(widget instanceof NotebookPanel)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return within(
|
|
44
|
+
widget.context.ready.then(() => widget),
|
|
45
|
+
ms
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** JupyterLab as ConversationPin sees it. */
|
|
50
|
+
export function notebookHost(
|
|
51
|
+
app: JupyterFrontEnd,
|
|
52
|
+
tracker: INotebookTracker,
|
|
53
|
+
waitMs = NOTEBOOK_WAIT_MS
|
|
54
|
+
): INotebookHost {
|
|
55
|
+
return {
|
|
56
|
+
// `app.shell` tracks only main-area tabs, so its current widget is the
|
|
57
|
+
// active tab even while focus is in the sidebar.
|
|
58
|
+
activeTab: () => {
|
|
59
|
+
const widget = app.shell.currentWidget;
|
|
60
|
+
return widget && tracker.has(widget) ? (widget as NotebookPanel) : null;
|
|
61
|
+
},
|
|
62
|
+
// A new Python 3 notebook, made the active tab. `notebook:create-new`
|
|
63
|
+
// returns the panel it opens; anything else is a failure. Taking the next
|
|
64
|
+
// notebook to appear instead could take one the user opened meanwhile.
|
|
65
|
+
create: async () => {
|
|
66
|
+
const panel = await loaded(
|
|
67
|
+
app.commands.execute('notebook:create-new', { kernelName: 'python3' }),
|
|
68
|
+
waitMs
|
|
69
|
+
);
|
|
70
|
+
if (panel) {
|
|
71
|
+
app.shell.activateById(panel.id);
|
|
72
|
+
}
|
|
73
|
+
return panel;
|
|
74
|
+
},
|
|
75
|
+
// Null when the file has been deleted, or has gone some other way: a new
|
|
76
|
+
// notebook then takes over.
|
|
77
|
+
reopen: async path => {
|
|
78
|
+
// Opened again by the user already: theirs, selection and all.
|
|
79
|
+
const open = tracker.find(panel => panel.context.path === path);
|
|
80
|
+
if (open) {
|
|
81
|
+
return loaded(Promise.resolve(open), waitMs);
|
|
82
|
+
}
|
|
83
|
+
const panel = await loaded(
|
|
84
|
+
app.commands.execute('docmanager:open', { path }),
|
|
85
|
+
waitMs
|
|
86
|
+
);
|
|
87
|
+
if (panel) {
|
|
88
|
+
// It opens with its first cell selected, which nobody chose, and
|
|
89
|
+
// answers go below the selected cell: above all the user's work.
|
|
90
|
+
// Select the last cell, so they go at the end.
|
|
91
|
+
panel.content.activeCellIndex = panel.content.widgets.length - 1;
|
|
92
|
+
}
|
|
93
|
+
return panel;
|
|
94
|
+
},
|
|
95
|
+
activate: panel => app.shell.activateById(panel.id)
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type * as nbformat from '@jupyterlab/nbformat';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What one code cell did when it ran, in the shape `/api/chat/observe` takes.
|
|
5
|
+
*
|
|
6
|
+
* The notebook counterpart of the sandbox's ExecResult. `message` is the
|
|
7
|
+
* agent's own explanation for the response that carried this code, so a
|
|
8
|
+
* capped final answer can say what the whole chain was doing.
|
|
9
|
+
*/
|
|
10
|
+
export interface ObservedRun {
|
|
11
|
+
code: string;
|
|
12
|
+
status: 'ok' | 'error';
|
|
13
|
+
stdout: string;
|
|
14
|
+
error: string | null;
|
|
15
|
+
message: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The part of a run the kernel decides; what `harvestOutputs` reads off it. */
|
|
19
|
+
export type RunOutcome = Pick<ObservedRun, 'status' | 'stdout' | 'error'>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Most text kept per field. The server reads only the tail of stdout and a
|
|
23
|
+
* bounded traceback (OBSERVATION_STDOUT_CHARS), and every observe request
|
|
24
|
+
* re-sends every run so far, so anything past this is bytes shipped for
|
|
25
|
+
* nothing -- a cell printing in a loop can produce megabytes.
|
|
26
|
+
*/
|
|
27
|
+
export const MAX_OBSERVED_CHARS = 8000;
|
|
28
|
+
|
|
29
|
+
// CSI (colour, cursor, including private-mode `?25l` from progress bars) and
|
|
30
|
+
// OSC (titles, hyperlinks) sequences, per ECMA-48. An OSC body cannot contain
|
|
31
|
+
// ESC, so the body class excludes it: otherwise an ST-terminated OSC followed
|
|
32
|
+
// later by a BEL-terminated one would swallow the text between them.
|
|
33
|
+
const ANSI =
|
|
34
|
+
// eslint-disable-next-line no-control-regex
|
|
35
|
+
/\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
|
|
36
|
+
|
|
37
|
+
/** Kernel tracebacks are coloured for a terminal; the model does not need that. */
|
|
38
|
+
export function stripAnsi(text: string): string {
|
|
39
|
+
return text.replace(ANSI, '');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Keep the end: that is where the summary line, or the raised error, is. */
|
|
43
|
+
function tail(text: string): string {
|
|
44
|
+
if (text.length <= MAX_OBSERVED_CHARS) {
|
|
45
|
+
return text;
|
|
46
|
+
}
|
|
47
|
+
return `…[${text.length - MAX_OBSERVED_CHARS} chars truncated]\n${text.slice(
|
|
48
|
+
-MAX_OBSERVED_CHARS
|
|
49
|
+
)}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function asText(value: nbformat.MultilineString | undefined): string {
|
|
53
|
+
if (value === undefined) {
|
|
54
|
+
return '';
|
|
55
|
+
}
|
|
56
|
+
return Array.isArray(value) ? value.join('') : value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Reduce a cell's outputs to what the observation prompt needs.
|
|
61
|
+
*
|
|
62
|
+
* Stream stdout is concatenated in order. An `error` output makes the run an
|
|
63
|
+
* error and its traceback the error text; the kernel's stderr stream is folded
|
|
64
|
+
* in after it, because warnings there are often the useful part of a failure.
|
|
65
|
+
* Rendered outputs (execute_result, display_data) are deliberately NOT sent:
|
|
66
|
+
* a DataFrame repr is kilobytes of dataset-derived text, and the agent gets
|
|
67
|
+
* the printed summary the code chose to make instead.
|
|
68
|
+
*/
|
|
69
|
+
export function harvestOutputs(
|
|
70
|
+
outputs: readonly nbformat.IOutput[]
|
|
71
|
+
): RunOutcome {
|
|
72
|
+
const stdout: string[] = [];
|
|
73
|
+
const stderr: string[] = [];
|
|
74
|
+
let error: string | null = null;
|
|
75
|
+
|
|
76
|
+
for (const output of outputs) {
|
|
77
|
+
if (nbformatIsStream(output)) {
|
|
78
|
+
(output.name === 'stderr' ? stderr : stdout).push(asText(output.text));
|
|
79
|
+
} else if (nbformatIsError(output)) {
|
|
80
|
+
const traceback = stripAnsi(output.traceback.join('\n'));
|
|
81
|
+
error = `${output.ename}: ${output.evalue}\n${traceback}`.trim();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (error !== null && stderr.length > 0) {
|
|
86
|
+
error = `${error}\n${stripAnsi(stderr.join(''))}`.trim();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
status: error === null ? 'ok' : 'error',
|
|
91
|
+
stdout: tail(stripAnsi(stdout.join(''))),
|
|
92
|
+
error: error === null ? null : tail(error)
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function nbformatIsStream(
|
|
97
|
+
output: nbformat.IOutput
|
|
98
|
+
): output is nbformat.IStream {
|
|
99
|
+
return output.output_type === 'stream';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function nbformatIsError(output: nbformat.IOutput): output is nbformat.IError {
|
|
103
|
+
return output.output_type === 'error';
|
|
104
|
+
}
|
package/src/progress.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the agent is doing right now, from the extension host to the widget
|
|
3
|
+
* (OCE-175).
|
|
4
|
+
*
|
|
5
|
+
* A channel is needed because the widget does not call the router: it issues
|
|
6
|
+
* the `oceanum-ai:submit-prompt` COMMAND, and command arguments must be JSON,
|
|
7
|
+
* so a callback cannot cross. The Stop button has the same constraint and
|
|
8
|
+
* solves it the same way -- an AbortController held in the plugin's closure.
|
|
9
|
+
*
|
|
10
|
+
* A module singleton rather than a JupyterLab `Signal`, because a Signal wants
|
|
11
|
+
* an owner object and there is no natural one here: the producer is a command
|
|
12
|
+
* handler and the consumer is a React component that mounts and unmounts
|
|
13
|
+
* independently of it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A phase the backend named, and the tool it is using if that is the phase. */
|
|
17
|
+
export interface Progress {
|
|
18
|
+
phase: string;
|
|
19
|
+
tool?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type Listener = (progress: Progress | null) => void;
|
|
23
|
+
|
|
24
|
+
const listeners = new Set<Listener>();
|
|
25
|
+
|
|
26
|
+
/** Subscribe. Returns the unsubscribe, for a React effect's cleanup. */
|
|
27
|
+
export function onProgress(listener: Listener): () => void {
|
|
28
|
+
listeners.add(listener);
|
|
29
|
+
return () => {
|
|
30
|
+
listeners.delete(listener);
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Publish. `null` means "no longer working", which the widget uses to fall
|
|
36
|
+
* back to its own wording rather than leaving the last phase on screen after
|
|
37
|
+
* the answer has arrived.
|
|
38
|
+
*
|
|
39
|
+
* A listener that throws must not stop the others hearing, nor break the
|
|
40
|
+
* request: this is decoration, and a broken indicator is not worth failing an
|
|
41
|
+
* answer over.
|
|
42
|
+
*/
|
|
43
|
+
export function reportProgress(progress: Progress | null): void {
|
|
44
|
+
for (const listener of listeners) {
|
|
45
|
+
try {
|
|
46
|
+
listener(progress);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error('Oceanum AI: a progress listener threw', err);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A reporter for one run: it publishes only while `isCurrent()` holds.
|
|
55
|
+
*
|
|
56
|
+
* A run that New chat or a newer prompt replaced is aborted, but it can take a
|
|
57
|
+
* while to unwind -- a cell that ignores the interrupt keeps it in its handoff
|
|
58
|
+
* -- and whatever it reports on the way out, a phase or its "no longer
|
|
59
|
+
* working", would land on the new run's progress line.
|
|
60
|
+
*/
|
|
61
|
+
export function reporterFor(
|
|
62
|
+
isCurrent: () => boolean
|
|
63
|
+
): (progress: Progress | null) => void {
|
|
64
|
+
return progress => {
|
|
65
|
+
if (isCurrent()) {
|
|
66
|
+
reportProgress(progress);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* What each phase is, in the user's terms.
|
|
73
|
+
*
|
|
74
|
+
* An unlisted phase or tool falls back to something generic rather than
|
|
75
|
+
* showing its internal name: a new server-side tool should read as progress,
|
|
76
|
+
* not as a leak of what the agent is made of. And no phase at all reads as
|
|
77
|
+
* "Thinking…", which is what this said before there was anything better to
|
|
78
|
+
* say -- so a backend that does not stream, or a proxy that buffered the
|
|
79
|
+
* stream away, degrades to exactly the old behaviour rather than to a blank.
|
|
80
|
+
*/
|
|
81
|
+
const TOOL_LABELS: Record<string, string> = {
|
|
82
|
+
search_catalog: 'Searching the catalogue…',
|
|
83
|
+
get_datasource_info: 'Reading dataset details…',
|
|
84
|
+
save_memory: 'Saving a note…'
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const PHASE_LABELS: Record<string, string> = {
|
|
88
|
+
generating: 'Thinking…',
|
|
89
|
+
interpreting: 'Reading the result…',
|
|
90
|
+
// Reported by the client, not the server: these two are the notebook's turn.
|
|
91
|
+
// Everything else here names something the agent is doing, and saying so
|
|
92
|
+
// while a cell runs would be actively false rather than merely vague.
|
|
93
|
+
running: 'Running the code…',
|
|
94
|
+
placing: 'Adding the code to the notebook…'
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export function describeProgress(progress: Progress | null): string {
|
|
98
|
+
if (!progress) {
|
|
99
|
+
return 'Thinking…';
|
|
100
|
+
}
|
|
101
|
+
if (progress.phase === 'tool') {
|
|
102
|
+
return labelFor(TOOL_LABELS, progress.tool) ?? 'Looking something up…';
|
|
103
|
+
}
|
|
104
|
+
return labelFor(PHASE_LABELS, progress.phase) ?? 'Working…';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The label `labels` itself gives `name`, if any. Only its own entries: the
|
|
109
|
+
* name comes off the stream -- a tool name is whatever the model called -- and
|
|
110
|
+
* `__proto__` or `toString` must not reach into Object.prototype. That hands
|
|
111
|
+
* React an object or a function to render, and an object breaks the panel.
|
|
112
|
+
*/
|
|
113
|
+
function labelFor(
|
|
114
|
+
labels: Record<string, string>,
|
|
115
|
+
name: string | undefined
|
|
116
|
+
): string | undefined {
|
|
117
|
+
return name !== undefined &&
|
|
118
|
+
Object.prototype.hasOwnProperty.call(labels, name)
|
|
119
|
+
? labels[name]
|
|
120
|
+
: undefined;
|
|
121
|
+
}
|
package/src/sse.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a Server-Sent Events body (OCE-175).
|
|
3
|
+
*
|
|
4
|
+
* Small and local rather than a dependency: SSE is a line format, the backend
|
|
5
|
+
* emits one `data:` line per event, and a library would bring a parser for the
|
|
6
|
+
* multi-line and retry-directive cases this stream does not use.
|
|
7
|
+
*
|
|
8
|
+
* Note it is NOT `EventSource`. That only issues GETs, and these are POSTs with
|
|
9
|
+
* a body and an auth header, which is why every client of this API hand-rolls
|
|
10
|
+
* the read.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** One frame off the wire. `data` is raw; the caller parses it. */
|
|
14
|
+
export interface SseEvent {
|
|
15
|
+
event: string;
|
|
16
|
+
data: string;
|
|
17
|
+
id?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Yield each event as it arrives.
|
|
22
|
+
*
|
|
23
|
+
* Decodes with `stream: true` so a multi-byte character split across two
|
|
24
|
+
* network chunks is reassembled rather than mangled -- a UTF-8 sequence has no
|
|
25
|
+
* obligation to arrive whole, and a dataset name with an accent in it is the
|
|
26
|
+
* kind of thing that finds this bug in production rather than in a test.
|
|
27
|
+
*/
|
|
28
|
+
export async function* readSse(
|
|
29
|
+
body: ReadableStream<Uint8Array>,
|
|
30
|
+
signal?: AbortSignal
|
|
31
|
+
): AsyncGenerator<SseEvent> {
|
|
32
|
+
const reader = body.getReader();
|
|
33
|
+
const decoder = new TextDecoder();
|
|
34
|
+
let buffer = '';
|
|
35
|
+
|
|
36
|
+
/** Every complete frame currently in the buffer. Events are separated by a
|
|
37
|
+
* blank line; anything after the last one is partial and stays put. */
|
|
38
|
+
function* drain(): Generator<SseEvent> {
|
|
39
|
+
let split = buffer.indexOf('\n\n');
|
|
40
|
+
while (split !== -1) {
|
|
41
|
+
const frame = buffer.slice(0, split);
|
|
42
|
+
buffer = buffer.slice(split + 2);
|
|
43
|
+
const parsed = parseFrame(frame);
|
|
44
|
+
if (parsed) {
|
|
45
|
+
yield parsed;
|
|
46
|
+
}
|
|
47
|
+
split = buffer.indexOf('\n\n');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
while (true) {
|
|
53
|
+
if (signal?.aborted) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const { done, value } = await reader.read();
|
|
57
|
+
if (done) {
|
|
58
|
+
// Flush the deferred `\r`, if any: no further chunk is coming, so it
|
|
59
|
+
// cannot be half of a CRLF and IS a line ending. Without this a stream
|
|
60
|
+
// using bare CR loses its final frame -- the terminator never forms.
|
|
61
|
+
buffer = normaliseNewlines(buffer, true);
|
|
62
|
+
yield* drain();
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
buffer = normaliseNewlines(
|
|
66
|
+
buffer + decoder.decode(value, { stream: true })
|
|
67
|
+
);
|
|
68
|
+
yield* drain();
|
|
69
|
+
}
|
|
70
|
+
} finally {
|
|
71
|
+
// Releases the reader's lock whether the consumer finished, threw, or
|
|
72
|
+
// stopped early -- a `break` in a for-await runs this. It does not cancel
|
|
73
|
+
// the body: the backend ends the stream after `done` or `error`, and Stop's
|
|
74
|
+
// abort closes the connection itself.
|
|
75
|
+
reader.releaseLock();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Collapse CRLF and bare CR to LF, so the rest of the parser can look for a
|
|
81
|
+
* single line ending.
|
|
82
|
+
*
|
|
83
|
+
* The spec allows all three, and our own backend sends LF -- but "our backend
|
|
84
|
+
* sends LF" is a statement about one hop. Anything between it and the browser
|
|
85
|
+
* may rewrite line endings, and the failure if it does is total rather than
|
|
86
|
+
* partial: the frame separator `\n\n` never matches `\r\n\r\n`, so ZERO events
|
|
87
|
+
* parse, the stream looks empty, and the caller reports the response as
|
|
88
|
+
* incomplete. Measured before this existed: 0 events from a well-formed CRLF
|
|
89
|
+
* stream.
|
|
90
|
+
*
|
|
91
|
+
* The trailing `\r` is held back, and that is the whole subtlety. A chunk may
|
|
92
|
+
* end mid-CRLF, with the `\n` arriving next; converting that `\r` to `\n` now
|
|
93
|
+
* and meeting its partner later would manufacture a blank line -- a frame
|
|
94
|
+
* boundary that was never sent, splitting one event into two malformed ones.
|
|
95
|
+
* So it waits for the next chunk, exactly as a split multi-byte character does.
|
|
96
|
+
*/
|
|
97
|
+
function normaliseNewlines(buffer: string, final = false): string {
|
|
98
|
+
const held = !final && buffer.endsWith('\r') ? '\r' : '';
|
|
99
|
+
const body = held ? buffer.slice(0, -1) : buffer;
|
|
100
|
+
return body.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + held;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseFrame(frame: string): SseEvent | null {
|
|
104
|
+
let event = 'message';
|
|
105
|
+
let id: string | undefined;
|
|
106
|
+
const data: string[] = [];
|
|
107
|
+
|
|
108
|
+
for (const line of frame.split('\n')) {
|
|
109
|
+
// A comment. The backend sends these as keepalives during a long wait, and
|
|
110
|
+
// they carry nothing.
|
|
111
|
+
if (line.startsWith(':')) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (line.startsWith('event:')) {
|
|
115
|
+
event = line.slice('event:'.length).trim();
|
|
116
|
+
} else if (line.startsWith('data:')) {
|
|
117
|
+
data.push(line.slice('data:'.length).trim());
|
|
118
|
+
} else if (line.startsWith('id:')) {
|
|
119
|
+
id = line.slice('id:'.length).trim();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (data.length === 0) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return { event, data: data.join('\n'), id };
|
|
127
|
+
}
|
package/src/svg.d.ts
ADDED
package/style/base.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76.5997 76.5918"><g id="Components"><g id="_x32_9414058-301b-4d05-be55-13a1e0a9aa73_x40_13"><path d="M50.9806,74.333c-3.3558,1.2907-7.8269,2.2589-12.7127,2.2589s-9.293-.9681-12.6489-2.2589c1.0973-.5806,2.2585-1.1615,3.4204-1.6779,2.9036-1.2903,5.9372-2.3877,9.2285-2.3877s6.3886,1.0973,9.2923,2.3877c1.1611.5164,2.3231,1.0973,3.4204,1.6779Z" style="fill:#aadde1;"/><path d="M38.268,0C17.1658,0,0,17.1654,0,38.3322c0,.9032.0638,1.8713.1284,2.7748.1292,2.2585.4522,4.3881,1.0335,6.5178,1.6133.7098,3.162,1.2257,4.6461,1.6133.9028.258,1.8071.3872,2.6453.3872,1.6133.1288,3.162,0,4.7753-.3872,2.453-.581,5.0337-1.6779,7.6151-2.9686,2.5814-1.2261,5.3559-2.7748,8.1957-4.0656,2.9036-1.2907,5.9372-2.3877,9.2285-2.3877s6.3886,1.0969,9.2923,2.3877c2.839,1.2907,5.5497,2.8394,8.1957,4.0656,2.5807,1.2907,5.0983,2.3877,7.6144,2.9686,1.6133.3872,3.2266.516,4.8399.3872.839-.0646,1.7425-.1938,2.5814-.3872,1.4841-.3876,3.0336-.9035,4.6469-1.6133.5152-2.1297.9028-4.2593,1.0319-6.5178.0646-.9035.1292-1.8717.1292-2.7748C76.5997,17.1654,59.4339,0,38.268,0Z" style="fill:#aadde1;"/><path d="M72.8563,54.7153c-1.1611,2.5169-2.5807,4.8399-4.3232,6.9696-1.7425.1934-3.4204.1288-5.1629-.2584-2.5161-.6452-5.0337-1.6779-7.6144-2.9682-2.646-1.2907-5.3567-2.7748-8.1957-4.0656-2.9036-1.2907-6.0011-2.3877-9.2923-2.3877s-6.3248,1.0969-9.2285,2.3877c-2.8398,1.2907-5.6143,2.7748-8.1957,4.0656-2.5814,1.2903-5.1621,2.3231-7.6151,2.9682-1.7425.3872-3.4204.4518-5.1621.2584-1.7425-2.1297-3.2266-4.4527-4.3885-6.9696.7105.1934,1.4195.3872,2.1301.5806,2.4515.581,4.9045.581,7.4206,0,2.453-.5806,5.0337-1.6133,7.6151-2.9036,2.5814-1.2907,5.3559-2.8394,8.1957-4.0656,2.9036-1.2907,5.9372-2.3877,9.2285-2.3877s6.3886,1.0969,9.2923,2.3877c2.839,1.2261,5.5497,2.7748,8.1957,4.0656,2.5807,1.2903,5.0983,2.3231,7.6144,2.9036,2.4523.581,4.9045.581,7.4214,0,.6452-.1288,1.3557-.3226,2.0647-.5806Z" style="fill:#aadde1;"/><path d="M63.1119,67.428c-1.7425,1.5491-3.678,2.904-5.7427,4.001-.5168-.258-1.0973-.516-1.6133-.7744-2.646-1.2903-5.3567-2.7748-8.1957-4.0656-2.9036-1.2903-6.0011-2.3877-9.2923-2.3877s-6.3248,1.0973-9.2285,2.3877c-2.8398,1.2907-5.6143,2.7752-8.1957,4.0656-.516.2584-1.0973.5164-1.6133.7744-2.0647-1.0969-4.001-2.4519-5.7435-4.001,2.3877-.5806,4.8399-1.6775,7.3568-2.8394,2.5814-1.2903,5.3559-2.8394,8.1957-4.1298,2.9036-1.2907,5.9372-2.3877,9.2285-2.3877s6.3886,1.0969,9.2923,2.3877c2.839,1.2903,5.5497,2.8394,8.1957,4.1298,2.5161,1.1619,4.9045,2.2589,7.356,2.8394Z" style="fill:#aadde1;"/></g></g></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76.5997 76.5918"><g id="Components"><g id="_x32_9414058-301b-4d05-be55-13a1e0a9aa73_x40_1"><path d="M50.9806,74.333c-3.3558,1.2907-7.8269,2.2589-12.7127,2.2589s-9.293-.9681-12.6489-2.2589c1.0973-.5806,2.2585-1.1615,3.4204-1.6779,2.9036-1.2903,5.9372-2.3877,9.2285-2.3877s6.3886,1.0973,9.2923,2.3877c1.1611.5164,2.3231,1.0973,3.4204,1.6779Z" style="fill:#0b2d40;"/><path d="M38.268,0C17.1658,0,0,17.1654,0,38.3322c0,.9032.0638,1.8713.1284,2.7748.1292,2.2585.4522,4.3881,1.0335,6.5178,1.6133.7098,3.162,1.2257,4.6461,1.6133.9028.258,1.8071.3872,2.6453.3872,1.6133.1288,3.162,0,4.7753-.3872,2.453-.581,5.0337-1.6779,7.6151-2.9686,2.5814-1.2261,5.3559-2.7748,8.1957-4.0656,2.9036-1.2907,5.9372-2.3877,9.2285-2.3877s6.3886,1.0969,9.2923,2.3877c2.839,1.2907,5.5497,2.8394,8.1957,4.0656,2.5807,1.2907,5.0983,2.3877,7.6144,2.9686,1.6133.3872,3.2266.516,4.8399.3872.839-.0646,1.7425-.1938,2.5814-.3872,1.4841-.3876,3.0336-.9035,4.6469-1.6133.5152-2.1297.9028-4.2593,1.0319-6.5178.0646-.9035.1292-1.8717.1292-2.7748C76.5997,17.1654,59.4339,0,38.268,0Z" style="fill:#0b2d40;"/><path d="M72.8563,54.7153c-1.1611,2.5169-2.5807,4.8399-4.3232,6.9696-1.7425.1934-3.4204.1288-5.1629-.2584-2.5161-.6452-5.0337-1.6779-7.6144-2.9682-2.646-1.2907-5.3567-2.7748-8.1957-4.0656-2.9036-1.2907-6.0011-2.3877-9.2923-2.3877s-6.3248,1.0969-9.2285,2.3877c-2.8398,1.2907-5.6143,2.7748-8.1957,4.0656-2.5814,1.2903-5.1621,2.3231-7.6151,2.9682-1.7425.3872-3.4204.4518-5.1621.2584-1.7425-2.1297-3.2266-4.4527-4.3885-6.9696.7105.1934,1.4195.3872,2.1301.5806,2.4515.581,4.9045.581,7.4206,0,2.453-.5806,5.0337-1.6133,7.6151-2.9036,2.5814-1.2907,5.3559-2.8394,8.1957-4.0656,2.9036-1.2907,5.9372-2.3877,9.2285-2.3877s6.3886,1.0969,9.2923,2.3877c2.839,1.2261,5.5497,2.7748,8.1957,4.0656,2.5807,1.2903,5.0983,2.3231,7.6144,2.9036,2.4523.581,4.9045.581,7.4214,0,.6452-.1288,1.3557-.3226,2.0647-.5806Z" style="fill:#0b2d40;"/><path d="M63.1119,67.428c-1.7425,1.5491-3.678,2.904-5.7427,4.001-.5168-.258-1.0973-.516-1.6133-.7744-2.646-1.2903-5.3567-2.7748-8.1957-4.0656-2.9036-1.2903-6.0011-2.3877-9.2923-2.3877s-6.3248,1.0973-9.2285,2.3877c-2.8398,1.2907-5.6143,2.7752-8.1957,4.0656-.516.2584-1.0973.5164-1.6133.7744-2.0647-1.0969-4.001-2.4519-5.7435-4.001,2.3877-.5806,4.8399-1.6775,7.3568-2.8394,2.5814-1.2903,5.3559-2.8394,8.1957-4.1298,2.9036-1.2907,5.9372-2.3877,9.2285-2.3877s6.3886,1.0969,9.2923,2.3877c2.839,1.2903,5.5497,2.8394,8.1957,4.1298,2.5161,1.1619,4.9045,2.2589,7.356,2.8394Z" style="fill:#0b2d40;"/></g></g></svg>
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76.5997 76.5918"><g id="Components"><g id="_x32_9414058-301b-4d05-be55-13a1e0a9aa73_x40_1"><path d="M50.9806,74.333c-3.3558,1.2907-7.8269,2.2589-12.7127,2.2589s-9.293-.9681-12.6489-2.2589c1.0973-.5806,2.2585-1.1615,3.4204-1.6779,2.9036-1.2903,5.9372-2.3877,9.2285-2.3877s6.3886,1.0973,9.2923,2.3877c1.1611.5164,2.3231,1.0973,3.4204,1.6779Z" fill="currentColor"/><path d="M38.268,0C17.1658,0,0,17.1654,0,38.3322c0,.9032.0638,1.8713.1284,2.7748.1292,2.2585.4522,4.3881,1.0335,6.5178,1.6133.7098,3.162,1.2257,4.6461,1.6133.9028.258,1.8071.3872,2.6453.3872,1.6133.1288,3.162,0,4.7753-.3872,2.453-.581,5.0337-1.6779,7.6151-2.9686,2.5814-1.2261,5.3559-2.7748,8.1957-4.0656,2.9036-1.2907,5.9372-2.3877,9.2285-2.3877s6.3886,1.0969,9.2923,2.3877c2.839,1.2907,5.5497,2.8394,8.1957,4.0656,2.5807,1.2907,5.0983,2.3877,7.6144,2.9686,1.6133.3872,3.2266.516,4.8399.3872.839-.0646,1.7425-.1938,2.5814-.3872,1.4841-.3876,3.0336-.9035,4.6469-1.6133.5152-2.1297.9028-4.2593,1.0319-6.5178.0646-.9035.1292-1.8717.1292-2.7748C76.5997,17.1654,59.4339,0,38.268,0Z" fill="currentColor"/><path d="M72.8563,54.7153c-1.1611,2.5169-2.5807,4.8399-4.3232,6.9696-1.7425.1934-3.4204.1288-5.1629-.2584-2.5161-.6452-5.0337-1.6779-7.6144-2.9682-2.646-1.2907-5.3567-2.7748-8.1957-4.0656-2.9036-1.2907-6.0011-2.3877-9.2923-2.3877s-6.3248,1.0969-9.2285,2.3877c-2.8398,1.2907-5.6143,2.7748-8.1957,4.0656-2.5814,1.2903-5.1621,2.3231-7.6151,2.9682-1.7425.3872-3.4204.4518-5.1621.2584-1.7425-2.1297-3.2266-4.4527-4.3885-6.9696.7105.1934,1.4195.3872,2.1301.5806,2.4515.581,4.9045.581,7.4206,0,2.453-.5806,5.0337-1.6133,7.6151-2.9036,2.5814-1.2907,5.3559-2.8394,8.1957-4.0656,2.9036-1.2907,5.9372-2.3877,9.2285-2.3877s6.3886,1.0969,9.2923,2.3877c2.839,1.2261,5.5497,2.7748,8.1957,4.0656,2.5807,1.2903,5.0983,2.3231,7.6144,2.9036,2.4523.581,4.9045.581,7.4214,0,.6452-.1288,1.3557-.3226,2.0647-.5806Z" fill="currentColor"/><path d="M63.1119,67.428c-1.7425,1.5491-3.678,2.904-5.7427,4.001-.5168-.258-1.0973-.516-1.6133-.7744-2.646-1.2903-5.3567-2.7748-8.1957-4.0656-2.9036-1.2903-6.0011-2.3877-9.2923-2.3877s-6.3248,1.0973-9.2285,2.3877c-2.8398,1.2907-5.6143,2.7752-8.1957,4.0656-.516.2584-1.0973.5164-1.6133.7744-2.0647-1.0969-4.001-2.4519-5.7435-4.001,2.3877-.5806,4.8399-1.6775,7.3568-2.8394,2.5814-1.2903,5.3559-2.8394,8.1957-4.1298,2.9036-1.2907,5.9372-2.3877,9.2285-2.3877s6.3886,1.0969,9.2923,2.3877c2.839,1.2903,5.5497,2.8394,8.1957,4.1298,2.5161,1.1619,4.9045,2.2589,7.356,2.8394Z" fill="currentColor"/></g></g></svg>
|