@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.
- package/.turbo/turbo-build.log +34 -31
- package/CHANGELOG.md +96 -0
- package/dist/assets/{basketViewActivator-Dn_bHUl2.js → basketViewActivator-CU8_toGq.js} +7 -7
- package/dist/assets/{bcf-B9SFl84i.js → bcf-DXGDhw56.js} +23 -23
- package/dist/assets/{deflate-yMpdCIqk.js → deflate-Bb1_H2Yf.js} +1 -1
- package/dist/assets/{exporters-D-BvrNIg.js → exporters-DZhLN0ux.js} +1861 -1658
- package/dist/assets/geometry-controller.worker-DQOSYqtw.js +7 -0
- package/dist/assets/geometry.worker-B62e03Ao.js +1 -0
- package/dist/assets/{geotiff-D1tvcDCb.js → geotiff-y0ZxbRJd.js} +10 -10
- package/dist/assets/{ids-DZLs0snJ.js → ids-DruUNtfD.js} +4 -4
- package/dist/assets/ifc-lite-Ch2T9pP9.js +7 -0
- package/dist/assets/{ifc-lite_bg-DyHX37GQ.wasm → ifc-lite_bg-D7O1WHgP.wasm} +0 -0
- package/dist/assets/{ifc-lite_bg-BIryVCXQ.wasm → ifc-lite_bg-iH_07wf8.wasm} +0 -0
- package/dist/assets/index-Bws3UAkj.css +1 -0
- package/dist/assets/{index-CXSBhkcJ.js → index-Dr88ZlSY.js} +64100 -47030
- package/dist/assets/{jpeg-DUMcZp24.js → jpeg-B3_loqFe.js} +1 -1
- package/dist/assets/lens-PYsLu_MA.js +1 -0
- package/dist/assets/{lerc-IN4uWojP.js → lerc-nkwS8ZUe.js} +1 -1
- package/dist/assets/{lzw-Cnw0hH-m.js → lzw-D3cW5Wpg.js} +1 -1
- package/dist/assets/{native-bridge-BVf2uzoH.js → native-bridge-BcYJooq8.js} +2 -2
- package/dist/assets/{packbits-BskJCwk0.js → packbits-DDN4xzB5.js} +1 -1
- package/dist/assets/{parser.worker-BdtkkaGf.js → parser.worker-BW1IMUed.js} +3 -3
- package/dist/assets/raw-CoIXstQ-.js +1 -0
- package/dist/assets/{sandbox-VLI_y7cl.js → sandbox-DETNEyQb.js} +498 -470
- package/dist/assets/{server-client-BLcKaWQB.js → server-client-CmzJOeS7.js} +1 -1
- package/dist/assets/{wasm-bridge-BAfZh7YT.js → wasm-bridge-CT7mK9W0.js} +1 -1
- package/dist/assets/{webimage-Db2xzze3.js → webimage-CBjgg4up.js} +1 -1
- package/dist/assets/{workerHelpers--sAYm9yN.js → workerHelpers-IEQDo8r3.js} +1 -1
- package/dist/assets/{zstd-BDToOQyD.js → zstd-C8oQ6qdS.js} +1 -1
- package/dist/index.html +8 -8
- package/package.json +11 -9
- package/src/App.tsx +5 -2
- package/src/components/extensions/AuditLogPanel.tsx +259 -0
- package/src/components/extensions/BundlePreview.tsx +102 -0
- package/src/components/extensions/CapabilityReview.tsx +333 -0
- package/src/components/extensions/ExtensionDockHost.tsx +192 -0
- package/src/components/extensions/ExtensionToolbarSlot.tsx +106 -0
- package/src/components/extensions/ExtensionsPanel.tsx +481 -0
- package/src/components/extensions/FlavorDialog.tsx +398 -0
- package/src/components/extensions/FlavorImportPreview.tsx +79 -0
- package/src/components/extensions/FlavorIndicator.tsx +81 -0
- package/src/components/extensions/FlavorListView.tsx +318 -0
- package/src/components/extensions/FlavorMergeDialog.tsx +326 -0
- package/src/components/extensions/HelpHint.tsx +182 -0
- package/src/components/extensions/IdeasPanel.tsx +344 -0
- package/src/components/extensions/PlanCard.tsx +227 -0
- package/src/components/extensions/PrivacyPanel.tsx +312 -0
- package/src/components/extensions/PromoteToolDialog.tsx +313 -0
- package/src/components/extensions/RepairQueuePanel.tsx +222 -0
- package/src/components/extensions/icon-registry.ts +92 -0
- package/src/components/extensions/toast-helpers.ts +49 -0
- package/src/components/extensions/widget/WidgetErrorBoundary.tsx +62 -0
- package/src/components/extensions/widget/WidgetRenderer.tsx +428 -0
- package/src/components/viewer/ChatPanel.tsx +251 -3
- package/src/components/viewer/CommandPalette.tsx +74 -4
- package/src/components/viewer/Drawing2DCanvas.tsx +178 -1
- package/src/components/viewer/EntityContextMenu.tsx +70 -0
- package/src/components/viewer/ExportDialog.tsx +9 -1
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +21 -6
- package/src/components/viewer/LensPanel.tsx +50 -0
- package/src/components/viewer/MainToolbar.tsx +170 -87
- package/src/components/viewer/ScriptPanel.tsx +105 -1
- package/src/components/viewer/Section2DPanel.tsx +58 -2
- package/src/components/viewer/StatusBar.tsx +18 -0
- package/src/components/viewer/ViewerLayout.tsx +53 -4
- package/src/components/viewer/Viewport.tsx +72 -0
- package/src/hooks/useActionLogger.test.ts +161 -0
- package/src/hooks/useActionLogger.ts +141 -0
- package/src/hooks/useForkExtension.ts +51 -0
- package/src/hooks/useIfcFederation.ts +7 -1
- package/src/hooks/useInstalledExtensions.ts +43 -0
- package/src/hooks/usePrivacyDisclosure.ts +48 -0
- package/src/hooks/useRunExtensionTests.ts +67 -0
- package/src/hooks/useSlotContributions.ts +38 -0
- package/src/hooks/useSymbolicAnnotations.test.ts +124 -0
- package/src/hooks/useSymbolicAnnotations.ts +776 -0
- package/src/lib/desktop-product.ts +7 -1
- package/src/lib/lens/adapter.ts +14 -0
- package/src/lib/llm/prompt-cache.ts +77 -0
- package/src/lib/llm/stream-client.ts +20 -2
- package/src/lib/llm/stream-direct.ts +11 -1
- package/src/lib/llm/system-prompt.ts +42 -0
- package/src/lib/safe-mode.ts +30 -0
- package/src/sdk/ExtensionHostProvider.tsx +103 -0
- package/src/services/extensions/flavor-service.ts +183 -0
- package/src/services/extensions/host-commands.ts +112 -0
- package/src/services/extensions/host-installer.ts +289 -0
- package/src/services/extensions/host.ts +514 -0
- package/src/services/extensions/idb-flavor-storage.test.ts +140 -0
- package/src/services/extensions/idb-flavor-storage.ts +241 -0
- package/src/services/extensions/idb-log-storage.test.ts +110 -0
- package/src/services/extensions/idb-log-storage.ts +171 -0
- package/src/services/extensions/idb-storage.ts +228 -0
- package/src/services/extensions/runtime-errors.ts +26 -0
- package/src/services/extensions/sandbox-factory.ts +217 -0
- package/src/store/constants.ts +48 -6
- package/src/store/index.ts +6 -1
- package/src/store/slices/drawing2DSlice.ts +8 -0
- package/src/store/slices/extensionsSlice.ts +90 -0
- package/src/store/slices/lensSlice.ts +28 -0
- package/src/store/slices/visibilitySlice.test.ts +6 -0
- package/src/store/slices/visibilitySlice.ts +17 -8
- package/src/store/types.ts +2 -0
- package/dist/assets/geometry-controller.worker-Cm5pvyR6.js +0 -7
- package/dist/assets/geometry.worker-ClNvXIrj.js +0 -1
- package/dist/assets/ifc-lite-BDg0iIbj.js +0 -7
- package/dist/assets/index-DS_xJQfP.css +0 -1
- package/dist/assets/lens-CpjUdqpw.js +0 -1
- 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 {
|
package/src/lib/lens/adapter.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|