@webmcp-auto-ui/ui 2.5.35 → 2.5.37
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/agent/MCPserversList.svelte +44 -32
- package/src/agent/RecipeBrowser.svelte +54 -18
- package/src/agent/RemoteMCPserversDemo.svelte +7 -7
- package/src/agent/ToolBrowser.svelte +34 -5
- package/src/agent/WebMCPserversList.svelte +85 -40
- package/src/index.ts +5 -0
- package/src/primitives/MarkdownView.svelte +12 -2
- package/src/recipe/RecipeCodeBlock.svelte +331 -0
- package/src/recipe/RecipeRunModal.svelte +245 -0
- package/src/recipe/types.ts +10 -0
- package/src/widgets/WidgetRenderer.svelte +2 -0
- package/src/widgets/notebook/executors/sql.ts +21 -7
- package/src/widgets/notebook/import-modal-api.ts +15 -43
- package/src/widgets/notebook/import-modal.svelte +36 -66
- package/src/widgets/notebook/left-pane.ts +30 -12
- package/src/widgets/notebook/notebook.svelte +0 -2
- package/src/widgets/notebook/notebook.ts +12 -56
- 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 +76 -3
- package/src/widgets/notebook/shared.ts +113 -79
|
@@ -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';
|
|
@@ -56,19 +55,15 @@ type ModalEl = HTMLElement & {
|
|
|
56
55
|
let _modal: ModalEl | null = null;
|
|
57
56
|
let _cleanup: (() => void) | null = null;
|
|
58
57
|
|
|
59
|
-
function ensureModal(): ModalEl {
|
|
58
|
+
async function ensureModal(): Promise<ModalEl> {
|
|
60
59
|
if (_modal && document.contains(_modal)) return _modal;
|
|
61
60
|
|
|
62
|
-
// Register the CE if not already done (Svelte registers it on first import
|
|
63
|
-
// but we ensure it here for safety).
|
|
64
|
-
if (!customElements.get('auto-import-modal')) {
|
|
65
|
-
// Dynamic import triggers CE registration via Svelte's customElement decorator.
|
|
66
|
-
// Since this module is already bundled with the CE, it's already registered.
|
|
67
|
-
// If somehow not registered, fall back gracefully.
|
|
68
|
-
}
|
|
69
|
-
|
|
70
61
|
const el = document.createElement('auto-import-modal') as ModalEl;
|
|
71
62
|
document.body.appendChild(el);
|
|
63
|
+
// Svelte 5's connectedCallback is async (awaits a microtask before creating
|
|
64
|
+
// $$c), and exported methods are exposed via getters that read $$c. Yield
|
|
65
|
+
// one microtask so el.openModal/closeModal are defined when we call them.
|
|
66
|
+
await Promise.resolve();
|
|
72
67
|
_modal = el;
|
|
73
68
|
return el;
|
|
74
69
|
}
|
|
@@ -85,8 +80,8 @@ export function closeImportModal(): void {
|
|
|
85
80
|
// openAddMdModal
|
|
86
81
|
// ---------------------------------------------------------------------------
|
|
87
82
|
|
|
88
|
-
export function openAddMdModal(onPick: (content: string) => void): void {
|
|
89
|
-
const el = ensureModal();
|
|
83
|
+
export async function openAddMdModal(onPick: (content: string) => void): Promise<void> {
|
|
84
|
+
const el = await ensureModal();
|
|
90
85
|
|
|
91
86
|
// Clean up previous listener
|
|
92
87
|
_cleanup?.();
|
|
@@ -112,8 +107,8 @@ export function openAddMdModal(onPick: (content: string) => void): void {
|
|
|
112
107
|
// openAddRecipeModal
|
|
113
108
|
// ---------------------------------------------------------------------------
|
|
114
109
|
|
|
115
|
-
export function openAddRecipeModal(opts: AddRecipeModalOptions): void {
|
|
116
|
-
const el = ensureModal();
|
|
110
|
+
export async function openAddRecipeModal(opts: AddRecipeModalOptions): Promise<void> {
|
|
111
|
+
const el = await ensureModal();
|
|
117
112
|
|
|
118
113
|
_cleanup?.();
|
|
119
114
|
|
|
@@ -142,17 +137,14 @@ export function openAddRecipeModal(opts: AddRecipeModalOptions): void {
|
|
|
142
137
|
// openRecipeViewerModal
|
|
143
138
|
// ---------------------------------------------------------------------------
|
|
144
139
|
|
|
145
|
-
export function openRecipeViewerModal(
|
|
140
|
+
export async function openRecipeViewerModal(
|
|
146
141
|
recipe: ImportedRecipe,
|
|
147
142
|
onInjectCell: (cell: NotebookCell) => void,
|
|
148
|
-
): void {
|
|
149
|
-
const el = ensureModal();
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
const el = await ensureModal();
|
|
150
145
|
|
|
151
146
|
_cleanup?.();
|
|
152
147
|
|
|
153
|
-
// We'll hold a reference to the prose renderer's destroy fn.
|
|
154
|
-
let proseDestroy: (() => void) | null = null;
|
|
155
|
-
|
|
156
148
|
const handler = (e: CustomEvent) => {
|
|
157
149
|
const { action, payload } = e.detail ?? {};
|
|
158
150
|
|
|
@@ -172,8 +164,6 @@ export function openRecipeViewerModal(
|
|
|
172
164
|
|
|
173
165
|
if (action === 'inject-all' || action === 'close') {
|
|
174
166
|
el.removeEventListener('widget:interact', handler as EventListener);
|
|
175
|
-
proseDestroy?.();
|
|
176
|
-
proseDestroy = null;
|
|
177
167
|
_cleanup = null;
|
|
178
168
|
}
|
|
179
169
|
};
|
|
@@ -181,38 +171,20 @@ export function openRecipeViewerModal(
|
|
|
181
171
|
el.addEventListener('widget:interact', handler as EventListener);
|
|
182
172
|
_cleanup = () => {
|
|
183
173
|
el.removeEventListener('widget:interact', handler as EventListener);
|
|
184
|
-
proseDestroy?.();
|
|
185
|
-
proseDestroy = null;
|
|
186
174
|
};
|
|
187
175
|
|
|
188
176
|
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
177
|
}
|
|
206
178
|
|
|
207
179
|
// ---------------------------------------------------------------------------
|
|
208
180
|
// openToolViewerModal
|
|
209
181
|
// ---------------------------------------------------------------------------
|
|
210
182
|
|
|
211
|
-
export function openToolViewerModal(
|
|
183
|
+
export async function openToolViewerModal(
|
|
212
184
|
tool: McpToolLike,
|
|
213
185
|
onInjectCells: (cells: NotebookCell[]) => void,
|
|
214
|
-
): void {
|
|
215
|
-
const el = ensureModal();
|
|
186
|
+
): Promise<void> {
|
|
187
|
+
const el = await ensureModal();
|
|
216
188
|
|
|
217
189
|
_cleanup?.();
|
|
218
190
|
|
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
19
|
|
|
20
20
|
import { filterRecipes, sortRecipes, WEBMCP_RECIPES } from '@webmcp-auto-ui/agent';
|
|
21
|
-
import {
|
|
21
|
+
import { canvas } from '@webmcp-auto-ui/sdk/canvas';
|
|
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
|
|
@@ -150,7 +157,7 @@
|
|
|
150
157
|
if (d.mcpServers?.length) {
|
|
151
158
|
const fetches = d.mcpServers.map(async (srv) => {
|
|
152
159
|
try {
|
|
153
|
-
const res: any = await
|
|
160
|
+
const res: any = await canvas.callTool(srv.name, 'list_recipes', {});
|
|
154
161
|
const items = extractRecipeItems(res, srv);
|
|
155
162
|
if (items.length) recipes = [...recipes, ...items];
|
|
156
163
|
} catch { /* ignore */ }
|
|
@@ -232,8 +239,9 @@
|
|
|
232
239
|
// Fetch body on demand if missing
|
|
233
240
|
if (!r.body && r.serverName && r.serverName !== 'webmcp') {
|
|
234
241
|
try {
|
|
235
|
-
const res: any = await
|
|
236
|
-
|
|
242
|
+
const res: any = await canvas.callTool(
|
|
243
|
+
r.serverName,
|
|
244
|
+
'get_recipe',
|
|
237
245
|
{ name: r.originalName ?? r.name, id: r.id ?? r.name },
|
|
238
246
|
);
|
|
239
247
|
r = { ...r, body: extractRecipeBody(res) ?? '' };
|
|
@@ -255,25 +263,13 @@
|
|
|
255
263
|
// Handlers — recipe-viewer
|
|
256
264
|
// ---------------------------------------------------------------------------
|
|
257
265
|
|
|
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
266
|
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.
|
|
267
|
+
// Notebook owns extractCellsFromRecipe to avoid a ui<->notebook cycle.
|
|
267
268
|
emitInteract('inject-all', { recipe: recipe });
|
|
268
269
|
closeModal();
|
|
269
270
|
}
|
|
270
271
|
|
|
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 ?? '';
|
|
272
|
+
function handleInjectFence(content: string, lang: string) {
|
|
277
273
|
emitInteract('inject-fence', { lang, content });
|
|
278
274
|
}
|
|
279
275
|
|
|
@@ -426,12 +422,30 @@
|
|
|
426
422
|
{#if recipe?.description}<p>{recipe.description}</p>{/if}
|
|
427
423
|
{#if recipe?.serverName}<span class="nb-imp-recipe-srv">{recipe.serverName}</span>{/if}
|
|
428
424
|
</div>
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
425
|
+
<section class="nb-imp-body nb-imp-body-recipe">
|
|
426
|
+
{#each recipeSegments as seg, i (i)}
|
|
427
|
+
{#if seg.type === 'markdown'}
|
|
428
|
+
<MarkdownView source={seg.content} />
|
|
429
|
+
{:else}
|
|
430
|
+
<RecipeCodeBlock
|
|
431
|
+
code={seg.content}
|
|
432
|
+
lang={seg.lang ?? 'text'}
|
|
433
|
+
actions={[{
|
|
434
|
+
icon: '+',
|
|
435
|
+
label: 'Inject as cell',
|
|
436
|
+
onclick: handleInjectFence,
|
|
437
|
+
}]}
|
|
438
|
+
/>
|
|
439
|
+
{/if}
|
|
440
|
+
{/each}
|
|
432
441
|
</section>
|
|
433
442
|
<footer class="nb-imp-foot">
|
|
434
|
-
<button
|
|
443
|
+
<button
|
|
444
|
+
type="button"
|
|
445
|
+
class="nb-imp-btn nb-imp-primary"
|
|
446
|
+
onclick={handleInjectAll}
|
|
447
|
+
disabled={recipeCellCount === 0}
|
|
448
|
+
>Inject all cells{recipeCellCount > 0 ? ` (${recipeCellCount})` : ''}</button>
|
|
435
449
|
</footer>
|
|
436
450
|
|
|
437
451
|
<!-- ================================================================ -->
|
|
@@ -691,48 +705,4 @@
|
|
|
691
705
|
font-size: 11.5px;
|
|
692
706
|
}
|
|
693
707
|
|
|
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
708
|
</style>
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// Collapsed by default.
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { canvas } from '@webmcp-auto-ui/sdk/canvas';
|
|
10
10
|
import { openRecipeViewerModal, openToolViewerModal, type ImportedRecipe } from './import-modal-api.js';
|
|
11
11
|
import type { NotebookCell, NotebookState, DataServerDescriptor } from './shared.js';
|
|
12
12
|
|
|
@@ -73,8 +73,7 @@ export function mountLeftPane(
|
|
|
73
73
|
section.innerHTML = `
|
|
74
74
|
<header class="nb-lp-srv-head">
|
|
75
75
|
<span class="nb-lp-srv-dot"></span>
|
|
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>
|
|
76
|
+
<span class="nb-lp-srv-name">${escapeHtml(srv.serverName ?? srv.label ?? srv.name)}</span>
|
|
78
77
|
</header>
|
|
79
78
|
<div class="nb-lp-srv-groups">
|
|
80
79
|
${srv.recipes?.length ? `
|
|
@@ -135,25 +134,45 @@ export function mountLeftPane(
|
|
|
135
134
|
}
|
|
136
135
|
if (!imported.body) {
|
|
137
136
|
try {
|
|
138
|
-
const res: any = await
|
|
137
|
+
const res: any = await canvas.callTool(srv.name, 'get_recipe', { name: r.name, id: r.name });
|
|
139
138
|
const text = res?.content?.find?.((c: any) => c.type === 'text')?.text;
|
|
140
139
|
if (text) {
|
|
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;
|
|
@@ -21,10 +21,8 @@
|
|
|
21
21
|
export interface NotebookData {
|
|
22
22
|
id?: string;
|
|
23
23
|
title?: string;
|
|
24
|
-
kicker?: string;
|
|
25
24
|
mode?: 'edit' | 'view';
|
|
26
25
|
autoRun?: boolean;
|
|
27
|
-
hideLiveToggle?: boolean;
|
|
28
26
|
cells?: NotebookCell[];
|
|
29
27
|
/** MCP servers for SQL execution (array of {name, url}) */
|
|
30
28
|
servers?: Array<{ name: string; url?: string }>;
|
|
@@ -35,10 +35,10 @@ 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,
|
|
39
|
+
publishedSlug: (data as any).publishedSlug,
|
|
40
|
+
publishedToken: (data as any).publishedToken,
|
|
40
41
|
});
|
|
41
|
-
if (!state.kicker) state.kicker = (data.kicker as string) ?? 'untitled';
|
|
42
42
|
|
|
43
43
|
// Live mode runtime overlay (created lazily). Never mutates state.
|
|
44
44
|
let overlay: RuntimeOverlay | null = null;
|
|
@@ -56,8 +56,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
56
56
|
<div class="nbe-leftpane-slot"></div>
|
|
57
57
|
<div class="nbe-shell">
|
|
58
58
|
<div class="nbe-kicker">
|
|
59
|
-
<input class="nbe-kicker-input" value="${escapeAttr(state.kicker || '')}" placeholder="kicker…">
|
|
60
|
-
<span class="nbe-live-toggle-slot"></span>
|
|
61
59
|
<div class="nb-mode-switch" style="margin-left:auto;">
|
|
62
60
|
<button class="nb-mode-edit nb-on">edit</button>
|
|
63
61
|
<button class="nb-mode-view">view</button>
|
|
@@ -107,24 +105,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
107
105
|
});
|
|
108
106
|
}
|
|
109
107
|
|
|
110
|
-
const hideLiveToggle = (data as any).hideLiveToggle === true;
|
|
111
|
-
|
|
112
|
-
function renderLiveToggle() {
|
|
113
|
-
const slot = shell.querySelector('.nbe-live-toggle-slot') as HTMLElement;
|
|
114
|
-
if (hideLiveToggle) { slot.innerHTML = ''; return; }
|
|
115
|
-
if (state.mode === 'edit') {
|
|
116
|
-
const checked = state.autoRun === true ? 'checked' : '';
|
|
117
|
-
slot.innerHTML = `<label class="nbe-live-toggle" title="Re-execute SQL cells against connected servers when this notebook is opened in view mode."><input type="checkbox" ${checked} />Live data</label>`;
|
|
118
|
-
const cb = slot.querySelector('input[type=checkbox]') as HTMLInputElement;
|
|
119
|
-
cb.addEventListener('change', () => {
|
|
120
|
-
state.autoRun = cb.checked;
|
|
121
|
-
rerender();
|
|
122
|
-
});
|
|
123
|
-
} else {
|
|
124
|
-
slot.innerHTML = '';
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
108
|
function renderLiveBadge() {
|
|
129
109
|
const slot = shell.querySelector('.nbe-live-badge-slot') as HTMLElement;
|
|
130
110
|
if (state.mode === 'view' && state.autoRun === true) {
|
|
@@ -197,7 +177,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
197
177
|
function rerender() {
|
|
198
178
|
const restore = preserveScrollAround(cellsEl);
|
|
199
179
|
mountHistoryPanel(historyPanel, state, (snap) => { restoreCellFromSnapshot(state, snap); rerender(); });
|
|
200
|
-
renderLiveToggle();
|
|
201
180
|
renderLiveBadge();
|
|
202
181
|
renderEmptyState();
|
|
203
182
|
renderCells();
|
|
@@ -224,9 +203,8 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
224
203
|
rerender();
|
|
225
204
|
});
|
|
226
205
|
} else if (which === 'recipe') {
|
|
227
|
-
const mcpServers = (
|
|
228
|
-
.map((s
|
|
229
|
-
.filter((s: any) => s.name);
|
|
206
|
+
const mcpServers = collectDataServers(data)
|
|
207
|
+
.map((s) => ({ name: s.name, url: s.url }));
|
|
230
208
|
openAddRecipeModal({
|
|
231
209
|
mcpServers,
|
|
232
210
|
scope: 'data',
|
|
@@ -259,10 +237,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
259
237
|
footerSlot: shell.querySelector('.nbe-publish-footer-slot') as HTMLElement,
|
|
260
238
|
onPublished: () => rerender(),
|
|
261
239
|
});
|
|
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
240
|
(shell.querySelector('.nbe-title') as HTMLInputElement).addEventListener('input', (e) => {
|
|
267
241
|
state.title = (e.target as HTMLInputElement).value;
|
|
268
242
|
state.lastEditAt = Date.now();
|
|
@@ -294,7 +268,7 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
294
268
|
});
|
|
295
269
|
|
|
296
270
|
// Auto-connect data servers declared in the recipe frontmatter (data.servers).
|
|
297
|
-
// The notebook reads MCP state passively from
|
|
271
|
+
// The notebook reads MCP state passively from canvas.dataServers.
|
|
298
272
|
autoConnectFrontmatterServers(data, () => pane.setServers(collectDataServers(data)));
|
|
299
273
|
|
|
300
274
|
// Keep pane servers in sync with canvas changes
|
|
@@ -623,16 +597,6 @@ function injectLayoutStyles(): void {
|
|
|
623
597
|
letter-spacing: 0.1em; text-transform: uppercase;
|
|
624
598
|
margin-bottom: 14px;
|
|
625
599
|
}
|
|
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
600
|
.nbe-title {
|
|
637
601
|
font-family: var(--font-serif, 'EB Garamond', Georgia, serif);
|
|
638
602
|
font-size: 30px; font-weight: 500;
|
|
@@ -650,9 +614,13 @@ function injectLayoutStyles(): void {
|
|
|
650
614
|
.nbe-handle { position: absolute; left: 0; top: 6px; }
|
|
651
615
|
.nbe-del-abs {
|
|
652
616
|
position: absolute; top: 4px; right: 4px;
|
|
653
|
-
opacity: 0; transition: opacity 0.15s;
|
|
617
|
+
opacity: 0.65; transition: opacity 0.15s;
|
|
618
|
+
width: 26px; height: 26px;
|
|
619
|
+
font-size: 16px; line-height: 1;
|
|
620
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
621
|
+
padding: 0;
|
|
622
|
+
z-index: 2;
|
|
654
623
|
}
|
|
655
|
-
.nbe-cell:hover .nbe-del-abs { opacity: 0.5; }
|
|
656
624
|
.nbe-del-abs:hover { opacity: 1 !important; }
|
|
657
625
|
|
|
658
626
|
.nbe-prose {
|
|
@@ -711,7 +679,7 @@ function injectLayoutStyles(): void {
|
|
|
711
679
|
}
|
|
712
680
|
.nbe-cell-head {
|
|
713
681
|
display: flex; align-items: center; gap: 8px;
|
|
714
|
-
padding: 7px 12px;
|
|
682
|
+
padding: 7px 44px 7px 12px;
|
|
715
683
|
border-bottom: 1px solid var(--color-border);
|
|
716
684
|
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
717
685
|
font-size: 10px; color: var(--color-text2);
|
|
@@ -814,18 +782,6 @@ function injectLayoutStyles(): void {
|
|
|
814
782
|
.nbe-toast.nbe-toast-in { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
815
783
|
.nbe-toast.nbe-toast-error { color: var(--color-accent2); border-color: var(--color-accent2); }
|
|
816
784
|
|
|
817
|
-
/* Live mode — discreet toggle in header (edit mode only) */
|
|
818
|
-
.nbe-live-toggle {
|
|
819
|
-
display: inline-flex; align-items: center; gap: 6px;
|
|
820
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
821
|
-
font-size: 10.5px; color: var(--color-text2);
|
|
822
|
-
letter-spacing: 0.06em; text-transform: uppercase;
|
|
823
|
-
cursor: pointer; user-select: none;
|
|
824
|
-
padding: 2px 7px; border: 1px solid var(--color-border); border-radius: 4px;
|
|
825
|
-
}
|
|
826
|
-
.nbe-live-toggle:hover { color: var(--color-text1); border-color: var(--color-border2); }
|
|
827
|
-
.nbe-live-toggle input { margin: 0; cursor: pointer; }
|
|
828
|
-
|
|
829
785
|
/* Title row + Live badge (view mode + autoRun) */
|
|
830
786
|
.nbe-title-row { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
|
831
787
|
.nbe-title-row .nbe-title { flex: 1; min-width: 0; }
|
|
@@ -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).
|