@kolbo/mcp 1.88.4 → 1.89.1

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.
@@ -6,6 +6,12 @@ const { widgetPage } = require('../html');
6
6
  * Media grid widget — media library, stock search, presets, voices, moodboards,
7
7
  * visual DNAs, music library.
8
8
  *
9
+ * Layout: a contact sheet, not a list. One PAGE of square tiles at a time with
10
+ * a pager underneath, so the card keeps a constant height no matter how many
11
+ * results there are. Forward past the last loaded page still goes through the
12
+ * server's own paging contract (page_tool + next_args) — the pages already
13
+ * fetched just stay in state so Back is instant.
14
+ *
9
15
  * structuredContent contract:
10
16
  * {
11
17
  * widget: 'media-grid',
@@ -17,6 +23,7 @@ const { widgetPage } = require('../html');
17
23
  * use_hint // unused — Use pastes `id` into the composer
18
24
  * }],
19
25
  * total, has_more,
26
+ * page_tool, next_args, // paging contract — the server names its own args
20
27
  * import_tool_hint // e.g. 'import via import_stock_asset' — shown on Use
21
28
  * }
22
29
  */
@@ -38,83 +45,125 @@ const SCRIPT = `
38
45
  el('logo').innerHTML = KOLBO_LOGO + '<span>Kolbo</span>';
39
46
  el('kolbo-link').onclick = function (e) { e.preventDefault(); window.kolbo.openLink('https://app.kolbo.ai'); };
40
47
  var state = null;
48
+ var page = 0;
49
+
50
+ function isAudio(item) { return item && item.media_type === 'audio'; }
51
+ // Audio comes down as full-width rows (a name + a player), so half a dozen of
52
+ // those already fill the card where a dozen square tiles do.
53
+ function pageSize() {
54
+ var items = (state && state.items) || [];
55
+ var audio = items.filter(isAudio).length;
56
+ return audio > items.length / 2 ? 6 : 12;
57
+ }
58
+ function pageCount() { return Math.max(1, Math.ceil(((state && state.items) || []).length / pageSize())); }
59
+ // Only a tool that told us HOW to page can go past what is already loaded.
60
+ // Every other grid used to render a Load more that did nothing.
61
+ function hasMore() {
62
+ if (!state) return false;
63
+ var shown = (state.items || []).length;
64
+ var flagged = typeof state.has_next === 'boolean' ? state.has_next : (state.total != null && state.total > shown);
65
+ return !!(flagged && state.page_tool && (state.next_args || state.query || state.page));
66
+ }
41
67
 
42
68
  function boot(sc) {
43
69
  if (!sc || !sc.items) return;
44
70
  state = sc;
71
+ page = 0;
45
72
  el('title').textContent = sc.title || 'Library';
46
- if (sc.total != null) { el('count-chip').style.display = ''; el('count-chip').textContent = sc.total + ' results'; }
47
- if (!sc.items.length) { el('stage').innerHTML = '<div class="k-empty">No results</div>'; return; }
48
- var audioItems = sc.items.filter(function (i) { return i.media_type === 'audio'; });
49
- var visualItems = sc.items.filter(function (i) { return i.media_type !== 'audio'; });
50
- var h = '';
51
- // Render everything currently in state — each PAGE is already capped
52
- // server-side (GRID_CAP), so this grows one page per Load more instead of
53
- // being pinned forever. The old hard slices (24 visual / 12 audio) meant an
54
- // appended page could never actually appear, and the count below tallied all
55
- // items while only 36 were drawn, so the button also lied about the total.
56
- if (visualItems.length) {
57
- h += '<div class="k-grid">' + visualItems.map(cellHTML).join('') + '</div>';
58
- }
59
- if (audioItems.length) {
60
- h += audioItems.map(audioRowHTML).join('');
61
- }
62
- var shown = visualItems.length + audioItems.length;
63
- // Only a tool that told us HOW to page (page_tool + next_args / query) gets a
64
- // button. Every other grid used to render a Load more that did nothing —
65
- // fetchNextPage returned early with no page_tool — which read as "broken".
66
- if (sc.total != null && sc.total > shown && sc.page_tool && (sc.next_args || sc.query || sc.page)) {
67
- h += '<button class="k-btn" id="load-more" style="width:100%;margin-top:10px">Load more (' +
68
- shown + ' of ' + sc.total + ' shown)</button>';
69
- } else if (sc.total != null && sc.total > shown) {
70
- h += '<div style="text-align:center;font-size:12px;color:var(--text-muted);margin-top:10px">' + shown + ' of ' + sc.total + ' shown</div>';
73
+ if (sc.total != null && sc.total >= 0) { el('count-chip').style.display = ''; el('count-chip').textContent = sc.total + ' results'; }
74
+ else { el('count-chip').style.display = 'none'; }
75
+ render();
76
+ }
77
+
78
+ function render() {
79
+ var items = (state && state.items) || [];
80
+ if (!items.length) {
81
+ el('stage').classList.add('k-empty');
82
+ el('stage').innerHTML = 'No results';
83
+ window.kolbo.notifySize();
84
+ return;
71
85
  }
86
+ var size = pageSize();
87
+ if (page > pageCount() - 1) page = pageCount() - 1;
88
+ var slice = items.slice(page * size, page * size + size);
89
+ var visual = slice.filter(function (i) { return !isAudio(i); });
90
+ var audio = slice.filter(isAudio);
91
+ var h = '';
92
+ if (visual.length) h += '<div class="k-grid">' + visual.map(tileHTML).join('') + '</div>';
93
+ if (audio.length) h += '<div' + (visual.length ? ' style="margin-top:8px"' : '') + '>' + audio.map(audioRowHTML).join('') + '</div>';
94
+ h += pagerHTML();
72
95
  el('stage').innerHTML = h;
73
96
  el('stage').classList.remove('k-empty');
74
97
  wire();
75
98
  window.kolbo.notifySize();
76
99
  }
77
100
 
78
- function cellHTML(item, i) {
101
+ function pagerHTML() {
102
+ var n = pageCount();
103
+ var more = hasMore();
104
+ if (n <= 1 && !more) return '';
105
+ var mid;
106
+ if (n <= 8) {
107
+ var dots = '';
108
+ for (var i = 0; i < n; i++) {
109
+ dots += '<button class="k-dot' + (i === page ? ' on' : '') + '" data-page="' + i + '" aria-label="Page ' + (i + 1) + '"></button>';
110
+ }
111
+ // A dimmed trailing dot says "there is more behind Next" without claiming
112
+ // to know how much — the server only ever promises the NEXT page.
113
+ if (more) dots += '<span class="k-dot ghost"></span>';
114
+ mid = '<div class="k-dots">' + dots + '</div>';
115
+ } else {
116
+ mid = '<span class="k-pager-label">' + (page + 1) + ' / ' + n + (more ? '+' : '') + '</span>';
117
+ }
118
+ return '<div class="k-pager">' +
119
+ '<button class="k-pager-btn" id="page-prev" aria-label="Previous"' + (page === 0 ? ' disabled' : '') + '>' + ICONS.chevronLeft + '</button>' +
120
+ mid +
121
+ '<button class="k-pager-btn" id="page-next" aria-label="Next"' + (page >= n - 1 && !more ? ' disabled' : '') + '>' + ICONS.chevronRight + '</button>' +
122
+ '</div>';
123
+ }
124
+
125
+ function tileHTML(item) {
79
126
  var idx = state.items.indexOf(item);
80
127
  var isVideo = item.media_type === 'video' && item.url;
81
128
  // onerror matters: a thumbnail that 404s / expires / can't be fetched by the
82
129
  // host webview left a BLACK tile with no glyph and no label hint — visually
83
130
  // identical to "corrupted media". Fall back to the same kind icon a
84
131
  // thumbnail-less item gets, so a dead URL degrades instead of looking broken.
85
- var fallbackCell = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-faint);font-size:22px">' +
86
- kindIcon(item.media_type) + '</div>';
132
+ // outerHTML, not parentNode.innerHTML: the parent is now the whole tile, and
133
+ // wiping it would take the caption and the Use/Download buttons with it.
134
+ var fallback = '<div class="k-tile-fb">' + kindIcon(item.media_type) + '</div>';
87
135
  var peekUrl = item.url || item.thumbnail;
88
136
  var media = item.thumbnail
89
137
  ? '<img class="k-peek-hit" src="' + esc(item.thumbnail) + '" loading="lazy" alt=""'
90
138
  + peekAttrs(peekUrl, item.media_type === 'video' ? 'video' : 'image', item.title)
91
- + ' onerror="this.parentNode.innerHTML=this.getAttribute(\\'data-fb\\')" data-fb="' + esc(fallbackCell) + '">'
92
- : fallbackCell;
93
- return '<div class="k-cell" data-i="' + idx + '">' +
94
- '<div class="k-cell-media">' + media +
95
- (isVideo ? '<button class="k-play k-cell-play" data-video-play="' + esc(item.url) + '">' + ICONS.play + '</button>' : '') +
96
- '</div>' +
97
- '<div class="k-cell-label">' + esc(item.title || '') + '</div>' +
98
- (item.subtitle ? '<div class="k-cell-sub">' + esc(item.subtitle) + '</div>' : '') +
99
- // Clicking the cell itself already sends the use_hint (see wire()), but that
100
- // wasn't discoverable — nothing on the tile signalled it was clickable. A
101
- // visible button makes the affordance obvious, same reasoning as the audio
102
- // rows' explicit "Use" button.
103
- '<button class="k-btn" data-use="' + idx + '" style="margin:0 8px 8px;width:calc(100% - 16px)">Use</button>' +
139
+ + ' onerror="this.outerHTML=this.getAttribute(\\'data-fb\\')" data-fb="' + esc(fallback) + '">'
140
+ : fallback;
141
+ var cap = (item.title || item.subtitle)
142
+ ? '<div class="k-tile-cap">' +
143
+ (item.title ? '<div class="k-tile-t">' + esc(item.title) + '</div>' : '') +
144
+ (item.subtitle ? '<div class="k-tile-s">' + esc(item.subtitle) + '</div>' : '') +
145
+ '</div>'
146
+ : '';
147
+ // Clicking the tile sends the id, but nothing on a bare thumbnail signals
148
+ // that — the explicit buttons keep the affordance visible (and on touch
149
+ // hosts CSS keeps them permanently on, since there is no hover there).
150
+ var acts = '<div class="k-tile-acts">' +
151
+ '<button class="k-tile-act" data-use="' + idx + '" title="Use" aria-label="Use">' + ICONS.arrowRight + '</button>' +
152
+ (item.url ? '<button class="k-tile-act" data-dl="' + esc(item.url) + '" title="Download" aria-label="Download">' + ICONS.download + '</button>' : '') +
104
153
  '</div>';
154
+ return '<div class="k-tile" data-i="' + idx + '" title="' + esc(item.title || '') + '">' + media +
155
+ (isVideo ? '<button class="k-play k-tile-play" data-video-play="' + esc(item.url) + '">' + ICONS.play + '</button>' : '') +
156
+ cap + acts + '</div>';
105
157
  }
106
158
 
107
159
  function audioRowHTML(item) {
108
160
  var idx = state.items.indexOf(item);
109
161
  var src = item.preview_audio || item.url;
110
- // Same onerror contract as cellHTML, and for the same reason: a thumbnail the
162
+ // Same onerror contract as tileHTML, and for the same reason: a thumbnail the
111
163
  // host cannot fetch (CSP, 404, expired) otherwise renders the browser's
112
- // broken-image glyph. Voices are the ONLY items that come down this path, so
113
- // this row is where a blocked thumbnail is actually visible — cells have
114
- // degraded gracefully since forever and hid the problem everywhere else.
115
- // this.outerHTML, NOT this.parentNode.innerHTML: here the <img> IS the art and
116
- // its parent is the whole row, so the cell version would erase the title,
117
- // subtitle, Use button and player along with it.
164
+ // broken-image glyph. this.outerHTML, NOT this.parentNode.innerHTML: here the
165
+ // <img> IS the art and its parent is the whole row, so wiping the parent would
166
+ // erase the title, subtitle, Use button and player along with it.
118
167
  var artFallback = '<div class="k-audio-art k-audio-art-fallback">' + kindIcon('audio') + '</div>';
119
168
  return '<div class="k-audio-row k-generated-audio" data-i="' + idx + '">' +
120
169
  (item.thumbnail
@@ -130,41 +179,59 @@ function audioRowHTML(item) {
130
179
  }
131
180
 
132
181
  function wire() {
133
- bindPeekHits(el('stage'));
134
- Array.prototype.forEach.call(document.querySelectorAll('.k-cell'), function (c) {
182
+ var stage = el('stage');
183
+ bindPeekHits(stage);
184
+ Array.prototype.forEach.call(stage.querySelectorAll('.k-tile, .k-audio-row'), function (c) {
135
185
  c.onclick = function (e) {
136
- if (e.target && e.target.closest && e.target.closest('[data-peek]')) return;
186
+ if (e.target && e.target.closest && e.target.closest('[data-peek],[data-use],[data-dl],[data-video-play]')) return;
137
187
  useItem(+c.getAttribute('data-i'));
138
188
  };
139
189
  });
140
- Array.prototype.forEach.call(document.querySelectorAll('[data-use]'), function (b) {
190
+ Array.prototype.forEach.call(stage.querySelectorAll('[data-use]'), function (b) {
141
191
  b.onclick = function (e) { e.stopPropagation(); useItem(+b.getAttribute('data-use')); };
142
192
  });
193
+ Array.prototype.forEach.call(stage.querySelectorAll('.k-tile-act[data-dl]'), function (b) {
194
+ b.onclick = function (e) { e.stopPropagation(); window.kolbo.openLink(downloadUrl(b.getAttribute('data-dl'))); };
195
+ });
196
+ Array.prototype.forEach.call(stage.querySelectorAll('[data-page]'), function (d) {
197
+ d.onclick = function () { var p = +d.getAttribute('data-page'); if (p !== page) { page = p; render(); } };
198
+ });
143
199
  // Native <audio controls> already gives play/pause + a seek bar + duration;
144
200
  // just stop clicks on it from bubbling up to the row's "Use" handler, and
145
201
  // pause any other preview when a new one starts.
146
- var players = document.querySelectorAll('.k-audio-player');
202
+ var players = stage.querySelectorAll('.k-audio-player');
147
203
  Array.prototype.forEach.call(players, function (player) {
148
204
  player.onclick = function (e) { e.stopPropagation(); };
149
205
  player.addEventListener('play', function () {
150
206
  Array.prototype.forEach.call(players, function (other) { if (other !== player) other.pause(); });
151
207
  });
152
208
  });
153
- // Video cells stay a static thumbnail until the play overlay is clicked,
154
- // then swap in a native <video controls> for an in-place scrubber.
155
- Array.prototype.forEach.call(document.querySelectorAll('[data-video-play]'), function (b) {
209
+ // Video tiles stay a static thumbnail until the play overlay is clicked, then
210
+ // swap the <img> for a native <video controls>. Replace only the image — the
211
+ // caption goes because the control bar lands in exactly that strip.
212
+ Array.prototype.forEach.call(stage.querySelectorAll('[data-video-play]'), function (b) {
156
213
  b.onclick = function (e) {
157
214
  e.stopPropagation();
158
- var url = b.getAttribute('data-video-play');
159
- var holder = b.parentNode;
160
- holder.innerHTML = '<video src="' + esc(url) + '" controls autoplay playsinline style="width:100%;height:100%;object-fit:contain;background:#000"></video>';
161
- holder.querySelector('video').onclick = function (ev) { ev.stopPropagation(); };
215
+ var tile = b.parentNode;
216
+ var v = document.createElement('video');
217
+ v.src = b.getAttribute('data-video-play');
218
+ v.controls = true; v.autoplay = true; v.playsInline = true;
219
+ v.setAttribute('style', 'width:100%;height:100%;object-fit:contain;background:#000');
220
+ v.onclick = function (ev) { ev.stopPropagation(); };
221
+ var img = tile.querySelector('img');
222
+ if (img) tile.replaceChild(v, img); else tile.insertBefore(v, tile.firstChild);
223
+ var cap = tile.querySelector('.k-tile-cap');
224
+ if (cap && cap.parentNode) cap.parentNode.removeChild(cap);
225
+ if (b.parentNode) b.parentNode.removeChild(b);
162
226
  };
163
227
  });
164
- var loadMore = el('load-more');
165
- if (loadMore) {
166
- loadMore.onclick = function () { fetchNextPage(loadMore); };
167
- }
228
+ var prev = el('page-prev');
229
+ if (prev) prev.onclick = function () { if (page > 0) { page--; render(); } };
230
+ var next = el('page-next');
231
+ if (next) next.onclick = function () {
232
+ if (page < pageCount() - 1) { page++; render(); return; }
233
+ fetchNextPage(next);
234
+ };
168
235
  }
169
236
 
170
237
  // Fetch the next page IN the widget and append it.
@@ -174,14 +241,12 @@ function wire() {
174
241
  // does — window.kolbo.callTool, the same bridge call every generation card
175
242
  // polls status with. And the message could not have worked anyway: the payload
176
243
  // carried no page number and none of the filters, so the model had nothing to
177
- // reconstruct the query from and would re-run page 1 or something else. The
178
- // button appeared to do nothing.
244
+ // reconstruct the query from and would re-run page 1 or something else.
179
245
  function fetchNextPage(btn) {
180
246
  if (!state || !state.page_tool || btn.disabled) return;
181
- var next = (state.page || 1) + 1;
247
+ var nextPage = (state.page || 1) + 1;
182
248
  btn.disabled = true;
183
- var label = btn.textContent;
184
- btn.innerHTML = '<span class="k-spin"></span> Loading';
249
+ btn.innerHTML = '<span class="k-spin"></span>';
185
250
  // The server knows its own paging arg names (page/limit, offset, cursor…):
186
251
  // when it shipped next_args, send exactly that. The query+page shape is the
187
252
  // legacy fallback list_media has always used.
@@ -191,7 +256,7 @@ function fetchNextPage(btn) {
191
256
  } else {
192
257
  var q = state.query || {};
193
258
  for (var k in q) { if (q[k] !== undefined && q[k] !== null && q[k] !== '') args[k] = q[k]; }
194
- args.page = next;
259
+ args.page = nextPage;
195
260
  if (state.page_size) args.page_size = state.page_size;
196
261
  }
197
262
 
@@ -199,26 +264,30 @@ function fetchNextPage(btn) {
199
264
  var sc = structured(res);
200
265
  if (res && res.isError) {
201
266
  btn.disabled = false;
202
- btn.textContent = 'Could not load more — try again';
267
+ btn.innerHTML = ICONS.chevronRight;
268
+ btn.title = 'Could not load more — try again';
203
269
  return;
204
270
  }
205
271
  var more = (sc && sc.items) || [];
206
272
  if (!more.length) {
207
- // Nothing came back: say so rather than restoring a button that still
208
- // looks like it has pages behind it.
209
- btn.textContent = 'No more results';
273
+ // Nothing came back: retire the control rather than leaving a Next that
274
+ // still looks like it has pages behind it.
275
+ state.has_next = false;
276
+ state.next_args = undefined;
277
+ render();
210
278
  return;
211
279
  }
212
280
  state.items = (state.items || []).concat(more);
213
- state.page = (sc && sc.page) || next;
281
+ state.page = (sc && sc.page) || nextPage;
214
282
  // The next page's args come from THIS response; none means this was the last page.
215
283
  state.next_args = sc && sc.next_args;
216
- if (sc && sc.next_args == null && sc.page_tool == null && state.next_args === undefined) state.next_args = undefined;
217
- if (sc && sc.total != null) state.total = sc.total;
218
- boot(state); // re-renders the grid + a fresh Load more button
284
+ if (sc && sc.total != null && sc.total >= 0) state.total = sc.total;
285
+ state.has_next = sc && typeof sc.has_next === 'boolean' ? sc.has_next : undefined;
286
+ page = page + 1;
287
+ render();
219
288
  }).catch(function () {
220
289
  btn.disabled = false;
221
- btn.textContent = label;
290
+ btn.innerHTML = ICONS.chevronRight;
222
291
  });
223
292
  }
224
293