@ifc-lite/viewer 1.14.1 → 1.14.3

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 (90) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/assets/{Arrow.dom-HLSMJR_v.js → Arrow.dom-BgkZDIQm.js} +1 -1
  3. package/dist/assets/basketViewActivator-h_M3YbMW.js +1 -0
  4. package/dist/assets/{browser-Ch0OnmZN.js → browser-CRQ0bPh1.js} +1 -1
  5. package/dist/assets/ifc-lite_bg-DyBKoGgk.wasm +0 -0
  6. package/dist/assets/{index-DJbbSLF9.js → index-Be6XjVeM.js} +94767 -83465
  7. package/dist/assets/index-C4VVJRL-.js +229 -0
  8. package/dist/assets/index-DdwD4c-E.css +1 -0
  9. package/dist/assets/{native-bridge-BzC7HkDs.js → native-bridge-DtcJqlOi.js} +1 -1
  10. package/dist/assets/{wasm-bridge-B_7dPwOa.js → wasm-bridge-BJJVu9P2.js} +1 -1
  11. package/dist/index.html +2 -2
  12. package/package.json +21 -20
  13. package/src/App.tsx +17 -1
  14. package/src/components/viewer/AxisHelper.tsx +57 -2
  15. package/src/components/viewer/BasketPresentationDock.tsx +8 -4
  16. package/src/components/viewer/BulkPropertyEditor.tsx +3 -14
  17. package/src/components/viewer/ChatPanel.tsx +1402 -0
  18. package/src/components/viewer/CodeEditor.tsx +70 -4
  19. package/src/components/viewer/DataConnector.tsx +3 -14
  20. package/src/components/viewer/ExportChangesButton.tsx +3 -14
  21. package/src/components/viewer/ExportDialog.tsx +3 -14
  22. package/src/components/viewer/HierarchyPanel.tsx +68 -11
  23. package/src/components/viewer/MainToolbar.tsx +1 -1
  24. package/src/components/viewer/PropertiesPanel.tsx +245 -77
  25. package/src/components/viewer/PropertyEditor.tsx +80 -18
  26. package/src/components/viewer/ScriptPanel.tsx +351 -184
  27. package/src/components/viewer/UpgradePage.tsx +69 -0
  28. package/src/components/viewer/ViewerLayout.tsx +2 -2
  29. package/src/components/viewer/Viewport.tsx +23 -0
  30. package/src/components/viewer/ViewportOverlays.tsx +7 -6
  31. package/src/components/viewer/chat/ChatMessage.tsx +144 -0
  32. package/src/components/viewer/chat/ExecutableCodeBlock.tsx +416 -0
  33. package/src/components/viewer/chat/ModelSelector.tsx +102 -0
  34. package/src/components/viewer/chat/renderTextContent.test.ts +23 -0
  35. package/src/components/viewer/chat/renderTextContent.ts +19 -0
  36. package/src/components/viewer/hierarchy/HierarchyNode.tsx +1 -0
  37. package/src/components/viewer/hierarchy/treeDataBuilder.ts +153 -1
  38. package/src/components/viewer/hierarchy/types.ts +3 -0
  39. package/src/components/viewer/hierarchy/useHierarchyTree.ts +7 -3
  40. package/src/components/viewer/properties/PropertySetCard.tsx +20 -4
  41. package/src/components/viewer/properties/encodingUtils.ts +2 -0
  42. package/src/hooks/useIfcCache.ts +1 -2
  43. package/src/hooks/useSandbox.ts +122 -6
  44. package/src/index.css +106 -0
  45. package/src/lib/attachments.ts +46 -0
  46. package/src/lib/llm/ClerkChatSync.tsx +74 -0
  47. package/src/lib/llm/clerk-auth.ts +62 -0
  48. package/src/lib/llm/code-extractor.ts +50 -0
  49. package/src/lib/llm/context-builder.test.ts +18 -0
  50. package/src/lib/llm/context-builder.ts +305 -0
  51. package/src/lib/llm/free-models.test.ts +118 -0
  52. package/src/lib/llm/message-capabilities.test.ts +131 -0
  53. package/src/lib/llm/message-capabilities.ts +94 -0
  54. package/src/lib/llm/models.ts +197 -0
  55. package/src/lib/llm/repair-loop.test.ts +91 -0
  56. package/src/lib/llm/repair-loop.ts +76 -0
  57. package/src/lib/llm/script-diagnostics.ts +445 -0
  58. package/src/lib/llm/script-edit-ops.test.ts +399 -0
  59. package/src/lib/llm/script-edit-ops.ts +954 -0
  60. package/src/lib/llm/script-preflight.test.ts +513 -0
  61. package/src/lib/llm/script-preflight.ts +990 -0
  62. package/src/lib/llm/script-preservation.test.ts +128 -0
  63. package/src/lib/llm/script-preservation.ts +152 -0
  64. package/src/lib/llm/stream-client.test.ts +97 -0
  65. package/src/lib/llm/stream-client.ts +410 -0
  66. package/src/lib/llm/system-prompt.test.ts +181 -0
  67. package/src/lib/llm/system-prompt.ts +665 -0
  68. package/src/lib/llm/types.ts +150 -0
  69. package/src/lib/scripts/templates/bim-globals.d.ts +226 -7
  70. package/src/lib/scripts/templates/create-building.ts +12 -12
  71. package/src/main.tsx +10 -1
  72. package/src/sdk/adapters/export-adapter.test.ts +24 -0
  73. package/src/sdk/adapters/export-adapter.ts +40 -16
  74. package/src/sdk/adapters/files-adapter.ts +39 -0
  75. package/src/sdk/adapters/model-compat.ts +1 -1
  76. package/src/sdk/adapters/mutate-adapter.ts +20 -6
  77. package/src/sdk/adapters/mutation-view.ts +112 -0
  78. package/src/sdk/adapters/query-adapter.ts +100 -4
  79. package/src/sdk/local-backend.ts +4 -0
  80. package/src/store/index.ts +15 -1
  81. package/src/store/slices/chatSlice.test.ts +325 -0
  82. package/src/store/slices/chatSlice.ts +468 -0
  83. package/src/store/slices/scriptSlice.test.ts +75 -0
  84. package/src/store/slices/scriptSlice.ts +256 -9
  85. package/src/utils/configureMutationView.ts +37 -0
  86. package/src/vite-env.d.ts +10 -0
  87. package/vite.config.ts +21 -2
  88. package/dist/assets/ifc-lite_bg-BOvNXJA_.wasm +0 -0
  89. package/dist/assets/index-JPFMj8C9.js +0 -216
  90. package/dist/assets/index-Qp8stcGO.css +0 -1
@@ -0,0 +1,74 @@
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
+ import { useEffect } from 'react';
6
+ import { useAuth } from '@clerk/clerk-react';
7
+ import { useViewerStore } from '@/store';
8
+
9
+ /**
10
+ * Sync Clerk session state into chat store for authenticated LLM requests.
11
+ * Keeps chat UX decoupled from auth/billing details.
12
+ */
13
+ export function ClerkChatSync() {
14
+ const { isLoaded, isSignedIn, userId, getToken, has } = useAuth();
15
+ const setChatAuthToken = useViewerStore((s) => s.setChatAuthToken);
16
+ const switchChatUserContext = useViewerStore((s) => s.switchChatUserContext);
17
+ const currentChatUserId = useViewerStore((s) => s.chatStorageUserId);
18
+ const currentChatHasPro = useViewerStore((s) => s.chatHasPro);
19
+
20
+ useEffect(() => {
21
+ if (!isLoaded) return;
22
+
23
+ if (!isSignedIn) {
24
+ switchChatUserContext(null, false, {
25
+ clearPersistedCurrent: currentChatUserId !== null,
26
+ restoreMessages: false,
27
+ });
28
+ setChatAuthToken(null);
29
+ return;
30
+ }
31
+
32
+ let cancelled = false;
33
+
34
+ const syncAuth = async () => {
35
+ try {
36
+ const token = await getToken({ skipCache: true });
37
+ const proPlan = has?.({ plan: 'pro' }) ?? false;
38
+ const proFeature = has?.({ feature: 'pro_models' }) ?? false;
39
+ const nextHasPro = proPlan || proFeature;
40
+ if (!cancelled) {
41
+ // Avoid resetting chat usage/messages on routine token refreshes for
42
+ // the same signed-in user. Only switch context when identity or
43
+ // entitlement actually changes.
44
+ if (currentChatUserId !== (userId ?? null) || currentChatHasPro !== nextHasPro) {
45
+ switchChatUserContext(userId ?? null, nextHasPro, {
46
+ clearPersistedCurrent: currentChatUserId !== null && currentChatUserId !== userId,
47
+ restoreMessages: true,
48
+ });
49
+ }
50
+ if (token) {
51
+ setChatAuthToken(token);
52
+ }
53
+ }
54
+ } catch {
55
+ if (!cancelled) {
56
+ // Preserve the current signed-in chat context on transient token
57
+ // refresh failures. Explicit sign-out is handled above.
58
+ }
59
+ }
60
+ };
61
+
62
+ void syncAuth();
63
+ // Keep short-lived JWTs fresh so chat/usage polling doesn't reuse expired tokens.
64
+ const timer = window.setInterval(() => {
65
+ void syncAuth();
66
+ }, 15_000);
67
+ return () => {
68
+ cancelled = true;
69
+ window.clearInterval(timer);
70
+ };
71
+ }, [currentChatHasPro, currentChatUserId, getToken, has, isLoaded, isSignedIn, setChatAuthToken, switchChatUserContext, userId]);
72
+
73
+ return null;
74
+ }
@@ -0,0 +1,62 @@
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
+ * Auth helpers for the LLM chat integration.
7
+ *
8
+ * Setup:
9
+ * 1. Install: pnpm add @clerk/clerk-react
10
+ * 2. Set VITE_CLERK_PUBLISHABLE_KEY in .env
11
+ * 3. Create plans in dashboard:
12
+ * - Free plan (slug: 'free', features: ['llm_chat', 'free_models'])
13
+ * - Pro plan (slug: 'pro', $8/month, features: ['llm_chat', 'free_models', 'pro_models'])
14
+ * 4. Wrap app with <ClerkProvider>
15
+ *
16
+ * Usage in components:
17
+ *
18
+ * ```tsx
19
+ * import { useAuth, useUser, Protect } from '@clerk/clerk-react';
20
+ *
21
+ * const { has } = useAuth();
22
+ * const hasPro = has?.({ feature: 'pro_models' }) ?? false;
23
+ *
24
+ * const { getToken } = useAuth();
25
+ * const token = await getToken();
26
+ * ```
27
+ */
28
+
29
+ /**
30
+ * Subscription tiers and their features.
31
+ */
32
+ export const SUBSCRIPTION_PLANS = {
33
+ free: {
34
+ slug: 'free',
35
+ name: 'Free',
36
+ features: ['llm_chat', 'free_models'],
37
+ description: 'AI chat with free models',
38
+ },
39
+ pro: {
40
+ slug: 'pro',
41
+ name: 'Pro',
42
+ features: ['llm_chat', 'free_models', 'pro_models'],
43
+ description: 'All models with monthly credits ($8/month)',
44
+ },
45
+ } as const;
46
+
47
+ /**
48
+ * Feature flags that map to plan features.
49
+ */
50
+ export const FEATURES = {
51
+ LLM_CHAT: 'llm_chat',
52
+ FREE_MODELS: 'free_models',
53
+ PRO_MODELS: 'pro_models',
54
+ } as const;
55
+
56
+ /**
57
+ * Check if auth is configured (publishable key present).
58
+ * When not configured, the chat works in anonymous free-tier mode.
59
+ */
60
+ export function isClerkConfigured(): boolean {
61
+ return Boolean(import.meta.env.VITE_CLERK_PUBLISHABLE_KEY);
62
+ }
@@ -0,0 +1,50 @@
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
+ * Extract executable code blocks from LLM markdown responses.
7
+ */
8
+
9
+ import type { CodeBlock } from './types.js';
10
+
11
+ /**
12
+ * Parse fenced code blocks from a markdown string.
13
+ * Supports ```js, ```javascript, ```typescript, ```ts, and bare ``` blocks.
14
+ */
15
+ export function extractCodeBlocks(markdown: string): CodeBlock[] {
16
+ const blocks: CodeBlock[] = [];
17
+ // Match ```lang\n...code...\n```
18
+ const regex = /```(\w*)\n([\s\S]*?)```/g;
19
+ let match: RegExpExecArray | null;
20
+ let index = 0;
21
+
22
+ while ((match = regex.exec(markdown)) !== null) {
23
+ const language = match[1] || 'js';
24
+ const code = match[2].trim();
25
+
26
+ // Only extract JS/TS code blocks (skip html, css, json, etc. unless they look like scripts)
27
+ const isExecutable = ['js', 'javascript', 'ts', 'typescript', ''].includes(language.toLowerCase());
28
+ // Also include unlabeled blocks that reference `bim.`
29
+ const referencesBim = code.includes('bim.');
30
+
31
+ if (isExecutable || referencesBim) {
32
+ blocks.push({ index, language, code });
33
+ index++;
34
+ }
35
+ }
36
+
37
+ return blocks;
38
+ }
39
+
40
+ /**
41
+ * Inject CSV data into a script as a `const DATA = [...]` declaration.
42
+ * Prepends the data array before the LLM-generated script body.
43
+ */
44
+ export function injectCsvData(
45
+ script: string,
46
+ data: Record<string, string>[],
47
+ ): string {
48
+ const dataDeclaration = `const DATA = ${JSON.stringify(data)};\n\n`;
49
+ return dataDeclaration + script;
50
+ }
@@ -0,0 +1,18 @@
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
+ import test from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { parseCSV } from './context-builder.js';
8
+
9
+ test('parseCSV preserves embedded newlines inside quoted fields', () => {
10
+ const csv = 'Name,Notes\n"Lobby","Line 1\nLine 2"\n"Office","Single line"';
11
+
12
+ const parsed = parseCSV(csv);
13
+
14
+ assert.deepEqual(parsed.columns, ['Name', 'Notes']);
15
+ assert.equal(parsed.rows.length, 2);
16
+ assert.equal(parsed.rows[0]?.Notes, 'Line 1\nLine 2');
17
+ assert.equal(parsed.rows[1]?.Notes, 'Single line');
18
+ });
@@ -0,0 +1,305 @@
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
+ * Build model context from the current viewer state.
7
+ * This context is injected into the system prompt so the LLM
8
+ * knows what's currently loaded in the 3D viewer.
9
+ */
10
+
11
+ import { useViewerStore } from '@/store';
12
+ import type { ModelContext } from './system-prompt.js';
13
+ import { IfcTypeEnum, type SpatialNode, type SpatialHierarchy } from '@ifc-lite/data';
14
+ import {
15
+ extractClassificationsOnDemand,
16
+ extractMaterialsOnDemand,
17
+ extractPropertiesOnDemand,
18
+ extractQuantitiesOnDemand,
19
+ extractTypeEntityOwnProperties,
20
+ extractTypePropertiesOnDemand,
21
+ } from '@ifc-lite/parser';
22
+ import { resolveEntityRef } from '@/store/resolveEntityRef';
23
+
24
+ let cachedTypeCountsFingerprint = '';
25
+ let cachedTypeCounts: Record<string, number> = {};
26
+
27
+ function buildFingerprint(state: ReturnType<typeof useViewerStore.getState>): string {
28
+ if (state.models.size > 0) {
29
+ const items: string[] = [];
30
+ for (const [id, model] of state.models) {
31
+ const count = model.ifcDataStore?.entities.count ?? 0;
32
+ items.push(`${id}:${count}`);
33
+ }
34
+ items.sort();
35
+ return `federated|${items.join('|')}`;
36
+ }
37
+
38
+ const legacyCount = state.ifcDataStore?.entities.count ?? 0;
39
+ return `legacy|${legacyCount}`;
40
+ }
41
+
42
+ function computeTypeCounts(state: ReturnType<typeof useViewerStore.getState>): Record<string, number> {
43
+ const typeCounts: Record<string, number> = {};
44
+
45
+ if (state.models.size > 0) {
46
+ for (const [, model] of state.models) {
47
+ const store = model.ifcDataStore;
48
+ if (!store) continue;
49
+ for (let i = 0; i < store.entities.count; i++) {
50
+ const id = store.entities.expressId[i];
51
+ const type = store.entities.getTypeName(id);
52
+ if (type) typeCounts[type] = (typeCounts[type] ?? 0) + 1;
53
+ }
54
+ }
55
+ return typeCounts;
56
+ }
57
+
58
+ if (state.ifcDataStore) {
59
+ const store = state.ifcDataStore;
60
+ for (let i = 0; i < store.entities.count; i++) {
61
+ const id = store.entities.expressId[i];
62
+ const type = store.entities.getTypeName(id);
63
+ if (type) typeCounts[type] = (typeCounts[type] ?? 0) + 1;
64
+ }
65
+ }
66
+
67
+ return typeCounts;
68
+ }
69
+
70
+ function collectStoreys(
71
+ hierarchy: SpatialHierarchy | undefined,
72
+ modelName?: string,
73
+ ): NonNullable<ModelContext['storeys']> {
74
+ if (!hierarchy?.project) return [];
75
+
76
+ const result: NonNullable<ModelContext['storeys']> = [];
77
+ const visit = (node: SpatialNode) => {
78
+ if (node.type === IfcTypeEnum.IfcBuildingStorey) {
79
+ result.push({
80
+ modelName,
81
+ name: node.name || 'Storey',
82
+ elevation: node.elevation ?? hierarchy.storeyElevations.get(node.expressId) ?? 0,
83
+ height: hierarchy.storeyHeights.get(node.expressId),
84
+ elementCount: hierarchy.byStorey.get(node.expressId)?.length ?? node.elements.length,
85
+ });
86
+ }
87
+ for (const child of node.children) visit(child);
88
+ };
89
+
90
+ visit(hierarchy.project);
91
+ result.sort((a, b) => a.elevation - b.elevation);
92
+ return result;
93
+ }
94
+
95
+ function getStoreForModel(
96
+ state: ReturnType<typeof useViewerStore.getState>,
97
+ modelId: string,
98
+ ): { store: NonNullable<typeof state.ifcDataStore> | null; modelName?: string } {
99
+ if (modelId === 'legacy') {
100
+ return { store: state.ifcDataStore, modelName: 'Model' };
101
+ }
102
+ const model = state.models.get(modelId);
103
+ return { store: model?.ifcDataStore ?? null, modelName: model?.name ?? modelId };
104
+ }
105
+
106
+ function uniqueStrings(values: Array<string | undefined>): string[] {
107
+ return Array.from(new Set(values.filter((value): value is string => Boolean(value))));
108
+ }
109
+
110
+ function collectSelectedEntities(state: ReturnType<typeof useViewerStore.getState>): NonNullable<ModelContext['selectedEntities']> {
111
+ const refs = state.selectedEntities.length > 0
112
+ ? state.selectedEntities
113
+ : state.selectedEntity
114
+ ? [state.selectedEntity]
115
+ : state.selectedEntityIds.size > 0
116
+ ? Array.from(state.selectedEntityIds).slice(0, 5).map((id) => resolveEntityRef(id))
117
+ : [];
118
+
119
+ return refs.slice(0, 5).flatMap((ref) => {
120
+ const { store, modelName } = getStoreForModel(state, ref.modelId);
121
+ if (!store) return [];
122
+
123
+ const type = store.entities.getTypeName(ref.expressId) || 'Unknown';
124
+ const name = store.entities.getName(ref.expressId) || `${type} #${ref.expressId}`;
125
+ const storeyId = store.spatialHierarchy?.elementToStorey.get(ref.expressId);
126
+ const storeyName = storeyId !== undefined ? (store.entities.getName(storeyId) || `Storey #${storeyId}`) : undefined;
127
+ const storeyElevation = storeyId !== undefined ? store.spatialHierarchy?.storeyElevations.get(storeyId) : undefined;
128
+
129
+ const rawPsets = extractPropertiesOnDemand(store, ref.expressId) as Array<{ name?: string; Name?: string }> | undefined;
130
+ const rawQsets = extractQuantitiesOnDemand(store, ref.expressId) as Array<{ name?: string; Name?: string }> | undefined;
131
+ const typeOwnPsets = extractTypeEntityOwnProperties(store, ref.expressId);
132
+ const inheritedTypeProps = extractTypePropertiesOnDemand(store, ref.expressId);
133
+ const rawMaterial = extractMaterialsOnDemand(store, ref.expressId);
134
+ const rawClassifications = extractClassificationsOnDemand(store, ref.expressId);
135
+ const instancePropertySets = uniqueStrings((rawPsets ?? []).map((pset) => pset.name ?? pset.Name));
136
+ const ownTypePropertySets = uniqueStrings(typeOwnPsets.map((pset) => pset.name));
137
+ const inheritedTypePropertySets = uniqueStrings((inheritedTypeProps?.properties ?? []).map((pset) => pset.name));
138
+ const selectionKind = ownTypePropertySets.length > 0 ? 'type' : 'occurrence';
139
+ const propertySets = (selectionKind === 'type' ? ownTypePropertySets : instancePropertySets).slice(0, 6);
140
+ const typePropertySets = (selectionKind === 'type' ? [] : inheritedTypePropertySets).slice(0, 6);
141
+ const quantitySets = (rawQsets ?? []).map((qset) => qset.name ?? qset.Name).filter((value): value is string => Boolean(value)).slice(0, 6);
142
+ const materialName = rawMaterial?.name ?? rawMaterial?.materials?.[0];
143
+ const classifications = rawClassifications
144
+ .map((classification) => classification.identification ?? classification.name ?? classification.system)
145
+ .filter((value): value is string => Boolean(value))
146
+ .slice(0, 4);
147
+
148
+ return [{
149
+ modelName,
150
+ name,
151
+ type,
152
+ selectionKind,
153
+ globalId: store.entities.getGlobalId?.(ref.expressId),
154
+ storeyName,
155
+ storeyElevation,
156
+ propertySets,
157
+ typePropertySets,
158
+ quantitySets,
159
+ materialName,
160
+ classifications,
161
+ }];
162
+ });
163
+ }
164
+
165
+ /**
166
+ * Snapshot the current model context from the Zustand store.
167
+ * Called before each LLM request to provide up-to-date context.
168
+ */
169
+ export function getModelContext(): ModelContext {
170
+ const state = useViewerStore.getState();
171
+
172
+ const models: ModelContext['models'] = [];
173
+ const storeys: NonNullable<ModelContext['storeys']> = [];
174
+ const fingerprint = buildFingerprint(state);
175
+
176
+ // Federated models
177
+ if (state.models.size > 0) {
178
+ for (const [, model] of state.models) {
179
+ const entityCount = model.ifcDataStore?.entities.count ?? 0;
180
+ models.push({
181
+ name: model.name ?? 'Unknown',
182
+ entityCount,
183
+ });
184
+ storeys.push(...collectStoreys(model.ifcDataStore?.spatialHierarchy, model.name ?? 'Unknown'));
185
+ }
186
+ }
187
+
188
+ // Legacy single-model path
189
+ if (models.length === 0 && state.ifcDataStore) {
190
+ const store = state.ifcDataStore;
191
+ models.push({
192
+ name: 'Model',
193
+ entityCount: store.entities.count,
194
+ });
195
+ storeys.push(...collectStoreys(store.spatialHierarchy, 'Model'));
196
+ }
197
+
198
+ if (fingerprint !== cachedTypeCountsFingerprint) {
199
+ cachedTypeCounts = computeTypeCounts(state);
200
+ cachedTypeCountsFingerprint = fingerprint;
201
+ }
202
+
203
+ // Selection count
204
+ const selectedCount = state.selectedEntities.length > 0
205
+ ? state.selectedEntities.length
206
+ : state.selectedEntitiesSet.size > 0
207
+ ? state.selectedEntitiesSet.size
208
+ : state.selectedEntity
209
+ ? 1
210
+ : state.selectedEntityIds.size > 0
211
+ ? state.selectedEntityIds.size
212
+ : state.selectedEntityId !== null ? 1 : 0;
213
+ const selectedEntities = collectSelectedEntities(state);
214
+
215
+ return { models, typeCounts: cachedTypeCounts, selectedCount, storeys, selectedEntities };
216
+ }
217
+
218
+ /**
219
+ * Parse a CSV string into an array of row objects.
220
+ * Simple parser that handles quoted fields with commas.
221
+ */
222
+ export function parseCSV(text: string): { columns: string[]; rows: Record<string, string>[] } {
223
+ const records = splitCSVRecords(text).filter((record) => record.trim().length > 0);
224
+ if (records.length === 0) return { columns: [], rows: [] };
225
+
226
+ // Parse header
227
+ const columns = parseCSVLine(records[0]);
228
+
229
+ // Parse rows
230
+ const rows: Record<string, string>[] = [];
231
+ for (let i = 1; i < records.length; i++) {
232
+ const values = parseCSVLine(records[i]);
233
+ const row: Record<string, string> = {};
234
+ for (let j = 0; j < columns.length; j++) {
235
+ row[columns[j]] = values[j] ?? '';
236
+ }
237
+ rows.push(row);
238
+ }
239
+
240
+ return { columns, rows };
241
+ }
242
+
243
+ function splitCSVRecords(text: string): string[] {
244
+ const normalized = text.replace(/\r\n?/g, '\n');
245
+ const records: string[] = [];
246
+ let current = '';
247
+ let inQuotes = false;
248
+
249
+ for (let i = 0; i < normalized.length; i++) {
250
+ const char = normalized[i];
251
+ if (char === '"') {
252
+ if (inQuotes && normalized[i + 1] === '"') {
253
+ current += '""';
254
+ i++;
255
+ continue;
256
+ }
257
+ inQuotes = !inQuotes;
258
+ current += char;
259
+ continue;
260
+ }
261
+ if (char === '\n' && !inQuotes) {
262
+ records.push(current);
263
+ current = '';
264
+ continue;
265
+ }
266
+ current += char;
267
+ }
268
+
269
+ if (current.length > 0) {
270
+ records.push(current);
271
+ }
272
+ return records;
273
+ }
274
+
275
+ /** Parse a single CSV line, handling quoted fields */
276
+ function parseCSVLine(line: string): string[] {
277
+ const fields: string[] = [];
278
+ let current = '';
279
+ let inQuotes = false;
280
+
281
+ for (let i = 0; i < line.length; i++) {
282
+ const char = line[i];
283
+ if (inQuotes) {
284
+ if (char === '"' && line[i + 1] === '"') {
285
+ current += '"';
286
+ i++;
287
+ } else if (char === '"') {
288
+ inQuotes = false;
289
+ } else {
290
+ current += char;
291
+ }
292
+ } else {
293
+ if (char === '"') {
294
+ inQuotes = true;
295
+ } else if (char === ',' || char === ';') {
296
+ fields.push(current.trim());
297
+ current = '';
298
+ } else {
299
+ current += char;
300
+ }
301
+ }
302
+ }
303
+ fields.push(current.trim());
304
+ return fields;
305
+ }
@@ -0,0 +1,118 @@
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
+ import test from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { readFile } from 'node:fs/promises';
8
+ import type { LLMModel } from './types.js';
9
+
10
+ const viewerEnvUrl = new URL('../../../.env.local', import.meta.url);
11
+ const VERIFY_OPENROUTER_MODELS = process.env.IFC_LITE_VERIFY_OPENROUTER_MODELS === '1';
12
+
13
+ function parseEnvValue(envText: string, key: string): string {
14
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
15
+ const match = envText.match(new RegExp(`^${escapedKey}=(.*)$`, 'm'));
16
+ return match?.[1]?.trim() ?? '';
17
+ }
18
+
19
+ function parseCsvList(raw: string): string[] {
20
+ if (!raw) return [];
21
+ return raw
22
+ .split(',')
23
+ .map((value) => value.trim())
24
+ .filter(Boolean);
25
+ }
26
+
27
+ async function readConfiguredFreeModels(): Promise<string[] | null> {
28
+ const envOverride = process.env.VITE_LLM_FREE_MODELS ?? process.env.LLM_FREE_MODELS;
29
+ if (typeof envOverride === 'string' && envOverride.trim().length > 0) {
30
+ return parseCsvList(envOverride);
31
+ }
32
+
33
+ try {
34
+ const envText = await readFile(viewerEnvUrl, 'utf8');
35
+ const configuredFreeModels = parseCsvList(parseEnvValue(envText, 'VITE_LLM_FREE_MODELS'));
36
+ return configuredFreeModels.length > 0 ? configuredFreeModels : null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ test('registry free models match configured env list', async (t) => {
43
+ const configuredFreeModels = await readConfiguredFreeModels();
44
+ if (!configuredFreeModels) {
45
+ t.skip('Viewer LLM env is not configured in this environment.');
46
+ return;
47
+ }
48
+
49
+ process.env.VITE_LLM_FREE_MODELS = configuredFreeModels.join(',');
50
+ process.env.VITE_LLM_PRO_MODELS_LOW = '';
51
+ process.env.VITE_LLM_PRO_MODELS_MEDIUM = '';
52
+ process.env.VITE_LLM_PRO_MODELS_HIGH = '';
53
+ process.env.VITE_LLM_IMAGE_MODELS = '';
54
+ process.env.VITE_LLM_FILE_ATTACHMENT_MODELS = '';
55
+
56
+ const { FREE_MODELS } = await import(`./models.ts?ts=${Date.now()}`) as { FREE_MODELS: LLMModel[] };
57
+ assert.deepEqual(
58
+ FREE_MODELS.map((model) => model.id),
59
+ configuredFreeModels,
60
+ 'FREE_MODELS must follow VITE_LLM_FREE_MODELS order and values',
61
+ );
62
+ });
63
+
64
+ test('model capabilities follow override env lists', async () => {
65
+ process.env.VITE_LLM_FREE_MODELS = 'qwen/qwen3-coder,mistralai/devstral-2512';
66
+ process.env.VITE_LLM_PRO_MODELS_LOW = '';
67
+ process.env.VITE_LLM_PRO_MODELS_MEDIUM = '';
68
+ process.env.VITE_LLM_PRO_MODELS_HIGH = '';
69
+ process.env.VITE_LLM_IMAGE_MODELS = 'mistralai/devstral-2512';
70
+ process.env.VITE_LLM_FILE_ATTACHMENT_MODELS = 'qwen/qwen3-coder';
71
+
72
+ const { ALL_MODELS } = await import(`./models.ts?ts=${Date.now()}`) as { ALL_MODELS: LLMModel[] };
73
+ const qwen = ALL_MODELS.find((m) => m.id === 'qwen/qwen3-coder');
74
+ const devstral = ALL_MODELS.find((m) => m.id === 'mistralai/devstral-2512');
75
+
76
+ assert.ok(qwen, 'Expected qwen model in registry');
77
+ assert.ok(devstral, 'Expected devstral model in registry');
78
+ assert.equal(qwen.supportsImages, false);
79
+ assert.equal(qwen.supportsFileAttachments, true);
80
+ assert.equal(devstral.supportsImages, true);
81
+ assert.equal(devstral.supportsFileAttachments, false);
82
+ });
83
+
84
+ test('each configured free model exists in OpenRouter catalog', async (t) => {
85
+ if (!VERIFY_OPENROUTER_MODELS) {
86
+ t.skip('Set IFC_LITE_VERIFY_OPENROUTER_MODELS=1 to run the live OpenRouter catalog check.');
87
+ return;
88
+ }
89
+
90
+ const configuredFreeModels = await readConfiguredFreeModels();
91
+ if (!configuredFreeModels) {
92
+ t.skip('Viewer LLM env is not configured in this environment.');
93
+ return;
94
+ }
95
+
96
+ const controller = new AbortController();
97
+ const timeout = setTimeout(() => controller.abort(), 10_000);
98
+ try {
99
+ const response = await fetch('https://openrouter.ai/api/v1/models', { signal: controller.signal });
100
+ assert.equal(response.ok, true, `OpenRouter models API failed with HTTP ${response.status}`);
101
+
102
+ const payload = await response.json() as { data?: Array<{ id?: string }> };
103
+ const modelIdSet = new Set(
104
+ Array.isArray(payload.data)
105
+ ? payload.data.map((model) => model.id).filter((id): id is string => typeof id === 'string')
106
+ : [],
107
+ );
108
+
109
+ const missing = configuredFreeModels.filter((id) => !modelIdSet.has(id));
110
+ assert.deepEqual(
111
+ missing,
112
+ [],
113
+ `Configured free model IDs missing from OpenRouter catalog: ${missing.join(', ')}`,
114
+ );
115
+ } finally {
116
+ clearTimeout(timeout);
117
+ }
118
+ });