@webmcp-auto-ui/ui 2.5.27 → 2.5.28

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.
Files changed (82) hide show
  1. package/package.json +15 -3
  2. package/src/agent/DataServersPanel.svelte +164 -0
  3. package/src/agent/LLMSelector.svelte +11 -3
  4. package/src/agent/ModelCacheManager.svelte +359 -0
  5. package/src/index.ts +42 -30
  6. package/src/widgets/WidgetRenderer.svelte +114 -104
  7. package/src/widgets/export-widget.ts +28 -1
  8. package/src/widgets/helpers/safe-image.ts +78 -0
  9. package/src/widgets/notebook/.gitkeep +0 -0
  10. package/src/widgets/notebook/chart-renderer.ts +63 -0
  11. package/src/widgets/notebook/compact.ts +823 -0
  12. package/src/widgets/notebook/document.ts +1065 -0
  13. package/src/widgets/notebook/editorial.ts +936 -0
  14. package/src/widgets/notebook/executors/.gitkeep +1 -0
  15. package/src/widgets/notebook/executors/index.ts +4 -0
  16. package/src/widgets/notebook/executors/js-worker.ts +269 -0
  17. package/src/widgets/notebook/executors/sql.ts +206 -0
  18. package/src/widgets/notebook/import-modals.ts +553 -0
  19. package/src/widgets/notebook/left-pane.ts +249 -0
  20. package/src/widgets/notebook/prose.ts +280 -0
  21. package/src/widgets/notebook/recipe-browser.ts +350 -0
  22. package/src/widgets/notebook/recipes/compact.md +124 -0
  23. package/src/widgets/notebook/recipes/document.md +139 -0
  24. package/src/widgets/notebook/recipes/editorial.md +120 -0
  25. package/src/widgets/notebook/recipes/workspace.md +119 -0
  26. package/src/widgets/notebook/resource-extractor.ts +162 -0
  27. package/src/widgets/notebook/share-handlers.ts +222 -0
  28. package/src/widgets/notebook/shared.ts +1592 -0
  29. package/src/widgets/notebook/workspace.ts +852 -0
  30. package/src/widgets/rich/cards.ts +181 -0
  31. package/src/widgets/rich/carousel.ts +319 -0
  32. package/src/widgets/rich/chart-rich.ts +386 -0
  33. package/src/widgets/rich/d3.ts +503 -0
  34. package/src/widgets/rich/data-table.ts +342 -0
  35. package/src/widgets/rich/gallery.ts +350 -0
  36. package/src/widgets/rich/grid-data.ts +173 -0
  37. package/src/widgets/rich/hemicycle.ts +313 -0
  38. package/src/widgets/rich/js-sandbox.ts +106 -0
  39. package/src/widgets/rich/json-viewer.ts +202 -0
  40. package/src/widgets/rich/log.ts +143 -0
  41. package/src/widgets/rich/map.ts +218 -0
  42. package/src/widgets/rich/profile.ts +256 -0
  43. package/src/widgets/rich/sankey.ts +262 -0
  44. package/src/widgets/rich/stat-card.ts +125 -0
  45. package/src/widgets/rich/timeline.ts +179 -0
  46. package/src/widgets/rich/trombinoscope.ts +246 -0
  47. package/src/widgets/simple/actions.ts +89 -0
  48. package/src/widgets/simple/alert.ts +100 -0
  49. package/src/widgets/simple/chart.ts +189 -0
  50. package/src/widgets/simple/code.ts +79 -0
  51. package/src/widgets/simple/kv.ts +68 -0
  52. package/src/widgets/simple/list.ts +89 -0
  53. package/src/widgets/simple/stat.ts +58 -0
  54. package/src/widgets/simple/tags.ts +125 -0
  55. package/src/widgets/simple/text.ts +198 -0
  56. package/src/widgets/SafeImage.svelte +0 -76
  57. package/src/widgets/rich/Cards.svelte +0 -39
  58. package/src/widgets/rich/Carousel.svelte +0 -88
  59. package/src/widgets/rich/Chart.svelte +0 -142
  60. package/src/widgets/rich/D3Widget.svelte +0 -378
  61. package/src/widgets/rich/DataTable.svelte +0 -62
  62. package/src/widgets/rich/Gallery.svelte +0 -94
  63. package/src/widgets/rich/GridData.svelte +0 -44
  64. package/src/widgets/rich/Hemicycle.svelte +0 -78
  65. package/src/widgets/rich/JsSandbox.svelte +0 -51
  66. package/src/widgets/rich/JsonViewer.svelte +0 -42
  67. package/src/widgets/rich/LogViewer.svelte +0 -24
  68. package/src/widgets/rich/MapView.svelte +0 -140
  69. package/src/widgets/rich/ProfileCard.svelte +0 -59
  70. package/src/widgets/rich/Sankey.svelte +0 -56
  71. package/src/widgets/rich/StatCard.svelte +0 -35
  72. package/src/widgets/rich/Timeline.svelte +0 -43
  73. package/src/widgets/rich/Trombinoscope.svelte +0 -48
  74. package/src/widgets/simple/ActionsBlock.svelte +0 -15
  75. package/src/widgets/simple/AlertBlock.svelte +0 -11
  76. package/src/widgets/simple/ChartBlock.svelte +0 -21
  77. package/src/widgets/simple/CodeBlock.svelte +0 -11
  78. package/src/widgets/simple/KVBlock.svelte +0 -16
  79. package/src/widgets/simple/ListBlock.svelte +0 -17
  80. package/src/widgets/simple/StatBlock.svelte +0 -14
  81. package/src/widgets/simple/TagsBlock.svelte +0 -15
  82. package/src/widgets/simple/TextBlock.svelte +0 -122
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webmcp-auto-ui/ui",
3
- "version": "2.5.27",
3
+ "version": "2.5.28",
4
4
  "description": "Svelte 5 UI components — primitives, widgets, window manager",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -15,7 +15,12 @@
15
15
  "./canvas": {
16
16
  "svelte": "./src/stores/canvas.svelte.ts",
17
17
  "import": "./src/stores/canvas.svelte.ts"
18
- }
18
+ },
19
+ "./widgets/notebook/*": {
20
+ "svelte": "./src/widgets/notebook/*",
21
+ "import": "./src/widgets/notebook/*"
22
+ },
23
+ "./widgets/notebook/recipes/*": "./src/widgets/notebook/recipes/*"
19
24
  },
20
25
  "scripts": {
21
26
  "build": "svelte-package -i src",
@@ -25,7 +30,13 @@
25
30
  "peerDependencies": {
26
31
  "d3": "^7.9.0",
27
32
  "leaflet": ">=1.9.0",
28
- "svelte": "^5.0.0"
33
+ "svelte": "^5.0.0",
34
+ "vega-embed": "^6.24.0"
35
+ },
36
+ "peerDependenciesMeta": {
37
+ "vega-embed": {
38
+ "optional": true
39
+ }
29
40
  },
30
41
  "devDependencies": {
31
42
  "@sveltejs/package": "^2.3.0",
@@ -46,6 +57,7 @@
46
57
  "auto-ui"
47
58
  ],
48
59
  "dependencies": {
60
+ "@webmcp-auto-ui/core": "*",
49
61
  "@webmcp-auto-ui/sdk": "*",
50
62
  "@types/d3": "^7.4.3",
51
63
  "bits-ui": "^2.17.2",
@@ -0,0 +1,164 @@
1
+ <script lang="ts">
2
+ /**
3
+ * DataServersPanel — manages the list of data (MCP) servers in the canvas store.
4
+ *
5
+ * Reads/writes through `canvas.dataServers` (reactive, Svelte 5 runes wrapper).
6
+ * A companion `MultiMcpBridge` (installed in the app root) observes the store
7
+ * and connects/disconnects servers according to their `enabled` flag.
8
+ *
9
+ * Visual state per server:
10
+ * - disabled : grey dot
11
+ * - enabled + !connected : yellow dot (connecting)
12
+ * - enabled + connected : green dot (ok)
13
+ * - error : red dot with tooltip
14
+ */
15
+ import { canvas } from '@webmcp-auto-ui/sdk/canvas';
16
+
17
+ let connectOpen = $state(false);
18
+ let newName = $state('');
19
+ let newUrl = $state('');
20
+ let formError = $state<string | null>(null);
21
+ let nameInputEl: HTMLInputElement | null = $state(null);
22
+
23
+ function openModal() {
24
+ connectOpen = true;
25
+ formError = null;
26
+ // Focus the name field once the modal mounts.
27
+ queueMicrotask(() => nameInputEl?.focus());
28
+ }
29
+
30
+ function closeModal() {
31
+ connectOpen = false;
32
+ newName = '';
33
+ newUrl = '';
34
+ formError = null;
35
+ }
36
+
37
+ function onKey(e: KeyboardEvent) {
38
+ if (e.key === 'Escape' && connectOpen) {
39
+ e.preventDefault();
40
+ closeModal();
41
+ }
42
+ }
43
+
44
+ function submitConnect(e?: Event) {
45
+ e?.preventDefault();
46
+ const name = newName.trim();
47
+ const url = newUrl.trim();
48
+ if (!name) { formError = 'Name required'; return; }
49
+ if (canvas.getDataServer(name)) { formError = 'A server with this name already exists'; return; }
50
+ try { new URL(url); } catch { formError = 'Invalid URL'; return; }
51
+ canvas.addDataServer({ name, url });
52
+ // addDataServer initialises enabled=true; the bridge will pick it up.
53
+ closeModal();
54
+ }
55
+ </script>
56
+
57
+ <svelte:window onkeydown={onKey} />
58
+
59
+ <section class="flex flex-col gap-2">
60
+ <div class="flex items-center justify-between">
61
+ <span class="text-[9px] font-mono text-text2 uppercase tracking-wider">Data servers</span>
62
+ <button
63
+ type="button"
64
+ class="font-mono text-[10px] h-6 px-2 rounded border border-accent/40 text-accent hover:bg-accent/10 transition-colors"
65
+ onclick={openModal}
66
+ >
67
+ + Connect
68
+ </button>
69
+ </div>
70
+
71
+ {#if canvas.dataServers.length === 0}
72
+ <div class="text-[10px] font-mono text-text2/50 italic">No data servers connected.</div>
73
+ {:else}
74
+ <ul class="flex flex-col gap-1">
75
+ {#each canvas.dataServers as s (s.name)}
76
+ <li class="flex items-center gap-2 px-2 py-1.5 rounded border border-border2/60 bg-surface2/40">
77
+ <label class="flex items-center gap-2 flex-1 min-w-0 cursor-pointer">
78
+ <input
79
+ type="checkbox"
80
+ class="accent-accent w-3.5 h-3.5 flex-shrink-0"
81
+ checked={s.enabled}
82
+ onchange={() => canvas.toggleDataServer(s.name)}
83
+ aria-label={`Toggle ${s.name}`}
84
+ />
85
+ <span
86
+ class="w-1.5 h-1.5 rounded-full flex-shrink-0
87
+ {s.error ? 'bg-accent2'
88
+ : !s.enabled ? 'bg-text2/40'
89
+ : s.connected ? 'bg-teal'
90
+ : 'bg-amber animate-pulse'}"
91
+ title={s.error
92
+ ? `Error: ${s.error}`
93
+ : !s.enabled ? 'Disabled'
94
+ : s.connected ? 'Connected'
95
+ : 'Connecting…'}
96
+ ></span>
97
+ <span class="font-mono text-xs text-text1 truncate" title={s.url}>{s.name}</span>
98
+ {#if s.tools && s.tools.length > 0}
99
+ <span class="font-mono text-[9px] text-text2/60 flex-shrink-0">{s.tools.length} tools</span>
100
+ {/if}
101
+ </label>
102
+ <button
103
+ type="button"
104
+ class="text-text2/60 hover:text-accent2 transition-colors flex-shrink-0 text-sm leading-none px-1"
105
+ onclick={() => canvas.removeDataServer(s.name)}
106
+ aria-label={`Remove ${s.name}`}
107
+ title="Remove"
108
+ >&times;</button>
109
+ </li>
110
+ {/each}
111
+ </ul>
112
+ {/if}
113
+ </section>
114
+
115
+ {#if connectOpen}
116
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
117
+ <div
118
+ class="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4"
119
+ onclick={closeModal}
120
+ >
121
+ <form
122
+ class="w-full max-w-sm bg-surface border border-border2 rounded-lg shadow-xl p-5 flex flex-col gap-3"
123
+ onclick={(e) => e.stopPropagation()}
124
+ onsubmit={submitConnect}
125
+ >
126
+ <h4 class="font-mono text-sm font-bold text-text1">Connect data server</h4>
127
+ <label class="flex flex-col gap-1">
128
+ <span class="text-[9px] font-mono text-text2 uppercase tracking-wider">Name</span>
129
+ <input
130
+ bind:this={nameInputEl}
131
+ bind:value={newName}
132
+ type="text"
133
+ placeholder="tricoteuses"
134
+ required
135
+ class="font-mono text-xs h-7 px-2 rounded border border-border2 bg-surface2 text-text1"
136
+ />
137
+ </label>
138
+ <label class="flex flex-col gap-1">
139
+ <span class="text-[9px] font-mono text-text2 uppercase tracking-wider">URL</span>
140
+ <input
141
+ bind:value={newUrl}
142
+ type="url"
143
+ placeholder="https://mcp.example.com/mcp"
144
+ required
145
+ class="font-mono text-xs h-7 px-2 rounded border border-border2 bg-surface2 text-text1"
146
+ />
147
+ </label>
148
+ {#if formError}
149
+ <div class="font-mono text-[10px] text-accent2">{formError}</div>
150
+ {/if}
151
+ <div class="flex items-center justify-end gap-2 pt-1">
152
+ <button
153
+ type="button"
154
+ class="font-mono text-xs h-7 px-3 rounded border border-border2 text-text2 hover:text-text1 transition-colors"
155
+ onclick={closeModal}
156
+ >Cancel</button>
157
+ <button
158
+ type="submit"
159
+ class="font-mono text-xs h-7 px-3 rounded border border-accent/40 text-accent hover:bg-accent/10 transition-colors"
160
+ >Connect</button>
161
+ </div>
162
+ </form>
163
+ </div>
164
+ {/if}
@@ -1,10 +1,10 @@
1
1
  <script lang="ts">
2
- import { listTransformersModels } from '@webmcp-auto-ui/agent';
2
+ import { listTransformersModels, listHawkModels } from '@webmcp-auto-ui/agent';
3
3
 
4
4
  export interface ModelOption {
5
5
  value: string;
6
6
  label: string;
7
- group: 'remote' | 'wasm' | 'transformers' | 'local';
7
+ group: 'remote' | 'wasm' | 'transformers' | 'local' | 'hawk';
8
8
  }
9
9
 
10
10
  const transformersOptions: ModelOption[] = listTransformersModels().map(({ id, entry }) => ({
@@ -13,15 +13,23 @@
13
13
  group: 'transformers' as const,
14
14
  }));
15
15
 
16
+ const hawkOptions: ModelOption[] = listHawkModels().map(m => ({
17
+ value: `hawk-${m.id}`,
18
+ label: m.label,
19
+ group: 'hawk' as const,
20
+ }));
21
+
16
22
  const DEFAULT_MODELS: ModelOption[] = [
17
23
  { value: 'haiku', label: 'claude-haiku-4-5', group: 'remote' },
18
24
  { value: 'gemma-e2b', label: 'Gemma E2B (MediaPipe)', group: 'wasm' },
19
25
  { value: 'gemma-e4b', label: 'Gemma E4B (MediaPipe)', group: 'wasm' },
20
26
  ...transformersOptions,
27
+ ...hawkOptions,
21
28
  ];
22
29
 
23
30
  const GROUP_LABELS: Record<string, string> = {
24
31
  remote: 'Remote',
32
+ hawk: 'Remote (Hawk)',
25
33
  wasm: 'In-Browser (MediaPipe)',
26
34
  transformers: 'In-Browser (Transformers.js)',
27
35
  local: 'Local',
@@ -36,7 +44,7 @@
36
44
  let { value, onchange, models = DEFAULT_MODELS, class: cls = '' }: Props = $props();
37
45
 
38
46
  const groups = $derived(
39
- ['remote', 'wasm', 'transformers', 'local'].filter(g => models.some(m => m.group === g))
47
+ ['remote', 'hawk', 'wasm', 'transformers', 'local'].filter(g => models.some(m => m.group === g))
40
48
  );
41
49
  </script>
42
50
 
@@ -0,0 +1,359 @@
1
+ <script lang="ts">
2
+ import {
3
+ listCachedModels, clearModelCache, clearAllModelCaches, type CachedModelInfo,
4
+ listAllStorage, deleteStorageEntry, clearAllStorage, type StorageEntry,
5
+ } from '@webmcp-auto-ui/agent';
6
+
7
+ interface Props {
8
+ class?: string;
9
+ }
10
+ let { class: cls = '' }: Props = $props();
11
+
12
+ let models = $state<CachedModelInfo[]>([]);
13
+ let storage = $state<StorageEntry[]>([]);
14
+ let usage = $state<number | null>(null);
15
+ let quota = $state<number | null>(null);
16
+ let loading = $state(true);
17
+ let supported = $state(true);
18
+ let busy = $state<string | null>(null);
19
+ let collapsed = $state(true);
20
+
21
+ let opfsCollapsed = $state(false);
22
+ let cacheCollapsed = $state(true);
23
+ let idbCollapsed = $state(true);
24
+
25
+ const totalSize = $derived(models.reduce((s, m) => s + m.size, 0));
26
+ const usagePct = $derived(quota && quota > 0 && usage !== null ? Math.min(100, (usage / quota) * 100) : 0);
27
+
28
+ const opfsExtras = $derived(storage.filter((e) => e.source === 'opfs'));
29
+ const cacheEntries = $derived(storage.filter((e) => e.source === 'cache-storage'));
30
+ const idbEntries = $derived(storage.filter((e) => e.source === 'indexeddb'));
31
+
32
+ const cacheTotalSize = $derived(cacheEntries.reduce((s, e) => s + e.size, 0));
33
+ const opfsExtrasTotalSize = $derived(opfsExtras.reduce((s, e) => s + e.size, 0));
34
+
35
+ function formatBytes(n: number): string {
36
+ if (!Number.isFinite(n) || n <= 0) return '0 B';
37
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
38
+ let v = n;
39
+ let i = 0;
40
+ while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
41
+ return `${v.toFixed(v >= 100 || i === 0 ? 0 : v >= 10 ? 1 : 2)} ${units[i]}`;
42
+ }
43
+
44
+ function formatDate(ms: number): string {
45
+ if (!ms) return '—';
46
+ const d = new Date(ms);
47
+ return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
48
+ }
49
+
50
+ function formatSize(e: StorageEntry): string {
51
+ if (!e.sizeKnown) return 'taille inconnue';
52
+ return formatBytes(e.size);
53
+ }
54
+
55
+ async function refresh() {
56
+ loading = true;
57
+ try {
58
+ if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) {
59
+ supported = false;
60
+ return;
61
+ }
62
+ supported = true;
63
+ const [ms, sto] = await Promise.all([listCachedModels(), listAllStorage()]);
64
+ models = ms;
65
+ storage = sto;
66
+ try {
67
+ const est = await navigator.storage.estimate();
68
+ usage = est.usage ?? null;
69
+ quota = est.quota ?? null;
70
+ } catch {
71
+ usage = null;
72
+ quota = null;
73
+ }
74
+ } finally {
75
+ loading = false;
76
+ }
77
+ }
78
+
79
+ async function deleteOne(repo: string) {
80
+ if (!confirm(`Supprimer le cache "${repo}" ?`)) return;
81
+ busy = `opfs:${repo}`;
82
+ try {
83
+ await clearModelCache(repo);
84
+ await refresh();
85
+ } finally {
86
+ busy = null;
87
+ }
88
+ }
89
+
90
+ async function deleteAll() {
91
+ if (!confirm(`Supprimer TOUS les modèles en cache (${models.length} repo·s, ${formatBytes(totalSize)}) ?`)) return;
92
+ busy = '__opfs_all__';
93
+ try {
94
+ await clearAllModelCaches();
95
+ await refresh();
96
+ } finally {
97
+ busy = null;
98
+ }
99
+ }
100
+
101
+ async function deleteEntry(e: StorageEntry) {
102
+ const label = `${e.source} · ${e.key}`;
103
+ if (!confirm(`Supprimer ${label} ?`)) return;
104
+ busy = `${e.source}:${e.key}`;
105
+ try {
106
+ await deleteStorageEntry(e);
107
+ await refresh();
108
+ } finally {
109
+ busy = null;
110
+ }
111
+ }
112
+
113
+ async function deleteSource(source: 'opfs' | 'cache-storage' | 'indexeddb', count: number) {
114
+ if (!confirm(`Supprimer TOUT le ${source} (${count} entrée·s) ?`)) return;
115
+ busy = `__${source}_all__`;
116
+ try {
117
+ await clearAllStorage(source);
118
+ await refresh();
119
+ } finally {
120
+ busy = null;
121
+ }
122
+ }
123
+
124
+ $effect(() => { refresh(); });
125
+ </script>
126
+
127
+ <div class="flex flex-col gap-2 {cls}">
128
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
129
+ <div class="flex items-center gap-1 cursor-pointer select-none"
130
+ onclick={() => collapsed = !collapsed}>
131
+ <span class="text-[9px] font-mono text-text2 uppercase tracking-wider">Model cache (OPFS)</span>
132
+ <span class="text-[9px] text-text2/60 font-mono">
133
+ {#if !supported}(n/a){:else if loading}(…){:else}({models.length} · {formatBytes(totalSize)}){/if}
134
+ </span>
135
+ <span class="text-[10px] text-text2 ml-auto transition-transform {collapsed ? '' : 'rotate-90'}">{@html '&#x25B6;'}</span>
136
+ </div>
137
+
138
+ {#if !collapsed}
139
+ <div class="text-[9px] font-mono text-text2/50 -mt-1">OPFS + Cache Storage + IndexedDB</div>
140
+
141
+ {#if !supported}
142
+ <div class="text-[10px] font-mono text-text2/60 p-2 border border-border2 rounded">
143
+ OPFS indisponible dans ce navigateur.
144
+ </div>
145
+ {:else if loading}
146
+ <div class="text-[10px] font-mono text-text2/60">Lecture du cache…</div>
147
+ {:else}
148
+ {#if quota !== null && usage !== null}
149
+ <div class="flex flex-col gap-1">
150
+ <div class="flex items-center justify-between font-mono text-[10px] text-text2">
151
+ <span>{formatBytes(usage)} / {formatBytes(quota)}</span>
152
+ <span class="text-text2/60">origine entière · {usagePct.toFixed(1)}%</span>
153
+ </div>
154
+ <div class="h-1 bg-surface2 rounded overflow-hidden">
155
+ <div class="h-full bg-teal transition-all" style="width: {usagePct}%"></div>
156
+ </div>
157
+ </div>
158
+ {/if}
159
+
160
+ <!-- ── LLM models (OPFS webmcp-models) ───────────────────────────── -->
161
+ <div class="flex flex-col gap-1">
162
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
163
+ <div class="flex items-center gap-1 cursor-pointer select-none"
164
+ onclick={() => opfsCollapsed = !opfsCollapsed}>
165
+ <span class="text-[9px] font-mono text-text2 uppercase tracking-wider">LLM models (OPFS)</span>
166
+ <span class="text-[9px] text-text2/60 font-mono">({models.length} · {formatBytes(totalSize)})</span>
167
+ <span class="text-[10px] text-text2 ml-auto transition-transform {opfsCollapsed ? '' : 'rotate-90'}">{@html '&#x25B6;'}</span>
168
+ </div>
169
+
170
+ {#if !opfsCollapsed}
171
+ <div class="flex items-center justify-between">
172
+ <span class="font-mono text-[10px] text-text2">
173
+ Modèles : <span class="text-text1">{models.length}</span> · {formatBytes(totalSize)}
174
+ </span>
175
+ {#if models.length > 0}
176
+ <button
177
+ class="font-mono text-[10px] h-6 px-2 rounded border border-accent2/40 text-accent2 hover:bg-accent2/10 transition-colors disabled:opacity-40"
178
+ disabled={busy !== null}
179
+ onclick={deleteAll}
180
+ >
181
+ {busy === '__opfs_all__' ? 'Suppression…' : 'Tout supprimer'}
182
+ </button>
183
+ {/if}
184
+ </div>
185
+
186
+ {#if models.length === 0}
187
+ <div class="text-[10px] font-mono text-text2/60 p-2 border border-dashed border-border2 rounded text-center">
188
+ Aucun modèle en cache.
189
+ </div>
190
+ {:else}
191
+ <ul class="flex flex-col gap-1">
192
+ {#each models as m (m.repo)}
193
+ <li class="flex items-center gap-2 p-2 rounded border border-border2 bg-surface2/40">
194
+ <div class="flex-1 min-w-0 flex flex-col gap-0.5">
195
+ <span class="font-mono text-[11px] text-text1 truncate" title={m.repo}>{m.repo}</span>
196
+ <span class="font-mono text-[9px] text-text2/60">
197
+ {formatBytes(m.size)} · {m.fileCount} fichier{m.fileCount > 1 ? 's' : ''} · {formatDate(m.lastModified)}
198
+ </span>
199
+ </div>
200
+ <button
201
+ class="font-mono text-[10px] h-6 px-2 rounded border border-accent2/30 text-accent2 hover:bg-accent2/10 transition-colors disabled:opacity-40 flex-shrink-0"
202
+ disabled={busy !== null}
203
+ onclick={() => deleteOne(m.repo)}
204
+ title="Supprimer ce cache"
205
+ >
206
+ {busy === `opfs:${m.repo}` ? '…' : 'Supprimer'}
207
+ </button>
208
+ </li>
209
+ {/each}
210
+ </ul>
211
+ {/if}
212
+
213
+ {#if opfsExtras.length > 0}
214
+ <div class="text-[9px] font-mono text-text2/50 mt-1">Autres OPFS : {opfsExtras.length} · {formatBytes(opfsExtrasTotalSize)}</div>
215
+ <ul class="flex flex-col gap-1">
216
+ {#each opfsExtras as e (e.key)}
217
+ <li class="flex items-center gap-2 p-2 rounded border border-border2 bg-surface2/40">
218
+ <div class="flex-1 min-w-0 flex flex-col gap-0.5">
219
+ <span class="font-mono text-[11px] text-text1 truncate flex items-center gap-1" title={e.key}>
220
+ {#if e.modelLike}<span class="text-accent text-[9px]">model</span>{/if}
221
+ {e.key}
222
+ </span>
223
+ <span class="font-mono text-[9px] text-text2/60">
224
+ {formatSize(e)} · {e.itemCount} fichier{e.itemCount > 1 ? 's' : ''} · {formatDate(e.lastModified)}
225
+ </span>
226
+ </div>
227
+ <button
228
+ class="font-mono text-[10px] h-6 px-2 rounded border border-accent2/30 text-accent2 hover:bg-accent2/10 transition-colors disabled:opacity-40 flex-shrink-0"
229
+ disabled={busy !== null}
230
+ onclick={() => deleteEntry(e)}
231
+ title="Supprimer ce répertoire OPFS"
232
+ >
233
+ {busy === `opfs:${e.key}` ? '…' : 'Supprimer'}
234
+ </button>
235
+ </li>
236
+ {/each}
237
+ </ul>
238
+ {/if}
239
+ {/if}
240
+ </div>
241
+
242
+ <!-- ── Cache Storage ─────────────────────────────────────────────── -->
243
+ <div class="flex flex-col gap-1">
244
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
245
+ <div class="flex items-center gap-1 cursor-pointer select-none"
246
+ onclick={() => cacheCollapsed = !cacheCollapsed}>
247
+ <span class="text-[9px] font-mono text-text2 uppercase tracking-wider">Cache Storage</span>
248
+ <span class="text-[9px] text-text2/60 font-mono">({cacheEntries.length} · {formatBytes(cacheTotalSize)})</span>
249
+ <span class="text-[10px] text-text2 ml-auto transition-transform {cacheCollapsed ? '' : 'rotate-90'}">{@html '&#x25B6;'}</span>
250
+ </div>
251
+
252
+ {#if !cacheCollapsed}
253
+ <div class="flex items-center justify-between">
254
+ <span class="font-mono text-[10px] text-text2">
255
+ Caches : <span class="text-text1">{cacheEntries.length}</span> · {formatBytes(cacheTotalSize)}
256
+ </span>
257
+ {#if cacheEntries.length > 0}
258
+ <button
259
+ class="font-mono text-[10px] h-6 px-2 rounded border border-accent2/40 text-accent2 hover:bg-accent2/10 transition-colors disabled:opacity-40"
260
+ disabled={busy !== null}
261
+ onclick={() => deleteSource('cache-storage', cacheEntries.length)}
262
+ >
263
+ {busy === '__cache-storage_all__' ? 'Suppression…' : 'Tout supprimer'}
264
+ </button>
265
+ {/if}
266
+ </div>
267
+
268
+ {#if cacheEntries.length === 0}
269
+ <div class="text-[10px] font-mono text-text2/60 p-2 border border-dashed border-border2 rounded text-center">
270
+ Aucun cache.
271
+ </div>
272
+ {:else}
273
+ <ul class="flex flex-col gap-1">
274
+ {#each cacheEntries as e (e.key)}
275
+ <li class="flex items-center gap-2 p-2 rounded border border-border2 bg-surface2/40">
276
+ <div class="flex-1 min-w-0 flex flex-col gap-0.5">
277
+ <span class="font-mono text-[11px] text-text1 truncate flex items-center gap-1" title={e.key}>
278
+ {#if e.modelLike}<span class="text-accent text-[9px]">model</span>{/if}
279
+ {e.key}
280
+ </span>
281
+ <span class="font-mono text-[9px] text-text2/60">
282
+ {formatSize(e)} · {e.itemCount} item{e.itemCount > 1 ? 's' : ''} · {formatDate(e.lastModified)}
283
+ </span>
284
+ </div>
285
+ <button
286
+ class="font-mono text-[10px] h-6 px-2 rounded border border-accent2/30 text-accent2 hover:bg-accent2/10 transition-colors disabled:opacity-40 flex-shrink-0"
287
+ disabled={busy !== null}
288
+ onclick={() => deleteEntry(e)}
289
+ title="Supprimer ce cache"
290
+ >
291
+ {busy === `cache-storage:${e.key}` ? '…' : 'Supprimer'}
292
+ </button>
293
+ </li>
294
+ {/each}
295
+ </ul>
296
+ {/if}
297
+ {/if}
298
+ </div>
299
+
300
+ <!-- ── IndexedDB ─────────────────────────────────────────────────── -->
301
+ <div class="flex flex-col gap-1">
302
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
303
+ <div class="flex items-center gap-1 cursor-pointer select-none"
304
+ onclick={() => idbCollapsed = !idbCollapsed}>
305
+ <span class="text-[9px] font-mono text-text2 uppercase tracking-wider">IndexedDB</span>
306
+ <span class="text-[9px] text-text2/60 font-mono">({idbEntries.length})</span>
307
+ <span class="text-[10px] text-text2 ml-auto transition-transform {idbCollapsed ? '' : 'rotate-90'}">{@html '&#x25B6;'}</span>
308
+ </div>
309
+
310
+ {#if !idbCollapsed}
311
+ <div class="flex items-center justify-between">
312
+ <span class="font-mono text-[10px] text-text2">
313
+ Bases : <span class="text-text1">{idbEntries.length}</span> · taille inconnue
314
+ </span>
315
+ {#if idbEntries.length > 0}
316
+ <button
317
+ class="font-mono text-[10px] h-6 px-2 rounded border border-accent2/40 text-accent2 hover:bg-accent2/10 transition-colors disabled:opacity-40"
318
+ disabled={busy !== null}
319
+ onclick={() => deleteSource('indexeddb', idbEntries.length)}
320
+ >
321
+ {busy === '__indexeddb_all__' ? 'Suppression…' : 'Tout supprimer'}
322
+ </button>
323
+ {/if}
324
+ </div>
325
+
326
+ {#if idbEntries.length === 0}
327
+ <div class="text-[10px] font-mono text-text2/60 p-2 border border-dashed border-border2 rounded text-center">
328
+ Aucune base IndexedDB.
329
+ </div>
330
+ {:else}
331
+ <ul class="flex flex-col gap-1">
332
+ {#each idbEntries as e (e.key)}
333
+ <li class="flex items-center gap-2 p-2 rounded border border-border2 bg-surface2/40">
334
+ <div class="flex-1 min-w-0 flex flex-col gap-0.5">
335
+ <span class="font-mono text-[11px] text-text1 truncate flex items-center gap-1" title={e.key}>
336
+ {#if e.modelLike}<span class="text-accent text-[9px]">model</span>{/if}
337
+ {e.key}
338
+ </span>
339
+ <span class="font-mono text-[9px] text-text2/60">
340
+ taille inconnue · version {e.itemCount}
341
+ </span>
342
+ </div>
343
+ <button
344
+ class="font-mono text-[10px] h-6 px-2 rounded border border-accent2/30 text-accent2 hover:bg-accent2/10 transition-colors disabled:opacity-40 flex-shrink-0"
345
+ disabled={busy !== null}
346
+ onclick={() => deleteEntry(e)}
347
+ title="Supprimer cette base"
348
+ >
349
+ {busy === `indexeddb:${e.key}` ? '…' : 'Supprimer'}
350
+ </button>
351
+ </li>
352
+ {/each}
353
+ </ul>
354
+ {/if}
355
+ {/if}
356
+ </div>
357
+ {/if}
358
+ {/if}
359
+ </div>