@webmcp-auto-ui/ui 2.5.34 → 2.5.36
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/package.json +1 -1
- package/src/index.ts +4 -0
- package/src/recipe/RecipeCodeBlock.svelte +323 -0
- package/src/recipe/types.ts +10 -0
- package/src/widgets/notebook/executors/sql.ts +17 -6
- package/src/widgets/notebook/import-modal-api.ts +0 -24
- package/src/widgets/notebook/import-modal.svelte +31 -62
- package/src/widgets/notebook/left-pane.ts +27 -9
- package/src/widgets/notebook/notebook.svelte +0 -1
- package/src/widgets/notebook/notebook.ts +7 -20
- package/src/widgets/notebook/prose.ts +0 -78
- package/src/widgets/notebook/recipes/notebook.md +0 -6
- package/src/widgets/notebook/resource-extractor.ts +21 -1
- package/src/widgets/notebook/share-handlers.ts +5 -2
- package/src/widgets/notebook/shared.ts +32 -10
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -19,6 +19,10 @@ export { default as MarkdownView } from './primitives/MarkdownView.svelte';
|
|
|
19
19
|
export { default as CodeView } from './primitives/CodeView.svelte';
|
|
20
20
|
export { renderMarkdown, highlightCode, createMarkdownRenderer } from './primitives/markdown-renderer.js';
|
|
21
21
|
|
|
22
|
+
// Recipe building blocks (used by RecipeModal and notebook recipe-viewer)
|
|
23
|
+
export { default as RecipeCodeBlock } from './recipe/RecipeCodeBlock.svelte';
|
|
24
|
+
export type { RecipeBlockAction } from './recipe/types.js';
|
|
25
|
+
|
|
22
26
|
// Widgets are shipped as Svelte 5 custom elements — import the widget file
|
|
23
27
|
// side-effect to register its tag (e.g. `import '@webmcp-auto-ui/ui/widgets/simple/stat.svelte';`
|
|
24
28
|
// then use `<auto-stat data={spec}></auto-stat>`). `WidgetRenderer` does this for you.
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { McpMultiClient } from '@webmcp-auto-ui/core';
|
|
3
|
+
import type { RunResult } from '@webmcp-auto-ui/sdk';
|
|
4
|
+
import { runCode, estimateTokens } from '@webmcp-auto-ui/sdk';
|
|
5
|
+
import { highlightCode } from '../primitives/markdown-renderer.js';
|
|
6
|
+
import type { RecipeBlockAction } from './types.js';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
code: string;
|
|
10
|
+
lang?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Custom action buttons in the gutter. If omitted AND `onrun` is provided,
|
|
13
|
+
* a default Run button is rendered (back-compat with original flex behavior).
|
|
14
|
+
*/
|
|
15
|
+
actions?: RecipeBlockAction[];
|
|
16
|
+
/**
|
|
17
|
+
* Back-compat: legacy flex usage. When set, a default Run button is rendered
|
|
18
|
+
* that calls runCode(code, lang) and forwards the result to this callback.
|
|
19
|
+
* Ignored when `actions` is provided.
|
|
20
|
+
*/
|
|
21
|
+
onrun?: (payload: { code: string; lang: string; result: RunResult }) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let {
|
|
25
|
+
code = '',
|
|
26
|
+
lang = 'text',
|
|
27
|
+
actions = undefined,
|
|
28
|
+
onrun,
|
|
29
|
+
}: Props = $props();
|
|
30
|
+
|
|
31
|
+
let editable = $state('');
|
|
32
|
+
let runStatus = $state<'idle' | 'running' | 'done' | 'error'>('idle');
|
|
33
|
+
let elapsed = $state(0);
|
|
34
|
+
let liveTokens = $state(0);
|
|
35
|
+
let lastDuration = $state<number | undefined>(undefined);
|
|
36
|
+
let lastTokens = $state<number | undefined>(undefined);
|
|
37
|
+
let timerId: ReturnType<typeof setInterval> | undefined;
|
|
38
|
+
let doneResetId: ReturnType<typeof setTimeout> | undefined;
|
|
39
|
+
|
|
40
|
+
$effect(() => {
|
|
41
|
+
editable = code;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
let highlightedHtml = $derived(highlightCode(editable, lang || 'plaintext'));
|
|
45
|
+
let preEl: HTMLPreElement | undefined = $state(undefined);
|
|
46
|
+
let taEl: HTMLTextAreaElement | undefined = $state(undefined);
|
|
47
|
+
|
|
48
|
+
function syncScroll() {
|
|
49
|
+
if (preEl && taEl) {
|
|
50
|
+
preEl.scrollTop = taEl.scrollTop;
|
|
51
|
+
preEl.scrollLeft = taEl.scrollLeft;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatTokens(n: number): string {
|
|
56
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
|
57
|
+
return `${n} tok`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function startTimer(startAt: number) {
|
|
61
|
+
stopTimer();
|
|
62
|
+
timerId = setInterval(() => {
|
|
63
|
+
elapsed = Math.round(performance.now() - startAt);
|
|
64
|
+
liveTokens = estimateTokens(editable);
|
|
65
|
+
}, 60);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function stopTimer() {
|
|
69
|
+
if (timerId) {
|
|
70
|
+
clearInterval(timerId);
|
|
71
|
+
timerId = undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function handleRun() {
|
|
76
|
+
if (runStatus === 'running') return;
|
|
77
|
+
if (doneResetId) {
|
|
78
|
+
clearTimeout(doneResetId);
|
|
79
|
+
doneResetId = undefined;
|
|
80
|
+
}
|
|
81
|
+
runStatus = 'running';
|
|
82
|
+
elapsed = 0;
|
|
83
|
+
liveTokens = estimateTokens(editable);
|
|
84
|
+
const t0 = performance.now();
|
|
85
|
+
startTimer(t0);
|
|
86
|
+
|
|
87
|
+
const multi = (globalThis as unknown as { __multiMcp?: { multiClient: McpMultiClient } }).__multiMcp?.multiClient;
|
|
88
|
+
const result = await runCode(editable, lang, multi);
|
|
89
|
+
|
|
90
|
+
stopTimer();
|
|
91
|
+
lastDuration = result.durationMs;
|
|
92
|
+
lastTokens = result.tokens;
|
|
93
|
+
runStatus = result.status === 'error' ? 'error' : 'done';
|
|
94
|
+
|
|
95
|
+
onrun?.({ code: editable, lang, result });
|
|
96
|
+
|
|
97
|
+
if (runStatus === 'done') {
|
|
98
|
+
doneResetId = setTimeout(() => {
|
|
99
|
+
if (runStatus === 'done') runStatus = 'idle';
|
|
100
|
+
}, 1000);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Resolve the action list: explicit actions OR back-compat single Run button.
|
|
105
|
+
const resolvedActions = $derived<RecipeBlockAction[]>(
|
|
106
|
+
actions && actions.length > 0
|
|
107
|
+
? actions
|
|
108
|
+
: onrun
|
|
109
|
+
? [{
|
|
110
|
+
icon: '▶',
|
|
111
|
+
label: 'Run',
|
|
112
|
+
onclick: () => handleRun(),
|
|
113
|
+
}]
|
|
114
|
+
: []
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// For the single-action Run case, we render run-status icon + stats inline.
|
|
118
|
+
const isSingleRunAction = $derived(
|
|
119
|
+
!actions && !!onrun && resolvedActions.length === 1
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
function handleActionClick(a: RecipeBlockAction) {
|
|
123
|
+
a.onclick(editable, lang);
|
|
124
|
+
}
|
|
125
|
+
</script>
|
|
126
|
+
|
|
127
|
+
<div class="code-block">
|
|
128
|
+
<div class="gutter">
|
|
129
|
+
{#if isSingleRunAction}
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
class="run-btn {runStatus}"
|
|
133
|
+
onclick={() => handleRun()}
|
|
134
|
+
disabled={runStatus === 'running'}
|
|
135
|
+
title={runStatus === 'running' ? 'Running...' : 'Run'}
|
|
136
|
+
>
|
|
137
|
+
<span class="icon">
|
|
138
|
+
{#if runStatus === 'running'}
|
|
139
|
+
{@html '◐'}
|
|
140
|
+
{:else if runStatus === 'done'}
|
|
141
|
+
{@html '✓'}
|
|
142
|
+
{:else if runStatus === 'error'}
|
|
143
|
+
!
|
|
144
|
+
{:else}
|
|
145
|
+
{@html '▶'}
|
|
146
|
+
{/if}
|
|
147
|
+
</span>
|
|
148
|
+
{#if runStatus === 'running' || lastDuration !== undefined}
|
|
149
|
+
<span class="stats">
|
|
150
|
+
<span class="t">
|
|
151
|
+
{runStatus === 'running' ? `${elapsed}ms` : `${lastDuration}ms`}
|
|
152
|
+
</span>
|
|
153
|
+
<span class="tok">
|
|
154
|
+
{formatTokens(runStatus === 'running' ? liveTokens : (lastTokens ?? 0))}
|
|
155
|
+
</span>
|
|
156
|
+
</span>
|
|
157
|
+
{/if}
|
|
158
|
+
</button>
|
|
159
|
+
{:else}
|
|
160
|
+
{#each resolvedActions as a}
|
|
161
|
+
<button
|
|
162
|
+
type="button"
|
|
163
|
+
class="action-btn {a.variant ?? 'default'}"
|
|
164
|
+
onclick={() => handleActionClick(a)}
|
|
165
|
+
title={a.label ?? a.icon}
|
|
166
|
+
>
|
|
167
|
+
<span class="icon">{@html a.icon}</span>
|
|
168
|
+
</button>
|
|
169
|
+
{/each}
|
|
170
|
+
{/if}
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<div class="editor-wrap">
|
|
174
|
+
{#if lang && lang !== 'text'}
|
|
175
|
+
<div class="lang-tag font-mono">{lang}</div>
|
|
176
|
+
{/if}
|
|
177
|
+
<pre bind:this={preEl} class="editor highlight-layer hljs font-mono" aria-hidden="true"><code class="hljs language-{lang || 'plaintext'}">{@html highlightedHtml}</code></pre>
|
|
178
|
+
<textarea
|
|
179
|
+
bind:this={taEl}
|
|
180
|
+
bind:value={editable}
|
|
181
|
+
onscroll={syncScroll}
|
|
182
|
+
spellcheck="false"
|
|
183
|
+
autocomplete="off"
|
|
184
|
+
rows={Math.min(Math.max(editable.split('\n').length, 3), 20)}
|
|
185
|
+
class="editor input-layer font-mono"
|
|
186
|
+
></textarea>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<style>
|
|
191
|
+
.code-block {
|
|
192
|
+
display: flex;
|
|
193
|
+
align-items: stretch;
|
|
194
|
+
gap: 6px;
|
|
195
|
+
margin: 0.5rem 0;
|
|
196
|
+
}
|
|
197
|
+
.gutter {
|
|
198
|
+
display: flex;
|
|
199
|
+
flex-direction: column;
|
|
200
|
+
gap: 4px;
|
|
201
|
+
align-items: stretch;
|
|
202
|
+
}
|
|
203
|
+
.run-btn,
|
|
204
|
+
.action-btn {
|
|
205
|
+
display: flex;
|
|
206
|
+
flex-direction: column;
|
|
207
|
+
align-items: center;
|
|
208
|
+
justify-content: center;
|
|
209
|
+
gap: 4px;
|
|
210
|
+
min-width: 56px;
|
|
211
|
+
padding: 8px 6px;
|
|
212
|
+
border-radius: 0.375rem;
|
|
213
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
214
|
+
background: #0d1117;
|
|
215
|
+
color: rgb(180, 180, 180);
|
|
216
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
217
|
+
font-size: 11px;
|
|
218
|
+
cursor: pointer;
|
|
219
|
+
transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
|
|
220
|
+
}
|
|
221
|
+
.run-btn:hover:not(:disabled),
|
|
222
|
+
.action-btn:hover:not(:disabled) {
|
|
223
|
+
background: #161b22;
|
|
224
|
+
color: #fff;
|
|
225
|
+
}
|
|
226
|
+
.run-btn:disabled,
|
|
227
|
+
.action-btn:disabled {
|
|
228
|
+
cursor: progress;
|
|
229
|
+
}
|
|
230
|
+
.run-btn .icon,
|
|
231
|
+
.action-btn .icon {
|
|
232
|
+
font-size: 14px;
|
|
233
|
+
line-height: 1;
|
|
234
|
+
}
|
|
235
|
+
.run-btn.running .icon {
|
|
236
|
+
animation: spin 0.9s linear infinite;
|
|
237
|
+
}
|
|
238
|
+
.run-btn.done,
|
|
239
|
+
.action-btn.success {
|
|
240
|
+
color: rgb(74, 222, 128);
|
|
241
|
+
border-color: rgba(74, 222, 128, 0.4);
|
|
242
|
+
}
|
|
243
|
+
.run-btn.error,
|
|
244
|
+
.action-btn.error {
|
|
245
|
+
color: rgb(248, 113, 113);
|
|
246
|
+
border-color: rgba(248, 113, 113, 0.4);
|
|
247
|
+
}
|
|
248
|
+
.run-btn .stats {
|
|
249
|
+
display: flex;
|
|
250
|
+
flex-direction: column;
|
|
251
|
+
gap: 1px;
|
|
252
|
+
font-size: 9px;
|
|
253
|
+
color: rgb(160, 160, 160);
|
|
254
|
+
line-height: 1.2;
|
|
255
|
+
}
|
|
256
|
+
.run-btn .stats .t { font-weight: 600; }
|
|
257
|
+
.run-btn .stats .tok { opacity: 0.8; }
|
|
258
|
+
@keyframes spin {
|
|
259
|
+
from { transform: rotate(0deg); }
|
|
260
|
+
to { transform: rotate(360deg); }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.editor-wrap {
|
|
264
|
+
flex: 1;
|
|
265
|
+
min-width: 0;
|
|
266
|
+
position: relative;
|
|
267
|
+
}
|
|
268
|
+
.lang-tag {
|
|
269
|
+
position: absolute;
|
|
270
|
+
top: 4px;
|
|
271
|
+
right: 8px;
|
|
272
|
+
font-size: 9px;
|
|
273
|
+
color: rgba(255, 255, 255, 0.35);
|
|
274
|
+
text-transform: lowercase;
|
|
275
|
+
pointer-events: none;
|
|
276
|
+
}
|
|
277
|
+
.editor {
|
|
278
|
+
display: block;
|
|
279
|
+
width: 100%;
|
|
280
|
+
box-sizing: border-box;
|
|
281
|
+
background: #0d1117;
|
|
282
|
+
color: rgb(220, 220, 220);
|
|
283
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
284
|
+
border-radius: 0.375rem;
|
|
285
|
+
padding: 0.7rem;
|
|
286
|
+
font-size: 0.7rem;
|
|
287
|
+
line-height: 1.5;
|
|
288
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
289
|
+
white-space: pre;
|
|
290
|
+
tab-size: 2;
|
|
291
|
+
margin: 0;
|
|
292
|
+
}
|
|
293
|
+
.highlight-layer {
|
|
294
|
+
position: absolute;
|
|
295
|
+
inset: 0;
|
|
296
|
+
overflow: auto;
|
|
297
|
+
pointer-events: none;
|
|
298
|
+
border-color: transparent;
|
|
299
|
+
}
|
|
300
|
+
.highlight-layer :global(code.hljs) {
|
|
301
|
+
background: transparent;
|
|
302
|
+
padding: 0;
|
|
303
|
+
}
|
|
304
|
+
.input-layer {
|
|
305
|
+
position: relative;
|
|
306
|
+
color: transparent;
|
|
307
|
+
caret-color: rgb(220, 220, 220);
|
|
308
|
+
background: transparent;
|
|
309
|
+
resize: vertical;
|
|
310
|
+
outline: none;
|
|
311
|
+
overflow: auto;
|
|
312
|
+
}
|
|
313
|
+
.input-layer::selection {
|
|
314
|
+
color: transparent;
|
|
315
|
+
background: rgba(96, 165, 250, 0.35);
|
|
316
|
+
}
|
|
317
|
+
.input-layer:focus {
|
|
318
|
+
border-color: rgba(96, 165, 250, 0.45);
|
|
319
|
+
}
|
|
320
|
+
.editor-wrap {
|
|
321
|
+
min-height: 0;
|
|
322
|
+
}
|
|
323
|
+
</style>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface RecipeBlockAction {
|
|
2
|
+
/** Glyph or HTML entity. Examples: '▶', '+', '⧉', '✓' */
|
|
3
|
+
icon: string;
|
|
4
|
+
/** Tooltip text. Defaults to icon. */
|
|
5
|
+
label?: string;
|
|
6
|
+
/** Visual variant. */
|
|
7
|
+
variant?: 'default' | 'success' | 'error';
|
|
8
|
+
/** Click handler. Receives the (possibly edited) code + lang. */
|
|
9
|
+
onclick: (code: string, lang: string) => void;
|
|
10
|
+
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { callToolViaPostMessage } from '@webmcp-auto-ui/core';
|
|
2
|
-
import
|
|
2
|
+
import { findCodeParamName, buildToolArgs } from '@webmcp-auto-ui/sdk';
|
|
3
|
+
import type { CellExecutor, CellExecContext, CellResult, DataServerDescriptor, DataServerTool } from '../shared.js';
|
|
3
4
|
|
|
4
5
|
const PATTERN_PRIMARY = /^.*query_sql$/i;
|
|
5
6
|
const PATTERN_FALLBACK = /^(query|run|execute)(_sql)?$/i;
|
|
6
7
|
|
|
7
|
-
function findSqlTool(servers: DataServerDescriptor[]):
|
|
8
|
+
function findSqlTool(servers: DataServerDescriptor[]): DataServerTool | null {
|
|
8
9
|
for (const p of [PATTERN_PRIMARY, PATTERN_FALLBACK]) {
|
|
9
10
|
for (const srv of servers) {
|
|
10
|
-
for (const t of srv.tools ?? []) if (p.test(t.name)) return t
|
|
11
|
+
for (const t of srv.tools ?? []) if (p.test(t.name)) return t;
|
|
11
12
|
}
|
|
12
13
|
}
|
|
13
14
|
return null;
|
|
@@ -16,16 +17,26 @@ function findSqlTool(servers: DataServerDescriptor[]): string | null {
|
|
|
16
17
|
export function createSqlExecutor(getServers: () => DataServerDescriptor[]): CellExecutor {
|
|
17
18
|
return async (ctx: CellExecContext): Promise<CellResult> => {
|
|
18
19
|
const startedAt = Date.now();
|
|
19
|
-
const
|
|
20
|
-
if (!
|
|
20
|
+
const tool = findSqlTool(getServers());
|
|
21
|
+
if (!tool) {
|
|
21
22
|
return { ok: false, error: 'No SQL tool available on connected servers.', errorKind: 'schema', durationMs: Date.now() - startedAt };
|
|
22
23
|
}
|
|
23
24
|
const sql = (ctx.cell.content ?? '').trim();
|
|
24
25
|
if (!sql) return { ok: true, kind: 'empty', durationMs: Date.now() - startedAt };
|
|
25
26
|
|
|
27
|
+
// Build args from the tool's inputSchema:
|
|
28
|
+
// 1. Pick the code-carrying param (query / sql / statement / ...) via findCodeParamName.
|
|
29
|
+
// 2. Auto-infer required params (e.g. `schema` enum from FROM/JOIN regex).
|
|
30
|
+
// 3. Merge cell-level overrides from cell.args (parsed from `-- @meta {...}` line).
|
|
31
|
+
const codeParam = findCodeParamName(tool.inputSchema) ?? 'sql';
|
|
32
|
+
const auto = buildToolArgs(tool.inputSchema, codeParam, sql, 'sql');
|
|
33
|
+
const args: Record<string, unknown> = { ...auto, ...(ctx.cell.args ?? {}) };
|
|
34
|
+
// Code param is owned by the cell content, never overridable via @meta
|
|
35
|
+
args[codeParam] = sql;
|
|
36
|
+
|
|
26
37
|
let raw: unknown;
|
|
27
38
|
try {
|
|
28
|
-
raw = await callToolViaPostMessage(
|
|
39
|
+
raw = await callToolViaPostMessage(tool.name, args);
|
|
29
40
|
} catch (err) {
|
|
30
41
|
return { ok: false, error: String((err as { message?: unknown })?.message ?? err), errorKind: 'runtime', durationMs: Date.now() - startedAt };
|
|
31
42
|
}
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
// Side-effect import: registers <auto-import-modal> custom element
|
|
17
17
|
import './import-modal.svelte';
|
|
18
18
|
|
|
19
|
-
import { renderMarkdownWithInjectButtons } from './prose.js';
|
|
20
19
|
import { extractCellsFromRecipe, extractCellsFromTool, extractCellFromFence } from './resource-extractor.js';
|
|
21
20
|
import type { NotebookCell } from './shared.js';
|
|
22
21
|
import type { McpToolLike } from './resource-extractor.js';
|
|
@@ -150,9 +149,6 @@ export function openRecipeViewerModal(
|
|
|
150
149
|
|
|
151
150
|
_cleanup?.();
|
|
152
151
|
|
|
153
|
-
// We'll hold a reference to the prose renderer's destroy fn.
|
|
154
|
-
let proseDestroy: (() => void) | null = null;
|
|
155
|
-
|
|
156
152
|
const handler = (e: CustomEvent) => {
|
|
157
153
|
const { action, payload } = e.detail ?? {};
|
|
158
154
|
|
|
@@ -172,8 +168,6 @@ export function openRecipeViewerModal(
|
|
|
172
168
|
|
|
173
169
|
if (action === 'inject-all' || action === 'close') {
|
|
174
170
|
el.removeEventListener('widget:interact', handler as EventListener);
|
|
175
|
-
proseDestroy?.();
|
|
176
|
-
proseDestroy = null;
|
|
177
171
|
_cleanup = null;
|
|
178
172
|
}
|
|
179
173
|
};
|
|
@@ -181,27 +175,9 @@ export function openRecipeViewerModal(
|
|
|
181
175
|
el.addEventListener('widget:interact', handler as EventListener);
|
|
182
176
|
_cleanup = () => {
|
|
183
177
|
el.removeEventListener('widget:interact', handler as EventListener);
|
|
184
|
-
proseDestroy?.();
|
|
185
|
-
proseDestroy = null;
|
|
186
178
|
};
|
|
187
179
|
|
|
188
180
|
el.openModal({ mode: 'recipe-viewer', recipe });
|
|
189
|
-
|
|
190
|
-
// After the CE opens, inject the rendered markdown into [data-role="render"].
|
|
191
|
-
// requestAnimationFrame ensures Svelte has rendered the modal DOM.
|
|
192
|
-
requestAnimationFrame(() => {
|
|
193
|
-
const renderTarget = el.querySelector('[data-role="render"]') as HTMLElement | null;
|
|
194
|
-
if (!renderTarget) return;
|
|
195
|
-
const { root, destroy } = renderMarkdownWithInjectButtons(
|
|
196
|
-
recipe.body ?? '',
|
|
197
|
-
({ lang, content }) => {
|
|
198
|
-
const cell = extractCellFromFence(lang, content);
|
|
199
|
-
onInjectCell(cell);
|
|
200
|
-
},
|
|
201
|
-
);
|
|
202
|
-
renderTarget.appendChild(root);
|
|
203
|
-
proseDestroy = destroy;
|
|
204
|
-
});
|
|
205
181
|
}
|
|
206
182
|
|
|
207
183
|
// ---------------------------------------------------------------------------
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
|
|
20
20
|
import { filterRecipes, sortRecipes, WEBMCP_RECIPES } from '@webmcp-auto-ui/agent';
|
|
21
21
|
import { callToolViaPostMessage } from '@webmcp-auto-ui/core';
|
|
22
|
+
import { parseBody } from '@webmcp-auto-ui/sdk';
|
|
23
|
+
import MarkdownView from '../../primitives/MarkdownView.svelte';
|
|
24
|
+
import RecipeCodeBlock from '../../recipe/RecipeCodeBlock.svelte';
|
|
22
25
|
|
|
23
26
|
// ---------------------------------------------------------------------------
|
|
24
27
|
// Types (inlined to avoid import-modals.ts cycle)
|
|
@@ -90,6 +93,10 @@
|
|
|
90
93
|
const mode = $derived(data?.mode ?? 'add-md');
|
|
91
94
|
const recipe = $derived(data?.recipe);
|
|
92
95
|
const tool = $derived(data?.tool);
|
|
96
|
+
const recipeSegments = $derived(parseBody(recipe?.body ?? ''));
|
|
97
|
+
const recipeCellCount = $derived(
|
|
98
|
+
recipeSegments.length + ((recipe?.name || recipe?.description) ? 1 : 0),
|
|
99
|
+
);
|
|
93
100
|
|
|
94
101
|
// ---------------------------------------------------------------------------
|
|
95
102
|
// Public API — called by wrapper functions
|
|
@@ -255,25 +262,13 @@
|
|
|
255
262
|
// Handlers — recipe-viewer
|
|
256
263
|
// ---------------------------------------------------------------------------
|
|
257
264
|
|
|
258
|
-
// prose rendering is done server-side for recipe-viewer; we re-use the
|
|
259
|
-
// renderMarkdownWithInjectButtons helper injected via slot or data.
|
|
260
|
-
// Since this is a CE, we render as raw HTML via {@html} after sanitizing.
|
|
261
|
-
// The inject buttons are wired via event delegation on the section.
|
|
262
|
-
|
|
263
265
|
function handleInjectAll() {
|
|
264
|
-
// extractCellsFromRecipe
|
|
265
|
-
// but import would create a cycle ui<->notebook. We emit the raw body
|
|
266
|
-
// and let the consumer (notebook.ts) call extractCellsFromRecipe.
|
|
266
|
+
// Notebook owns extractCellsFromRecipe to avoid a ui<->notebook cycle.
|
|
267
267
|
emitInteract('inject-all', { recipe: recipe });
|
|
268
268
|
closeModal();
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
-
function handleInjectFence(
|
|
272
|
-
// Delegated click on .nb-md-fence-inject buttons rendered via {@html}
|
|
273
|
-
const btn = (e.target as HTMLElement).closest('[data-fence-inject]') as HTMLElement | null;
|
|
274
|
-
if (!btn) return;
|
|
275
|
-
const lang = btn.dataset.lang ?? '';
|
|
276
|
-
const content = btn.dataset.content ?? '';
|
|
271
|
+
function handleInjectFence(content: string, lang: string) {
|
|
277
272
|
emitInteract('inject-fence', { lang, content });
|
|
278
273
|
}
|
|
279
274
|
|
|
@@ -426,12 +421,30 @@
|
|
|
426
421
|
{#if recipe?.description}<p>{recipe.description}</p>{/if}
|
|
427
422
|
{#if recipe?.serverName}<span class="nb-imp-recipe-srv">{recipe.serverName}</span>{/if}
|
|
428
423
|
</div>
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
424
|
+
<section class="nb-imp-body nb-imp-body-recipe">
|
|
425
|
+
{#each recipeSegments as seg, i (i)}
|
|
426
|
+
{#if seg.type === 'markdown'}
|
|
427
|
+
<MarkdownView source={seg.content} />
|
|
428
|
+
{:else}
|
|
429
|
+
<RecipeCodeBlock
|
|
430
|
+
code={seg.content}
|
|
431
|
+
lang={seg.lang ?? 'text'}
|
|
432
|
+
actions={[{
|
|
433
|
+
icon: '+',
|
|
434
|
+
label: 'Inject as cell',
|
|
435
|
+
onclick: handleInjectFence,
|
|
436
|
+
}]}
|
|
437
|
+
/>
|
|
438
|
+
{/if}
|
|
439
|
+
{/each}
|
|
432
440
|
</section>
|
|
433
441
|
<footer class="nb-imp-foot">
|
|
434
|
-
<button
|
|
442
|
+
<button
|
|
443
|
+
type="button"
|
|
444
|
+
class="nb-imp-btn nb-imp-primary"
|
|
445
|
+
onclick={handleInjectAll}
|
|
446
|
+
disabled={recipeCellCount === 0}
|
|
447
|
+
>Inject all cells{recipeCellCount > 0 ? ` (${recipeCellCount})` : ''}</button>
|
|
435
448
|
</footer>
|
|
436
449
|
|
|
437
450
|
<!-- ================================================================ -->
|
|
@@ -691,48 +704,4 @@
|
|
|
691
704
|
font-size: 11.5px;
|
|
692
705
|
}
|
|
693
706
|
|
|
694
|
-
/* ---- Styles for prose rendered inside recipe-viewer (delegated via {@html} in consumers) ---- */
|
|
695
|
-
:global(.nb-md-render) { font-size: 13px; line-height: 1.5; }
|
|
696
|
-
:global(.nb-md-render h1),
|
|
697
|
-
:global(.nb-md-render h2),
|
|
698
|
-
:global(.nb-md-render h3) { margin: 12px 0 6px; }
|
|
699
|
-
:global(.nb-md-render p) { margin: 6px 0; }
|
|
700
|
-
:global(.nb-md-render ul),
|
|
701
|
-
:global(.nb-md-render ol) { margin: 6px 0 6px 20px; }
|
|
702
|
-
:global(.nb-md-render pre) {
|
|
703
|
-
background: var(--color-surface2, #f4f4f5);
|
|
704
|
-
padding: 10px 12px;
|
|
705
|
-
border-radius: 6px;
|
|
706
|
-
overflow-x: auto;
|
|
707
|
-
font-size: 12px;
|
|
708
|
-
}
|
|
709
|
-
:global(.nb-md-render code) { font-family: var(--font-mono, monospace); font-size: 12px; }
|
|
710
|
-
:global(.nb-md-fence) {
|
|
711
|
-
border: 1px solid var(--color-border, #e4e4e7);
|
|
712
|
-
border-radius: 8px;
|
|
713
|
-
margin: 10px 0;
|
|
714
|
-
overflow: hidden;
|
|
715
|
-
}
|
|
716
|
-
:global(.nb-md-fence-head) {
|
|
717
|
-
display: flex;
|
|
718
|
-
align-items: center;
|
|
719
|
-
gap: 10px;
|
|
720
|
-
padding: 6px 10px;
|
|
721
|
-
background: var(--color-surface2, #f4f4f5);
|
|
722
|
-
font-size: 11px;
|
|
723
|
-
color: var(--color-text2, #666);
|
|
724
|
-
border-bottom: 1px solid var(--color-border, #e4e4e7);
|
|
725
|
-
}
|
|
726
|
-
:global(.nb-md-fence-lang) { font-family: monospace; flex: 1; }
|
|
727
|
-
:global(.nb-md-fence-inject) {
|
|
728
|
-
background: var(--color-accent, #6a55ff);
|
|
729
|
-
color: #fff;
|
|
730
|
-
border: 0;
|
|
731
|
-
border-radius: 4px;
|
|
732
|
-
padding: 3px 9px;
|
|
733
|
-
font-size: 11px;
|
|
734
|
-
cursor: pointer;
|
|
735
|
-
}
|
|
736
|
-
:global(.nb-md-fence-inject:hover) { filter: brightness(1.08); }
|
|
737
|
-
:global(.nb-md-fence pre) { margin: 0; border-radius: 0; background: transparent; }
|
|
738
707
|
</style>
|
|
@@ -74,7 +74,6 @@ export function mountLeftPane(
|
|
|
74
74
|
<header class="nb-lp-srv-head">
|
|
75
75
|
<span class="nb-lp-srv-dot"></span>
|
|
76
76
|
<span class="nb-lp-srv-name">${escapeHtml(srv.name)}</span>
|
|
77
|
-
<span class="nb-lp-srv-meta">${(srv.recipes?.length ?? 0)} recipes · ${(srv.tools?.length ?? 0)} tools</span>
|
|
78
77
|
</header>
|
|
79
78
|
<div class="nb-lp-srv-groups">
|
|
80
79
|
${srv.recipes?.length ? `
|
|
@@ -141,19 +140,39 @@ export function mountLeftPane(
|
|
|
141
140
|
let body = text;
|
|
142
141
|
try {
|
|
143
142
|
const parsed = JSON.parse(text);
|
|
144
|
-
// Recipe servers return either { content: "..." } (legacy) or
|
|
145
|
-
// { name, description, body, ... } (autoui-style). Pick whichever
|
|
146
|
-
// string field carries the markdown body, in priority order.
|
|
147
143
|
if (parsed && typeof parsed === 'object') {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
144
|
+
const candidates = [
|
|
145
|
+
parsed.body,
|
|
146
|
+
parsed.content,
|
|
147
|
+
parsed.markdown,
|
|
148
|
+
parsed.recipe?.body,
|
|
149
|
+
parsed.recipe?.content,
|
|
150
|
+
parsed.recipe?.markdown,
|
|
151
|
+
parsed.result?.body,
|
|
152
|
+
parsed.result?.content,
|
|
153
|
+
parsed.data?.body,
|
|
154
|
+
parsed.data?.content,
|
|
155
|
+
];
|
|
156
|
+
const hit = candidates.find((c) => typeof c === 'string' && c.trim().length > 0);
|
|
157
|
+
if (hit) {
|
|
158
|
+
body = hit;
|
|
159
|
+
} else {
|
|
160
|
+
console.warn(
|
|
161
|
+
`[notebook] get_recipe(${srv.name}/${r.name}): no recognized body key in parsed JSON. Keys:`,
|
|
162
|
+
Object.keys(parsed),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
151
165
|
}
|
|
152
166
|
} catch { /* not JSON, use raw text */ }
|
|
153
167
|
imported.body = body;
|
|
154
168
|
recipeBodyCache.set(key, body);
|
|
155
169
|
}
|
|
156
|
-
} catch {
|
|
170
|
+
} catch (err) {
|
|
171
|
+
console.warn(`[notebook] get_recipe(${srv.name}/${r.name}) failed:`, err);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (!imported.body) {
|
|
175
|
+
imported.body = `> ⚠ Failed to load recipe body from \`${srv.name}\`. The server returned no usable content for \`${r.name}\`.`;
|
|
157
176
|
}
|
|
158
177
|
openRecipeViewerModal(imported, (cell) => handlers.onInjectCells([cell]));
|
|
159
178
|
}
|
|
@@ -236,7 +255,6 @@ function injectLeftPaneStyles() {
|
|
|
236
255
|
flex-shrink: 0;
|
|
237
256
|
}
|
|
238
257
|
.nb-lp-srv-name { font-weight: 600; color: var(--color-text1, #111); }
|
|
239
|
-
.nb-lp-srv-meta { margin-left: auto; font-family: monospace; font-size: 10.5px; }
|
|
240
258
|
.nb-lp-group > summary {
|
|
241
259
|
cursor: pointer; padding: 4px 2px; font-size: 11px;
|
|
242
260
|
color: var(--color-text2, #666); font-family: monospace;
|
|
@@ -35,10 +35,8 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
35
35
|
title: data.title as string ?? 'Untitled notebook',
|
|
36
36
|
mode: (data.mode as any) ?? 'edit',
|
|
37
37
|
cells: data.cells as any,
|
|
38
|
-
kicker: (data.kicker as string) ?? undefined,
|
|
39
38
|
autoRun: (data as any).autoRun === true,
|
|
40
39
|
});
|
|
41
|
-
if (!state.kicker) state.kicker = (data.kicker as string) ?? 'untitled';
|
|
42
40
|
|
|
43
41
|
// Live mode runtime overlay (created lazily). Never mutates state.
|
|
44
42
|
let overlay: RuntimeOverlay | null = null;
|
|
@@ -56,7 +54,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
56
54
|
<div class="nbe-leftpane-slot"></div>
|
|
57
55
|
<div class="nbe-shell">
|
|
58
56
|
<div class="nbe-kicker">
|
|
59
|
-
<input class="nbe-kicker-input" value="${escapeAttr(state.kicker || '')}" placeholder="kicker…">
|
|
60
57
|
<span class="nbe-live-toggle-slot"></span>
|
|
61
58
|
<div class="nb-mode-switch" style="margin-left:auto;">
|
|
62
59
|
<button class="nb-mode-edit nb-on">edit</button>
|
|
@@ -259,10 +256,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
259
256
|
footerSlot: shell.querySelector('.nbe-publish-footer-slot') as HTMLElement,
|
|
260
257
|
onPublished: () => rerender(),
|
|
261
258
|
});
|
|
262
|
-
(shell.querySelector('.nbe-kicker-input') as HTMLInputElement).addEventListener('input', (e) => {
|
|
263
|
-
state.kicker = (e.target as HTMLInputElement).value;
|
|
264
|
-
state.lastEditAt = Date.now();
|
|
265
|
-
});
|
|
266
259
|
(shell.querySelector('.nbe-title') as HTMLInputElement).addEventListener('input', (e) => {
|
|
267
260
|
state.title = (e.target as HTMLInputElement).value;
|
|
268
261
|
state.lastEditAt = Date.now();
|
|
@@ -623,16 +616,6 @@ function injectLayoutStyles(): void {
|
|
|
623
616
|
letter-spacing: 0.1em; text-transform: uppercase;
|
|
624
617
|
margin-bottom: 14px;
|
|
625
618
|
}
|
|
626
|
-
.nbe-kicker-input {
|
|
627
|
-
flex: 0 0 auto; min-width: 120px;
|
|
628
|
-
background: transparent; border: 1px dashed transparent;
|
|
629
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
630
|
-
font-size: 11px; color: var(--color-text2);
|
|
631
|
-
letter-spacing: 0.1em; text-transform: uppercase;
|
|
632
|
-
padding: 2px 4px; border-radius: 3px; outline: none;
|
|
633
|
-
}
|
|
634
|
-
.nbe-kicker-input:focus { border-color: var(--color-border); background: var(--color-bg); color: var(--color-text1); }
|
|
635
|
-
.nb-root.nb-view-mode .nbe-kicker-input { pointer-events: none; }
|
|
636
619
|
.nbe-title {
|
|
637
620
|
font-family: var(--font-serif, 'EB Garamond', Georgia, serif);
|
|
638
621
|
font-size: 30px; font-weight: 500;
|
|
@@ -650,9 +633,13 @@ function injectLayoutStyles(): void {
|
|
|
650
633
|
.nbe-handle { position: absolute; left: 0; top: 6px; }
|
|
651
634
|
.nbe-del-abs {
|
|
652
635
|
position: absolute; top: 4px; right: 4px;
|
|
653
|
-
opacity: 0; transition: opacity 0.15s;
|
|
636
|
+
opacity: 0.65; transition: opacity 0.15s;
|
|
637
|
+
width: 26px; height: 26px;
|
|
638
|
+
font-size: 16px; line-height: 1;
|
|
639
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
640
|
+
padding: 0;
|
|
641
|
+
z-index: 2;
|
|
654
642
|
}
|
|
655
|
-
.nbe-cell:hover .nbe-del-abs { opacity: 0.5; }
|
|
656
643
|
.nbe-del-abs:hover { opacity: 1 !important; }
|
|
657
644
|
|
|
658
645
|
.nbe-prose {
|
|
@@ -711,7 +698,7 @@ function injectLayoutStyles(): void {
|
|
|
711
698
|
}
|
|
712
699
|
.nbe-cell-head {
|
|
713
700
|
display: flex; align-items: center; gap: 8px;
|
|
714
|
-
padding: 7px 12px;
|
|
701
|
+
padding: 7px 44px 7px 12px;
|
|
715
702
|
border-bottom: 1px solid var(--color-border);
|
|
716
703
|
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
717
704
|
font-size: 10px; color: var(--color-text2);
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
2
|
// ---------------------------------------------------------------------------
|
|
3
3
|
// Lightweight markdown renderer + allowlist sanitizer for notebook prose cells.
|
|
4
|
-
// Also: renderMarkdownWithInjectButtons — used by recipe viewer modal.
|
|
5
4
|
// No external dependencies.
|
|
6
5
|
// ---------------------------------------------------------------------------
|
|
7
6
|
|
|
@@ -546,80 +545,3 @@ function updateEmptyState(host: HTMLElement): void {
|
|
|
546
545
|
}
|
|
547
546
|
}
|
|
548
547
|
|
|
549
|
-
// ---------------------------------------------------------------------------
|
|
550
|
-
// Renderer with inject buttons — used by recipe viewer modal.
|
|
551
|
-
// Each fenced code block gets an "↳ inject" button next to it.
|
|
552
|
-
// ---------------------------------------------------------------------------
|
|
553
|
-
|
|
554
|
-
export interface InjectFenceEvent {
|
|
555
|
-
lang: string;
|
|
556
|
-
content: string;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
/**
|
|
560
|
-
* Render markdown into a container. Each fenced code block is rendered with
|
|
561
|
-
* an "↳ inject" button; clicking it calls onInject({lang, content}).
|
|
562
|
-
* Returns a cleanup function.
|
|
563
|
-
*/
|
|
564
|
-
export function renderMarkdownWithInjectButtons(
|
|
565
|
-
body: string,
|
|
566
|
-
onInject: (e: InjectFenceEvent) => void,
|
|
567
|
-
): { root: HTMLElement; destroy: () => void } {
|
|
568
|
-
const root = document.createElement('div');
|
|
569
|
-
root.className = 'nb-md-render';
|
|
570
|
-
|
|
571
|
-
const lines = (body || '').replace(/\r\n/g, '\n').split('\n');
|
|
572
|
-
const buf: string[] = [];
|
|
573
|
-
let i = 0;
|
|
574
|
-
const cleanups: Array<() => void> = [];
|
|
575
|
-
|
|
576
|
-
const flushProse = () => {
|
|
577
|
-
if (buf.length) {
|
|
578
|
-
const chunk = buf.join('\n');
|
|
579
|
-
const p = document.createElement('div');
|
|
580
|
-
p.innerHTML = renderProse(chunk);
|
|
581
|
-
root.appendChild(p);
|
|
582
|
-
buf.length = 0;
|
|
583
|
-
}
|
|
584
|
-
};
|
|
585
|
-
|
|
586
|
-
while (i < lines.length) {
|
|
587
|
-
const line = lines[i];
|
|
588
|
-
if (/^```/.test(line)) {
|
|
589
|
-
flushProse();
|
|
590
|
-
const lang = line.replace(/^```/, '').trim().toLowerCase() || 'text';
|
|
591
|
-
const code: string[] = [];
|
|
592
|
-
i++;
|
|
593
|
-
while (i < lines.length && !/^```/.test(lines[i])) {
|
|
594
|
-
code.push(lines[i]);
|
|
595
|
-
i++;
|
|
596
|
-
}
|
|
597
|
-
i++; // closing fence
|
|
598
|
-
const content = code.join('\n');
|
|
599
|
-
|
|
600
|
-
const block = document.createElement('div');
|
|
601
|
-
block.className = 'nb-md-fence';
|
|
602
|
-
block.innerHTML = `
|
|
603
|
-
<div class="nb-md-fence-head">
|
|
604
|
-
<span class="nb-md-fence-lang">${escapeHtml(lang)}</span>
|
|
605
|
-
<button type="button" class="nb-md-fence-inject">↳ inject</button>
|
|
606
|
-
</div>
|
|
607
|
-
<pre class="hljs-pre"><code class="hljs language-${escapeHtml(lang)}">${highlightCode(content, lang)}</code></pre>
|
|
608
|
-
`;
|
|
609
|
-
const btn = block.querySelector('.nb-md-fence-inject') as HTMLButtonElement;
|
|
610
|
-
const handler = () => onInject({ lang, content });
|
|
611
|
-
btn.addEventListener('click', handler);
|
|
612
|
-
cleanups.push(() => btn.removeEventListener('click', handler));
|
|
613
|
-
root.appendChild(block);
|
|
614
|
-
continue;
|
|
615
|
-
}
|
|
616
|
-
buf.push(line);
|
|
617
|
-
i++;
|
|
618
|
-
}
|
|
619
|
-
flushProse();
|
|
620
|
-
|
|
621
|
-
return {
|
|
622
|
-
root,
|
|
623
|
-
destroy: () => cleanups.forEach((f) => f()),
|
|
624
|
-
};
|
|
625
|
-
}
|
|
@@ -11,9 +11,6 @@ schema:
|
|
|
11
11
|
mode:
|
|
12
12
|
type: string
|
|
13
13
|
enum: [edit, view]
|
|
14
|
-
kicker:
|
|
15
|
-
type: string
|
|
16
|
-
description: Small uppercase label above the title (e.g. "analysis", "memo", "brief"). Editable inline. Defaults to "untitled".
|
|
17
14
|
hideLiveToggle:
|
|
18
15
|
type: boolean
|
|
19
16
|
default: false
|
|
@@ -52,7 +49,6 @@ The distinguishing feature: prose paragraphs and code cells share a single order
|
|
|
52
49
|
```
|
|
53
50
|
widget_display({name: "notebook", params: {
|
|
54
51
|
title: "Q3 observations",
|
|
55
|
-
kicker: "memo",
|
|
56
52
|
cells: [
|
|
57
53
|
{type: "md", content: "This memo covers the highlights of last quarter."},
|
|
58
54
|
{type: "md", content: "We first look at revenue, then at churn."},
|
|
@@ -74,7 +70,6 @@ The distinguishing feature: prose paragraphs and code cells share a single order
|
|
|
74
70
|
## Notes
|
|
75
71
|
|
|
76
72
|
- The serif font (EB Garamond, with Georgia fallback) applies only to prose content inside this widget — it signals "publication" the moment the user sees it.
|
|
77
|
-
- The **kicker** above the title ("analysis", "memo", "internal") is editable inline — click to rename. Keep it short.
|
|
78
73
|
- Prose cells are rendered via an HTML-sanitizing markdown pipeline: markdown syntax is resolved, unsafe tags are stripped (XSS closed), `<mark>` and other editorial tags are preserved.
|
|
79
74
|
- The footer exposes a single `share` button.
|
|
80
75
|
- Run / Stop controls are at the left of each code cell's header, same as the other notebook layouts.
|
|
@@ -112,7 +107,6 @@ An editorial piece earns its weight when the prose is anchored to real material.
|
|
|
112
107
|
name: 'notebook',
|
|
113
108
|
params: {
|
|
114
109
|
title: '...',
|
|
115
|
-
kicker: 'memo',
|
|
116
110
|
cells: [...],
|
|
117
111
|
servers: [{ name: 'tricoteuses', url: 'https://...', kind: 'data' }]
|
|
118
112
|
}
|
|
@@ -26,6 +26,23 @@ const LANG_TO_TYPE: Record<string, CellType> = {
|
|
|
26
26
|
* whenever such a fence is mapped so the user gets a hint. */
|
|
27
27
|
const TS_LIKE_LANGS = new Set(['ts', 'typescript']);
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Peel a leading `-- @meta {...}` (SQL) or `// @meta {...}` (JS) line from the
|
|
31
|
+
* cell content and return the parsed JSON args plus the remaining content.
|
|
32
|
+
* Round-trip with serializeCellMeta in share-handlers.ts.
|
|
33
|
+
*/
|
|
34
|
+
export function peelMetaComment(content: string): { content: string; args?: Record<string, unknown> } {
|
|
35
|
+
const m = /^[ \t]*(?:--|\/\/)[ \t]*@meta[ \t]+(\{[\s\S]*?\})[ \t]*\r?\n?/.exec(content);
|
|
36
|
+
if (!m) return { content };
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(m[1]);
|
|
39
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
40
|
+
return { content: content.slice(m[0].length), args: parsed as Record<string, unknown> };
|
|
41
|
+
}
|
|
42
|
+
} catch { /* invalid JSON → ignore, keep line in content */ }
|
|
43
|
+
return { content };
|
|
44
|
+
}
|
|
45
|
+
|
|
29
46
|
export function fenceLangToCellType(lang: string): CellType | null {
|
|
30
47
|
const key = (lang || '').toLowerCase().trim();
|
|
31
48
|
if (TS_LIKE_LANGS.has(key)) {
|
|
@@ -45,7 +62,10 @@ export function fenceLangToCellType(lang: string): CellType | null {
|
|
|
45
62
|
export function extractCellFromFence(lang: string, content: string): NotebookCell {
|
|
46
63
|
const cellType = fenceLangToCellType(lang);
|
|
47
64
|
if (cellType) {
|
|
48
|
-
|
|
65
|
+
const { content: stripped, args } = peelMetaComment(content.trim());
|
|
66
|
+
const cell: NotebookCell = { id: uid(), type: cellType, content: stripped, hideSource: false, hideResult: false };
|
|
67
|
+
if (args) cell.args = args;
|
|
68
|
+
return cell;
|
|
49
69
|
}
|
|
50
70
|
// Detect pseudo-code MCP tool calls like: query_sql({sql: "..."})
|
|
51
71
|
// Only attempted when the fence language is unknown/text (cellType === null).
|
|
@@ -36,7 +36,11 @@ function serializeToMarkdown(state: NotebookState): string {
|
|
|
36
36
|
} else {
|
|
37
37
|
const lang = cell.type === 'sql' ? 'sql' : 'js';
|
|
38
38
|
const varname = cell.varname ? ` // → ${cell.varname}` : '';
|
|
39
|
-
|
|
39
|
+
const commentPrefix = cell.type === 'sql' ? '--' : '//';
|
|
40
|
+
const metaLine = cell.args && Object.keys(cell.args).length > 0
|
|
41
|
+
? `${commentPrefix} @meta ${JSON.stringify(cell.args)}\n`
|
|
42
|
+
: '';
|
|
43
|
+
parts.push('```' + lang + varname, metaLine + cell.content.trim(), '```', '');
|
|
40
44
|
}
|
|
41
45
|
}
|
|
42
46
|
return parts.join('\n').trim() + '\n';
|
|
@@ -191,7 +195,6 @@ function minify(state: NotebookState): Record<string, unknown> {
|
|
|
191
195
|
id: state.id,
|
|
192
196
|
title: state.title,
|
|
193
197
|
mode: state.mode,
|
|
194
|
-
kicker: state.kicker,
|
|
195
198
|
cells: state.cells.map((c: NotebookCell) => ({
|
|
196
199
|
id: c.id,
|
|
197
200
|
type: c.type,
|
|
@@ -51,6 +51,7 @@ export interface NotebookCell {
|
|
|
51
51
|
status?: 'fresh' | 'stale';
|
|
52
52
|
comment?: { who: string; when: string; body: string } | null;
|
|
53
53
|
lastResult?: CellResult;
|
|
54
|
+
args?: Record<string, unknown>; // override of tool args, parsed from `@meta {...}` leading comment
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
export interface NotebookState {
|
|
@@ -62,7 +63,6 @@ export interface NotebookState {
|
|
|
62
63
|
scope: Record<string, unknown>;
|
|
63
64
|
executors: CellExecutors;
|
|
64
65
|
lastEditAt: number;
|
|
65
|
-
kicker?: string;
|
|
66
66
|
publishedSlug?: string;
|
|
67
67
|
publishedToken?: string;
|
|
68
68
|
/**
|
|
@@ -168,24 +168,46 @@ export function defaultCellContent(type: CellType): string {
|
|
|
168
168
|
// ---------------------------------------------------------------------------
|
|
169
169
|
|
|
170
170
|
export function createState(initial?: Partial<NotebookState>): NotebookState {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
cells: initial?.cells ?? [
|
|
176
|
-
{ id: uid(), type: 'md', content: '### Untitled notebook\n\nAdd some context here.', hideSource: false, hideResult: false },
|
|
171
|
+
const title = initial?.title ?? 'Untitled notebook';
|
|
172
|
+
const cells = stripDuplicateTitleHeading(
|
|
173
|
+
initial?.cells ?? [
|
|
174
|
+
{ id: uid(), type: 'md', content: 'Add some context here.', hideSource: false, hideResult: false },
|
|
177
175
|
{ id: uid(), type: 'sql', content: 'select *\nfrom source\nlimit 5', varname: 'rows', hideSource: false, hideResult: false, status: 'fresh' },
|
|
178
176
|
{ id: uid(), type: 'js', content: 'console.log(rows)', hideSource: false, hideResult: false, status: 'stale' },
|
|
179
177
|
],
|
|
178
|
+
title,
|
|
179
|
+
);
|
|
180
|
+
return {
|
|
181
|
+
id: initial?.id ?? uid(),
|
|
182
|
+
title,
|
|
183
|
+
mode: initial?.mode ?? 'edit',
|
|
184
|
+
cells,
|
|
180
185
|
history: initial?.history ?? [],
|
|
181
186
|
scope: initial?.scope ?? {},
|
|
182
187
|
executors: initial?.executors ?? {},
|
|
183
188
|
lastEditAt: initial?.lastEditAt ?? Date.now(),
|
|
184
|
-
kicker: initial?.kicker,
|
|
185
189
|
autoRun: initial?.autoRun ?? false,
|
|
186
190
|
};
|
|
187
191
|
}
|
|
188
192
|
|
|
193
|
+
// If the first md cell opens with a heading whose plain text matches the
|
|
194
|
+
// notebook title, drop that heading line so the title is not rendered twice.
|
|
195
|
+
function stripDuplicateTitleHeading(cells: NotebookCell[], title: string): NotebookCell[] {
|
|
196
|
+
if (!title || cells.length === 0) return cells;
|
|
197
|
+
const first = cells[0];
|
|
198
|
+
if (first.type !== 'md' || typeof first.content !== 'string') return cells;
|
|
199
|
+
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
200
|
+
const lines = first.content.split('\n');
|
|
201
|
+
let i = 0;
|
|
202
|
+
while (i < lines.length && lines[i].trim() === '') i++;
|
|
203
|
+
if (i >= lines.length) return cells;
|
|
204
|
+
const m = lines[i].match(/^#{1,6}\s+(.+?)\s*$/);
|
|
205
|
+
if (!m || norm(m[1]) !== norm(title)) return cells;
|
|
206
|
+
let drop = i + 1;
|
|
207
|
+
while (drop < lines.length && lines[drop].trim() === '') drop++;
|
|
208
|
+
return [{ ...first, content: lines.slice(drop).join('\n') }, ...cells.slice(1)];
|
|
209
|
+
}
|
|
210
|
+
|
|
189
211
|
// ---------------------------------------------------------------------------
|
|
190
212
|
// Live mode (autoRun) — RuntimeOverlay + helpers
|
|
191
213
|
//
|
|
@@ -641,6 +663,7 @@ export function tickRunningCell(cell: NotebookCell, elapsedEl: HTMLElement, onDo
|
|
|
641
663
|
export interface DataServerTool {
|
|
642
664
|
name: string;
|
|
643
665
|
description?: string;
|
|
666
|
+
inputSchema?: unknown;
|
|
644
667
|
}
|
|
645
668
|
|
|
646
669
|
export interface DataServerRecipe {
|
|
@@ -715,7 +738,7 @@ export interface PublishControlsOptions {
|
|
|
715
738
|
onPublished?: (info: { slug: string; url: string; updated: boolean }) => void;
|
|
716
739
|
/** Optional toast function — falls back to internal toast helper if absent. */
|
|
717
740
|
toast?: (message: string, isError?: boolean) => void;
|
|
718
|
-
/** Minimal projection of the state sent to the server. If absent, sends { id, title,
|
|
741
|
+
/** Minimal projection of the state sent to the server. If absent, sends { id, title, mode, cells }. */
|
|
719
742
|
serializeState?: (state: NotebookState) => Record<string, unknown>;
|
|
720
743
|
}
|
|
721
744
|
|
|
@@ -819,7 +842,6 @@ export function createPublishControls(state: NotebookState, opts: PublishControl
|
|
|
819
842
|
: {
|
|
820
843
|
id: state.id,
|
|
821
844
|
title: state.title,
|
|
822
|
-
kicker: state.kicker,
|
|
823
845
|
mode: state.mode,
|
|
824
846
|
cells: state.cells,
|
|
825
847
|
};
|