@ifc-lite/viewer 1.23.0 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/.turbo/turbo-build.log +34 -31
  2. package/CHANGELOG.md +96 -0
  3. package/dist/assets/{basketViewActivator-Dn_bHUl2.js → basketViewActivator-CU8_toGq.js} +7 -7
  4. package/dist/assets/{bcf-B9SFl84i.js → bcf-DXGDhw56.js} +23 -23
  5. package/dist/assets/{deflate-yMpdCIqk.js → deflate-Bb1_H2Yf.js} +1 -1
  6. package/dist/assets/{exporters-D-BvrNIg.js → exporters-DZhLN0ux.js} +1861 -1658
  7. package/dist/assets/geometry-controller.worker-DQOSYqtw.js +7 -0
  8. package/dist/assets/geometry.worker-B62e03Ao.js +1 -0
  9. package/dist/assets/{geotiff-D1tvcDCb.js → geotiff-y0ZxbRJd.js} +10 -10
  10. package/dist/assets/{ids-DZLs0snJ.js → ids-DruUNtfD.js} +4 -4
  11. package/dist/assets/ifc-lite-Ch2T9pP9.js +7 -0
  12. package/dist/assets/{ifc-lite_bg-DyHX37GQ.wasm → ifc-lite_bg-D7O1WHgP.wasm} +0 -0
  13. package/dist/assets/{ifc-lite_bg-BIryVCXQ.wasm → ifc-lite_bg-iH_07wf8.wasm} +0 -0
  14. package/dist/assets/index-Bws3UAkj.css +1 -0
  15. package/dist/assets/{index-CXSBhkcJ.js → index-Dr88ZlSY.js} +64100 -47030
  16. package/dist/assets/{jpeg-DUMcZp24.js → jpeg-B3_loqFe.js} +1 -1
  17. package/dist/assets/lens-PYsLu_MA.js +1 -0
  18. package/dist/assets/{lerc-IN4uWojP.js → lerc-nkwS8ZUe.js} +1 -1
  19. package/dist/assets/{lzw-Cnw0hH-m.js → lzw-D3cW5Wpg.js} +1 -1
  20. package/dist/assets/{native-bridge-BVf2uzoH.js → native-bridge-BcYJooq8.js} +2 -2
  21. package/dist/assets/{packbits-BskJCwk0.js → packbits-DDN4xzB5.js} +1 -1
  22. package/dist/assets/{parser.worker-BdtkkaGf.js → parser.worker-BW1IMUed.js} +3 -3
  23. package/dist/assets/raw-CoIXstQ-.js +1 -0
  24. package/dist/assets/{sandbox-VLI_y7cl.js → sandbox-DETNEyQb.js} +498 -470
  25. package/dist/assets/{server-client-BLcKaWQB.js → server-client-CmzJOeS7.js} +1 -1
  26. package/dist/assets/{wasm-bridge-BAfZh7YT.js → wasm-bridge-CT7mK9W0.js} +1 -1
  27. package/dist/assets/{webimage-Db2xzze3.js → webimage-CBjgg4up.js} +1 -1
  28. package/dist/assets/{workerHelpers--sAYm9yN.js → workerHelpers-IEQDo8r3.js} +1 -1
  29. package/dist/assets/{zstd-BDToOQyD.js → zstd-C8oQ6qdS.js} +1 -1
  30. package/dist/index.html +8 -8
  31. package/package.json +11 -9
  32. package/src/App.tsx +5 -2
  33. package/src/components/extensions/AuditLogPanel.tsx +259 -0
  34. package/src/components/extensions/BundlePreview.tsx +102 -0
  35. package/src/components/extensions/CapabilityReview.tsx +333 -0
  36. package/src/components/extensions/ExtensionDockHost.tsx +192 -0
  37. package/src/components/extensions/ExtensionToolbarSlot.tsx +106 -0
  38. package/src/components/extensions/ExtensionsPanel.tsx +481 -0
  39. package/src/components/extensions/FlavorDialog.tsx +398 -0
  40. package/src/components/extensions/FlavorImportPreview.tsx +79 -0
  41. package/src/components/extensions/FlavorIndicator.tsx +81 -0
  42. package/src/components/extensions/FlavorListView.tsx +318 -0
  43. package/src/components/extensions/FlavorMergeDialog.tsx +326 -0
  44. package/src/components/extensions/HelpHint.tsx +182 -0
  45. package/src/components/extensions/IdeasPanel.tsx +344 -0
  46. package/src/components/extensions/PlanCard.tsx +227 -0
  47. package/src/components/extensions/PrivacyPanel.tsx +312 -0
  48. package/src/components/extensions/PromoteToolDialog.tsx +313 -0
  49. package/src/components/extensions/RepairQueuePanel.tsx +222 -0
  50. package/src/components/extensions/icon-registry.ts +92 -0
  51. package/src/components/extensions/toast-helpers.ts +49 -0
  52. package/src/components/extensions/widget/WidgetErrorBoundary.tsx +62 -0
  53. package/src/components/extensions/widget/WidgetRenderer.tsx +428 -0
  54. package/src/components/viewer/ChatPanel.tsx +251 -3
  55. package/src/components/viewer/CommandPalette.tsx +74 -4
  56. package/src/components/viewer/Drawing2DCanvas.tsx +178 -1
  57. package/src/components/viewer/EntityContextMenu.tsx +70 -0
  58. package/src/components/viewer/ExportDialog.tsx +9 -1
  59. package/src/components/viewer/KeyboardShortcutsDialog.tsx +21 -6
  60. package/src/components/viewer/LensPanel.tsx +50 -0
  61. package/src/components/viewer/MainToolbar.tsx +170 -87
  62. package/src/components/viewer/ScriptPanel.tsx +105 -1
  63. package/src/components/viewer/Section2DPanel.tsx +58 -2
  64. package/src/components/viewer/StatusBar.tsx +18 -0
  65. package/src/components/viewer/ViewerLayout.tsx +53 -4
  66. package/src/components/viewer/Viewport.tsx +72 -0
  67. package/src/hooks/useActionLogger.test.ts +161 -0
  68. package/src/hooks/useActionLogger.ts +141 -0
  69. package/src/hooks/useForkExtension.ts +51 -0
  70. package/src/hooks/useIfcFederation.ts +7 -1
  71. package/src/hooks/useInstalledExtensions.ts +43 -0
  72. package/src/hooks/usePrivacyDisclosure.ts +48 -0
  73. package/src/hooks/useRunExtensionTests.ts +67 -0
  74. package/src/hooks/useSlotContributions.ts +38 -0
  75. package/src/hooks/useSymbolicAnnotations.test.ts +124 -0
  76. package/src/hooks/useSymbolicAnnotations.ts +776 -0
  77. package/src/lib/desktop-product.ts +7 -1
  78. package/src/lib/lens/adapter.ts +14 -0
  79. package/src/lib/llm/prompt-cache.ts +77 -0
  80. package/src/lib/llm/stream-client.ts +20 -2
  81. package/src/lib/llm/stream-direct.ts +11 -1
  82. package/src/lib/llm/system-prompt.ts +42 -0
  83. package/src/lib/safe-mode.ts +30 -0
  84. package/src/sdk/ExtensionHostProvider.tsx +103 -0
  85. package/src/services/extensions/flavor-service.ts +183 -0
  86. package/src/services/extensions/host-commands.ts +112 -0
  87. package/src/services/extensions/host-installer.ts +289 -0
  88. package/src/services/extensions/host.ts +514 -0
  89. package/src/services/extensions/idb-flavor-storage.test.ts +140 -0
  90. package/src/services/extensions/idb-flavor-storage.ts +241 -0
  91. package/src/services/extensions/idb-log-storage.test.ts +110 -0
  92. package/src/services/extensions/idb-log-storage.ts +171 -0
  93. package/src/services/extensions/idb-storage.ts +228 -0
  94. package/src/services/extensions/runtime-errors.ts +26 -0
  95. package/src/services/extensions/sandbox-factory.ts +217 -0
  96. package/src/store/constants.ts +48 -6
  97. package/src/store/index.ts +6 -1
  98. package/src/store/slices/drawing2DSlice.ts +8 -0
  99. package/src/store/slices/extensionsSlice.ts +90 -0
  100. package/src/store/slices/lensSlice.ts +28 -0
  101. package/src/store/slices/visibilitySlice.test.ts +6 -0
  102. package/src/store/slices/visibilitySlice.ts +17 -8
  103. package/src/store/types.ts +2 -0
  104. package/dist/assets/geometry-controller.worker-Cm5pvyR6.js +0 -7
  105. package/dist/assets/geometry.worker-ClNvXIrj.js +0 -1
  106. package/dist/assets/ifc-lite-BDg0iIbj.js +0 -7
  107. package/dist/assets/index-DS_xJQfP.css +0 -1
  108. package/dist/assets/lens-CpjUdqpw.js +0 -1
  109. package/dist/assets/raw-DzTtEZIY.js +0 -1
@@ -12,7 +12,8 @@ export type DesktopFeature =
12
12
  | 'exports'
13
13
  | 'ids_validation'
14
14
  | 'bcf_issue_management'
15
- | 'ai_assistant';
15
+ | 'ai_assistant'
16
+ | 'extensions';
16
17
 
17
18
  export interface DesktopEntitlement {
18
19
  tier: DesktopPlanTier;
@@ -69,6 +70,11 @@ const DESKTOP_FEATURES: Record<DesktopFeature, DesktopFeatureDefinition> = {
69
70
  description: 'Optional host-provided AI integrations.',
70
71
  free: true,
71
72
  },
73
+ extensions: {
74
+ label: 'Extensions & flavors',
75
+ description: 'Install user-authored extensions, manage flavors, and access the authoring loop.',
76
+ free: true,
77
+ },
72
78
  };
73
79
 
74
80
  export function isDesktopBillingEnforced(): boolean {
@@ -25,6 +25,7 @@ import type { FederatedModel } from '@/store/types';
25
25
 
26
26
  interface ModelEntry {
27
27
  id: string;
28
+ name: string;
28
29
  ifcDataStore: IfcDataStore;
29
30
  idOffset: number;
30
31
  maxExpressId: number;
@@ -58,6 +59,7 @@ export function createLensDataProvider(
58
59
  if (model.ifcDataStore) {
59
60
  entries.push({
60
61
  id: model.id,
62
+ name: model.name,
61
63
  ifcDataStore: model.ifcDataStore,
62
64
  idOffset: model.idOffset ?? 0,
63
65
  maxExpressId: model.maxExpressId ?? 0,
@@ -67,6 +69,7 @@ export function createLensDataProvider(
67
69
  } else if (legacyDataStore) {
68
70
  entries.push({
69
71
  id: 'legacy',
72
+ name: 'Model',
70
73
  ifcDataStore: legacyDataStore,
71
74
  idOffset: 0,
72
75
  maxExpressId: computeMaxExpressId(legacyDataStore),
@@ -285,6 +288,17 @@ export function createLensDataProvider(
285
288
  if (info.materials?.length) return info.materials[0]?.name;
286
289
  return undefined;
287
290
  },
291
+
292
+ getModelId(globalId: number): string | undefined {
293
+ const resolved = resolveGlobalId(globalId, entries);
294
+ if (!resolved) return undefined;
295
+ return resolved.entry.id;
296
+ },
297
+
298
+ getModelName(modelId: string): string | undefined {
299
+ const entry = entries.find(e => e.id === modelId);
300
+ return entry?.name ?? modelId;
301
+ },
288
302
  };
289
303
  }
290
304
 
@@ -0,0 +1,77 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Prompt-cache shaping helpers shared by `stream-direct.ts` (BYOK
7
+ * Anthropic SDK path) and `stream-client.ts` (proxy path).
8
+ *
9
+ * Anthropic supports a `cache_control: { type: 'ephemeral' }` marker
10
+ * on system-prompt blocks that pins the prefix into a 5-minute server
11
+ * cache. Subsequent calls with the same prefix hit the cache and pay
12
+ * a 10% read cost instead of the full input price.
13
+ *
14
+ * Threshold: 4096 chars (~1024 tokens at 4 chars/token) — Anthropic's
15
+ * documented minimum cacheable size. Below the threshold the wrapper
16
+ * returns the raw string, so cheap one-shot turns don't get the array
17
+ * shape.
18
+ *
19
+ * The contract: callers send the result as `system` in the
20
+ * Messages.create payload. The Anthropic SDK and the proxy both
21
+ * accept both `string` and the array form, so this is safe on both
22
+ * paths.
23
+ *
24
+ * Observability: when caching kicks in we log to console under
25
+ * `[ext:prompt-cache]`. The Anthropic response carries usage fields
26
+ * `cache_creation_input_tokens` and `cache_read_input_tokens` —
27
+ * stream callers that surface these via `onUsageInfo` get the hit
28
+ * rate visible in dev tools.
29
+ */
30
+
31
+ const CACHE_THRESHOLD_CHARS = 4096;
32
+
33
+ export interface CacheableTextBlock {
34
+ type: 'text';
35
+ text: string;
36
+ cache_control?: { type: 'ephemeral' };
37
+ }
38
+
39
+ /**
40
+ * Build the `system` argument for an Anthropic call. Returns:
41
+ * - `undefined` when the input is empty
42
+ * - the raw string when below threshold
43
+ * - a single-element array with `cache_control: { type: 'ephemeral' }`
44
+ * when the prompt is long enough to be worth caching
45
+ */
46
+ export function buildCacheableSystem(
47
+ system: string | undefined,
48
+ ): string | CacheableTextBlock[] | undefined {
49
+ if (!system) return undefined;
50
+ if (system.length < CACHE_THRESHOLD_CHARS) return system;
51
+ if (typeof console !== 'undefined' && console.debug) {
52
+ console.debug(
53
+ `[ext:prompt-cache] wrapping ${system.length}-char system prompt in ephemeral cache block`,
54
+ );
55
+ }
56
+ return [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }];
57
+ }
58
+
59
+ /**
60
+ * Log a cache-hit summary from a usage payload when present. Callers
61
+ * forward this from the stream completion event so we can see the
62
+ * cache_read / cache_creation token split per turn in dev tools.
63
+ */
64
+ export function logCacheHit(usage: {
65
+ cache_creation_input_tokens?: number;
66
+ cache_read_input_tokens?: number;
67
+ } | null | undefined): void {
68
+ if (!usage) return;
69
+ const creation = usage.cache_creation_input_tokens ?? 0;
70
+ const read = usage.cache_read_input_tokens ?? 0;
71
+ if (creation === 0 && read === 0) return;
72
+ if (typeof console !== 'undefined' && console.debug) {
73
+ console.debug(
74
+ `[ext:prompt-cache] cache_read=${read} cache_creation=${creation}`,
75
+ );
76
+ }
77
+ }
@@ -9,6 +9,8 @@
9
9
  * back as SSE. Extracts usage headers from the response for UI display.
10
10
  */
11
11
 
12
+ import { buildCacheableSystem, logCacheHit } from './prompt-cache.js';
13
+
12
14
  /** A text content part in a multimodal message */
13
15
  export interface TextContentPart {
14
16
  type: 'text';
@@ -213,7 +215,17 @@ export async function streamChat(options: StreamOptions): Promise<void> {
213
215
  'Content-Type': 'application/json',
214
216
  };
215
217
 
216
- const requestBody = JSON.stringify({ messages, model, system });
218
+ // Shape the system prompt with cache_control markers when it's
219
+ // long enough to be worth caching. The proxy passes the body
220
+ // through to Anthropic, which accepts both string and array forms.
221
+ // Authoring turns (which ship the ~5 KiB manifest/widget/capability
222
+ // contract) hit this path; one-shot turns fall under the threshold
223
+ // and pass through as plain string.
224
+ const requestBody = JSON.stringify({
225
+ messages,
226
+ model,
227
+ system: buildCacheableSystem(system),
228
+ });
217
229
  const fetchChat = async (url: string) => {
218
230
  const controller = new AbortController();
219
231
  const timeoutId = setTimeout(() => controller.abort(new Error('Chat request timed out. Please try again.')), STREAM_REQUEST_TIMEOUT_MS);
@@ -343,7 +355,10 @@ export async function streamChat(options: StreamOptions): Promise<void> {
343
355
 
344
356
  const ok = await readSseStream(response.body, signal, (data) => {
345
357
  const parsed = JSON.parse(data) as {
346
- __ifcLiteUsage?: UsageInfo;
358
+ __ifcLiteUsage?: UsageInfo & {
359
+ cache_creation_input_tokens?: number;
360
+ cache_read_input_tokens?: number;
361
+ };
347
362
  choices?: Array<{
348
363
  delta?: { content?: string };
349
364
  finish_reason?: string | null;
@@ -353,6 +368,9 @@ export async function streamChat(options: StreamOptions): Promise<void> {
353
368
  // Final usage update emitted by proxy after stream-end reconciliation.
354
369
  if (parsed.__ifcLiteUsage && onUsageInfo) {
355
370
  onUsageInfo(parsed.__ifcLiteUsage);
371
+ // Surface cache hit/miss numbers under the same logger as the
372
+ // direct path; observability stays consistent across both flows.
373
+ logCacheHit(parsed.__ifcLiteUsage);
356
374
  return;
357
375
  }
358
376
 
@@ -16,6 +16,7 @@
16
16
  import Anthropic from '@anthropic-ai/sdk';
17
17
  import { readSseStream, type StreamMessage, type StreamOptions } from './stream-client.js';
18
18
  import { getModelById } from './models.js';
19
+ import { buildCacheableSystem, logCacheHit } from './prompt-cache.js';
19
20
 
20
21
  const STREAM_REQUEST_TIMEOUT_MS = 45_000;
21
22
 
@@ -85,7 +86,12 @@ export async function streamAnthropicChat(
85
86
  model,
86
87
  max_tokens: 8192,
87
88
  ...(sendSamplingParams ? { temperature: 0.3 } : {}),
88
- system: system || undefined,
89
+ // Wrap the system prompt in an ephemeral cache block when it's
90
+ // long enough to be worth caching (Anthropic's minimum is ~1024
91
+ // tokens, ≈ 4 KiB). Authoring turns ship the manifest schema +
92
+ // widget DSL contract which is well over the threshold; one-shot
93
+ // turns fall under it and pass through as plain string.
94
+ system: buildCacheableSystem(system),
89
95
  messages: toAnthropicMessages(messages),
90
96
  });
91
97
 
@@ -105,6 +111,10 @@ export async function streamAnthropicChat(
105
111
 
106
112
  if (signal?.aborted) return;
107
113
 
114
+ // Surface cache hit/miss numbers in dev tools so we can see
115
+ // whether the authoring contract is paying off.
116
+ logCacheHit(finalMessage.usage as { cache_creation_input_tokens?: number; cache_read_input_tokens?: number });
117
+
108
118
  const stopReason = finalMessage.stop_reason;
109
119
  onFinishReason?.(stopReason === 'end_turn' ? 'stop' : stopReason);
110
120
  onComplete(fullText);
@@ -19,6 +19,7 @@ import {
19
19
  import type { FileAttachment } from './types.js';
20
20
  import type { ScriptEditorSelection } from './types.js';
21
21
  import { formatDiagnosticsForPrompt, type ScriptDiagnostic } from './script-diagnostics.js';
22
+ import { buildAuthoringContract } from '@ifc-lite/extensions';
22
23
 
23
24
  const MAX_ATTACHMENT_ROWS_IN_PROMPT = 5;
24
25
  const MAX_ATTACHMENT_TEXT_PREVIEW_CHARS = 1200;
@@ -54,6 +55,21 @@ export interface ScriptEditorPromptContext {
54
55
  export interface PromptTaskContext {
55
56
  userPrompt?: string;
56
57
  diagnostics?: ScriptDiagnostic[];
58
+ /**
59
+ * Personal prompt overlay from the active flavor (RFC §06.4). When
60
+ * present, it's appended at the very end of the system prompt
61
+ * inside a clearly-delimited block so we can cache the everything-
62
+ * else portion across users and only invalidate the tail.
63
+ */
64
+ personalOverlay?: string;
65
+ /**
66
+ * When set, append the AI authoring contract from
67
+ * `@ifc-lite/extensions` (manifest schema + widget DSL + capability
68
+ * catalogue + style rules). Used when the chat classifier flags an
69
+ * authoring intent. Cached separately so non-authoring turns don't
70
+ * pay for the extra tokens.
71
+ */
72
+ includeAuthoringContract?: boolean;
57
73
  }
58
74
 
59
75
  interface NamespacedMethod {
@@ -350,6 +366,13 @@ export function buildSystemPrompt(
350
366
  let prompt = `You are an IFC/BIM scripting assistant embedded in ifc-lite, a web-based IFC viewer with a live 3D viewport.
351
367
  You write JavaScript code that executes in a sandboxed environment with a global \`bim\` object.
352
368
 
369
+ ## SANDBOX CONSTRAINTS (read first)
370
+ Scripts run inside a QuickJS-WASM sandbox, NOT in a browser context.
371
+ You DO have: \`bim\`, \`console\` (log/info/warn/error).
372
+ You do NOT have: \`document\`, \`window\`, \`navigator\`, \`location\`, \`globalThis.*\`, \`fetch\`, \`XMLHttpRequest\`, \`localStorage\`, \`indexedDB\`, \`setTimeout\`/\`setInterval\`, \`eval\`, \`Function(...)\`, dynamic \`import()\`, ES module \`import\`/\`export\`, or any DOM API.
373
+ For UI side-effects use \`bim.viewer.*\` (colorize, isolate, fly, section). For data use \`bim.query\`, \`bim.properties\`, \`bim.export\`. For chat-attached files use \`bim.files.*\`.
374
+ If a previous attempt referenced \`document\`, \`window\`, or \`fetch\`, rewrite using the sandbox APIs above. The sandbox will reject those globals at runtime with a "not defined" error.
375
+
353
376
  ## YOUR CAPABILITIES
354
377
  - Create complete IFC buildings from scratch (walls, slabs, columns, beams, stairs, roofs)
355
378
  - Query and analyze loaded IFC models
@@ -762,5 +785,24 @@ if (!rows) {
762
785
  prompt += `\n${formatDiagnosticsForPrompt(task.diagnostics)}`;
763
786
  }
764
787
 
788
+ if (task?.includeAuthoringContract) {
789
+ // AI extension authoring contract (RFC §04.5/§11). Deterministic
790
+ // for a given SDK version so a hosted cache layer hits cleanly.
791
+ // Inject the live SDK version so the AI emits a compatible
792
+ // `engines.ifcLiteSdk` range instead of guessing a future major.
793
+ const sdkVersion = typeof __APP_VERSION__ === 'string' && __APP_VERSION__.length > 0
794
+ ? __APP_VERSION__
795
+ : undefined;
796
+ prompt += `\n\n${buildAuthoringContract({ currentSdkVersion: sdkVersion })}`;
797
+ }
798
+
799
+ if (task?.personalOverlay && task.personalOverlay.trim().length > 0) {
800
+ // Personal prompt overlay — durable user preferences captured by
801
+ // the memory loop (RFC §06.4). Cached separately so the rest of
802
+ // the prompt stays cacheable across users.
803
+ prompt += `\n\n## PERSONAL CONTEXT (from the user's flavor)`;
804
+ prompt += `\n${task.personalOverlay.trim()}`;
805
+ }
806
+
765
807
  return prompt;
766
808
  }
@@ -0,0 +1,30 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Safe-mode entry point.
7
+ *
8
+ * The user reaches safe mode by appending `?safe=1` (or `?safe=true`)
9
+ * to the URL. The desktop Shift-launch wiring referenced in the spec
10
+ * is a follow-up — the Tauri side needs to append the same query
11
+ * parameter to the loaded URL when Shift is held; until that lands,
12
+ * desktop users use the same URL flag from the address bar.
13
+ *
14
+ * In safe mode the host:
15
+ * - Skips automatic activation of the currently-active flavor.
16
+ * - Disables installed extensions for the session — they remain on
17
+ * disk but do not load.
18
+ * - Surfaces a banner so the user knows the rest of the UI is
19
+ * deliberately minimal.
20
+ *
21
+ * Spec: docs/architecture/ai-customization/05-flavors-and-sharing.md §6.4.
22
+ */
23
+
24
+ /** Returns true if the current page should boot in safe mode. */
25
+ export function isSafeMode(): boolean {
26
+ if (typeof window === 'undefined') return false;
27
+ const params = new URLSearchParams(window.location.search);
28
+ const v = params.get('safe');
29
+ return v === '1' || v === 'true';
30
+ }
@@ -0,0 +1,103 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * `ExtensionHostProvider` — React context for the viewer's extension
7
+ * host service.
8
+ *
9
+ * Sits inside `<BimProvider>` so it can pull the live `BimContext`
10
+ * out of the existing SDK plumbing. The service is constructed once
11
+ * on mount, initialised lazily (we kick `init()` on the first commit
12
+ * and surface the loaded statuses to listeners), and disposed on
13
+ * unmount — though in practice the viewer lives for the whole tab
14
+ * session so unmount is rare.
15
+ *
16
+ * Components consume the service via `useExtensionHost()` (everything
17
+ * the user can do) or the specialised hooks
18
+ * `useSlotContributions(slot)` and `useInstalledExtensions()`.
19
+ */
20
+
21
+ import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
22
+ import { useBim } from './BimProvider.js';
23
+ import { ExtensionHostService } from '@/services/extensions/host.js';
24
+ import { isSafeMode } from '@/lib/safe-mode';
25
+ import { toast } from '@/components/ui/toast';
26
+
27
+ const ExtensionHostContext = createContext<ExtensionHostService | null>(null);
28
+
29
+ interface ExtensionHostProviderProps {
30
+ children: ReactNode;
31
+ }
32
+
33
+ export function ExtensionHostProvider({ children }: ExtensionHostProviderProps) {
34
+ const bim = useBim();
35
+ // Service identity must be stable across renders so subscribers don't
36
+ // tear themselves down on every commit.
37
+ const service = useMemo(() => new ExtensionHostService({ sdk: bim }), [bim]);
38
+
39
+ const [, forceRender] = useState(0);
40
+ useEffect(() => {
41
+ if (isSafeMode()) {
42
+ // Safe mode: skip auto-activation. Service still constructs so
43
+ // the user can run uninstall / disable / repair from the UI; we
44
+ // just don't fire onStartup or load extension code.
45
+ console.info('[ExtensionHostProvider] safe mode — skipping init().');
46
+ return service.onChange(() => forceRender((n) => n + 1));
47
+ }
48
+ service.init()
49
+ .then((statuses) => {
50
+ // Partial-failure path: init() succeeded overall but one or
51
+ // more extensions failed to load. Surface a single toast that
52
+ // points at the Repair queue — repeated per-extension toasts
53
+ // would be noisy on a cold boot with many extensions.
54
+ const failed = statuses.filter((s) => !s.ok);
55
+ if (failed.length > 0) {
56
+ const label = failed.length === 1
57
+ ? `Extension "${failed[0].id}" failed to load.`
58
+ : `${failed.length} extensions failed to load.`;
59
+ toast.error(`${label} Open the Extensions panel → Repair queue to retry.`);
60
+ }
61
+ })
62
+ .catch((err) => {
63
+ console.error('[ExtensionHostProvider] init failed:', err);
64
+ const message = err instanceof Error ? err.message : String(err);
65
+ toast.error(
66
+ `Extension system failed to start: ${message}. Installed extensions ` +
67
+ `may be unavailable — open the Extensions panel to recover.`,
68
+ );
69
+ });
70
+ return service.onChange(() => forceRender((n) => n + 1));
71
+ }, [service]);
72
+
73
+ useEffect(() => {
74
+ return () => {
75
+ service.dispose().catch((err) => {
76
+ console.error('[ExtensionHostProvider] dispose failed:', err);
77
+ });
78
+ };
79
+ }, [service]);
80
+
81
+ return (
82
+ <ExtensionHostContext.Provider value={service}>
83
+ {children}
84
+ </ExtensionHostContext.Provider>
85
+ );
86
+ }
87
+
88
+ /**
89
+ * Access the extension host service. Throws if used outside
90
+ * `<ExtensionHostProvider>`.
91
+ */
92
+ export function useExtensionHost(): ExtensionHostService {
93
+ const ctx = useContext(ExtensionHostContext);
94
+ if (!ctx) {
95
+ throw new Error('useExtensionHost() must be used within an <ExtensionHostProvider>');
96
+ }
97
+ return ctx;
98
+ }
99
+
100
+ /** Same as useExtensionHost but returns null instead of throwing. Useful for code paths that may or may not be inside the provider. */
101
+ export function useOptionalExtensionHost(): ExtensionHostService | null {
102
+ return useContext(ExtensionHostContext);
103
+ }
@@ -0,0 +1,183 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * `FlavorService` — viewer-side façade for the flavor library.
7
+ *
8
+ * Defaults to the IDB-backed storage adapter so flavors persist across
9
+ * reloads. Tests can pass `InMemoryFlavorStorage` via options. The
10
+ * service owns the active-flavor pointer, list/CRUD, switch logic,
11
+ * and snapshot management.
12
+ *
13
+ * Spec: docs/architecture/ai-customization/05-flavors-and-sharing.md.
14
+ */
15
+
16
+ import {
17
+ DEFAULT_FLAVOR_ID,
18
+ flavorImportedId,
19
+ packFlavor,
20
+ switchFlavor,
21
+ unpackFlavor,
22
+ validateFlavor,
23
+ type Flavor,
24
+ type FlavorExtensionState,
25
+ type FlavorStorage,
26
+ type FlavorSwitcherCallbacks,
27
+ type FlavorSwitchResult,
28
+ type UnpackedFlavor,
29
+ } from '@ifc-lite/extensions';
30
+ import { IdbFlavorStorage } from './idb-flavor-storage.js';
31
+
32
+ export interface FlavorServiceOptions {
33
+ storage?: FlavorStorage;
34
+ /**
35
+ * Optional callback the service fires on every lifecycle event
36
+ * (activate, export, import) so the host can mirror them into the
37
+ * action log for the pattern miner. Content-free: the callback
38
+ * only sees the flavor id, never any flavor data.
39
+ */
40
+ onLifecycle?: (event: 'activate' | 'export' | 'import', id?: string) => void;
41
+ }
42
+
43
+ export class FlavorService {
44
+ private readonly storage: FlavorStorage;
45
+ private readonly onLifecycle?: FlavorServiceOptions['onLifecycle'];
46
+ private listeners = new Set<() => void>();
47
+
48
+ constructor(opts: FlavorServiceOptions = {}) {
49
+ this.storage = opts.storage ?? new IdbFlavorStorage();
50
+ this.onLifecycle = opts.onLifecycle;
51
+ }
52
+
53
+ async list(): Promise<Flavor[]> {
54
+ return this.storage.listFlavors();
55
+ }
56
+
57
+ async getActive(): Promise<Flavor | undefined> {
58
+ const id = await this.storage.getActiveId();
59
+ return id ? this.storage.getFlavor(id) : undefined;
60
+ }
61
+
62
+ async put(flavor: Flavor, reason?: string): Promise<void> {
63
+ await this.storage.putFlavor(flavor, reason);
64
+ this.emit();
65
+ }
66
+
67
+ async delete(id: string): Promise<void> {
68
+ await this.storage.deleteFlavor(id);
69
+ this.emit();
70
+ }
71
+
72
+ async activate(id: string | undefined): Promise<void> {
73
+ await this.storage.setActiveId(id);
74
+ if (id) this.onLifecycle?.('activate', id);
75
+ this.emit();
76
+ }
77
+
78
+ /**
79
+ * Switch to a target flavor, enabling its extension list and
80
+ * disabling anything the prior flavor had that this one doesn't.
81
+ * Callers wire the `callbacks` to their extension loader/runtime.
82
+ */
83
+ async switchTo(
84
+ target: Flavor,
85
+ installed: readonly FlavorExtensionState[],
86
+ callbacks: FlavorSwitcherCallbacks,
87
+ ): Promise<FlavorSwitchResult> {
88
+ const current = await this.getActive();
89
+ const result = await switchFlavor({
90
+ target,
91
+ installed,
92
+ current,
93
+ callbacks,
94
+ });
95
+ // Always emit — even on failure the rollback ran and the in-memory
96
+ // extension state likely moved. The UI needs to refresh either way.
97
+ this.emit();
98
+ return result;
99
+ }
100
+
101
+ /** Serialise the active (or named) flavor to a `.iflv` byte array. */
102
+ async exportFlavor(id?: string, summary?: string): Promise<Uint8Array> {
103
+ const flavorId = id ?? (await this.storage.getActiveId());
104
+ if (!flavorId) throw new Error('No active flavor to export.');
105
+ const flavor = await this.storage.getFlavor(flavorId);
106
+ if (!flavor) throw new Error(`Unknown flavor: ${flavorId}`);
107
+ const bytes = packFlavor(flavor, { summary });
108
+ this.onLifecycle?.('export', flavorId);
109
+ return bytes;
110
+ }
111
+
112
+ /** Parse + validate a `.iflv` byte array. Does NOT install. */
113
+ async preview(bytes: Uint8Array): Promise<UnpackedFlavor> {
114
+ const result = unpackFlavor(bytes);
115
+ if (!result.ok) {
116
+ throw new Error(
117
+ `Flavor did not unpack: ${result.errors[0]?.message ?? 'unknown error'}`,
118
+ );
119
+ }
120
+ return result.value;
121
+ }
122
+
123
+ /** Save a previewed flavor, optionally as a new id. */
124
+ async importFlavor(
125
+ unpacked: UnpackedFlavor,
126
+ opts: { strategy?: 'replace' | 'save-as-new'; newId?: string } = {},
127
+ ): Promise<Flavor> {
128
+ const validated = validateFlavor(unpacked.flavor);
129
+ if (!validated.ok) {
130
+ throw new Error(
131
+ `Imported flavor did not validate: ${validated.errors[0]?.message ?? 'unknown'}`,
132
+ );
133
+ }
134
+ let flavor = validated.value;
135
+ if (opts.strategy === 'save-as-new') {
136
+ flavor = {
137
+ ...flavor,
138
+ id: opts.newId ?? flavorImportedId(flavor.id),
139
+ updatedAt: new Date().toISOString(),
140
+ };
141
+ }
142
+ await this.storage.putFlavor(flavor, 'imported');
143
+ this.onLifecycle?.('import', flavor.id);
144
+ this.emit();
145
+ return flavor;
146
+ }
147
+
148
+ /** Reset to a clean baseline flavor. */
149
+ async resetToDefaults(): Promise<Flavor> {
150
+ const id = DEFAULT_FLAVOR_ID;
151
+ const now = new Date().toISOString();
152
+ const flavor: Flavor = {
153
+ schemaVersion: 1,
154
+ id,
155
+ name: 'Default',
156
+ description: 'Baseline flavor — no extensions, no overrides.',
157
+ createdAt: now,
158
+ updatedAt: now,
159
+ extensions: [],
160
+ lenses: [],
161
+ savedQueries: [],
162
+ keybindings: [],
163
+ layout: { state: {} },
164
+ settings: {},
165
+ };
166
+ await this.storage.putFlavor(flavor, 'reset to defaults');
167
+ await this.storage.setActiveId(id);
168
+ this.emit();
169
+ return flavor;
170
+ }
171
+
172
+ /** Subscribe to flavor changes. Returns unsubscribe. */
173
+ onChange(listener: () => void): () => void {
174
+ this.listeners.add(listener);
175
+ return () => {
176
+ this.listeners.delete(listener);
177
+ };
178
+ }
179
+
180
+ private emit(): void {
181
+ for (const l of this.listeners) l();
182
+ }
183
+ }