@ifc-lite/viewer 1.14.2 → 1.14.4

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 (80) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/assets/{Arrow.dom-CSgnLhN4.js → Arrow.dom-_vGzMMKs.js} +1 -1
  3. package/dist/assets/basketViewActivator-BZcoCL3V.js +1 -0
  4. package/dist/assets/{browser-qSKWrKQW.js → browser-Czmf34bo.js} +1 -1
  5. package/dist/assets/ifc-lite_bg-DyBKoGgk.wasm +0 -0
  6. package/dist/assets/index-CMQ_Dgkr.css +1 -0
  7. package/dist/assets/index-D7nEDctQ.js +229 -0
  8. package/dist/assets/{index-4Y4XaV8N.js → index-DX-Qf5fA.js} +72669 -61673
  9. package/dist/assets/{native-bridge-CSFDsEkg.js → native-bridge-DAOWftxE.js} +1 -1
  10. package/dist/assets/{wasm-bridge-Zf90ysEm.js → wasm-bridge-D7jYpn8a.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/BasketPresentationDock.tsx +8 -4
  15. package/src/components/viewer/ChatPanel.tsx +1402 -0
  16. package/src/components/viewer/CodeEditor.tsx +70 -4
  17. package/src/components/viewer/CommandPalette.tsx +1 -0
  18. package/src/components/viewer/HierarchyPanel.tsx +28 -13
  19. package/src/components/viewer/MainToolbar.tsx +113 -95
  20. package/src/components/viewer/ScriptPanel.tsx +351 -184
  21. package/src/components/viewer/UpgradePage.tsx +69 -0
  22. package/src/components/viewer/Viewport.tsx +23 -0
  23. package/src/components/viewer/chat/ChatMessage.tsx +144 -0
  24. package/src/components/viewer/chat/ExecutableCodeBlock.tsx +416 -0
  25. package/src/components/viewer/chat/ModelSelector.tsx +102 -0
  26. package/src/components/viewer/chat/renderTextContent.test.ts +23 -0
  27. package/src/components/viewer/chat/renderTextContent.ts +19 -0
  28. package/src/components/viewer/hierarchy/HierarchyNode.tsx +10 -3
  29. package/src/components/viewer/hierarchy/treeDataBuilder.test.ts +126 -0
  30. package/src/components/viewer/hierarchy/treeDataBuilder.ts +139 -38
  31. package/src/components/viewer/hierarchy/types.ts +6 -1
  32. package/src/components/viewer/hierarchy/useHierarchyTree.ts +27 -12
  33. package/src/hooks/useIfcCache.ts +1 -2
  34. package/src/hooks/useSandbox.ts +122 -6
  35. package/src/index.css +10 -0
  36. package/src/lib/attachments.ts +46 -0
  37. package/src/lib/llm/ClerkChatSync.tsx +74 -0
  38. package/src/lib/llm/clerk-auth.ts +62 -0
  39. package/src/lib/llm/code-extractor.ts +50 -0
  40. package/src/lib/llm/context-builder.test.ts +18 -0
  41. package/src/lib/llm/context-builder.ts +305 -0
  42. package/src/lib/llm/free-models.test.ts +118 -0
  43. package/src/lib/llm/message-capabilities.test.ts +131 -0
  44. package/src/lib/llm/message-capabilities.ts +94 -0
  45. package/src/lib/llm/models.ts +197 -0
  46. package/src/lib/llm/repair-loop.test.ts +91 -0
  47. package/src/lib/llm/repair-loop.ts +76 -0
  48. package/src/lib/llm/script-diagnostics.ts +445 -0
  49. package/src/lib/llm/script-edit-ops.test.ts +399 -0
  50. package/src/lib/llm/script-edit-ops.ts +954 -0
  51. package/src/lib/llm/script-preflight.test.ts +513 -0
  52. package/src/lib/llm/script-preflight.ts +990 -0
  53. package/src/lib/llm/script-preservation.test.ts +128 -0
  54. package/src/lib/llm/script-preservation.ts +152 -0
  55. package/src/lib/llm/stream-client.test.ts +97 -0
  56. package/src/lib/llm/stream-client.ts +410 -0
  57. package/src/lib/llm/system-prompt.test.ts +181 -0
  58. package/src/lib/llm/system-prompt.ts +665 -0
  59. package/src/lib/llm/types.ts +150 -0
  60. package/src/lib/scripts/templates/bim-globals.d.ts +226 -7
  61. package/src/lib/scripts/templates/create-building.ts +12 -12
  62. package/src/main.tsx +10 -1
  63. package/src/sdk/adapters/export-adapter.test.ts +24 -0
  64. package/src/sdk/adapters/export-adapter.ts +40 -16
  65. package/src/sdk/adapters/files-adapter.ts +39 -0
  66. package/src/sdk/adapters/model-compat.ts +1 -1
  67. package/src/sdk/adapters/mutate-adapter.ts +20 -6
  68. package/src/sdk/adapters/mutation-view.ts +112 -0
  69. package/src/sdk/adapters/query-adapter.ts +100 -4
  70. package/src/sdk/local-backend.ts +4 -0
  71. package/src/store/index.ts +15 -1
  72. package/src/store/slices/chatSlice.test.ts +325 -0
  73. package/src/store/slices/chatSlice.ts +468 -0
  74. package/src/store/slices/scriptSlice.test.ts +75 -0
  75. package/src/store/slices/scriptSlice.ts +256 -9
  76. package/src/vite-env.d.ts +10 -0
  77. package/vite.config.ts +21 -2
  78. package/dist/assets/ifc-lite_bg-BOvNXJA_.wasm +0 -0
  79. package/dist/assets/index-ByrFvN5A.css +0 -1
  80. package/dist/assets/index-CN7qDq7G.js +0 -216
@@ -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
+ });
@@ -0,0 +1,131 @@
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 type { ChatMessage, FileAttachment } from './types.js';
8
+ import { buildStreamMessagesForModel, filterAttachmentsForModel } from './message-capabilities.js';
9
+
10
+ function makeImageAttachment(name: string): FileAttachment {
11
+ return {
12
+ id: `img:${name}`,
13
+ name,
14
+ type: 'image/png',
15
+ size: 100,
16
+ imageBase64: 'data:image/png;base64,abc',
17
+ isImage: true,
18
+ };
19
+ }
20
+
21
+ function makeFileAttachment(name: string): FileAttachment {
22
+ return {
23
+ id: `file:${name}`,
24
+ name,
25
+ type: 'text/csv',
26
+ size: 20,
27
+ textContent: 'a,b\n1,2',
28
+ };
29
+ }
30
+
31
+ test('filters attachments by model capabilities', () => {
32
+ const attachments = [makeImageAttachment('img.png'), makeFileAttachment('data.csv')];
33
+ const filtered = filterAttachmentsForModel(attachments, false, true);
34
+
35
+ assert.equal(filtered.accepted.length, 1);
36
+ assert.equal(filtered.accepted[0].name, 'data.csv');
37
+ assert.equal(filtered.droppedImages, 1);
38
+ assert.equal(filtered.droppedFiles, 0);
39
+ });
40
+
41
+ test('buildStreamMessagesForModel drops unsupported image payload parts', () => {
42
+ const messages: ChatMessage[] = [
43
+ {
44
+ id: 'm1',
45
+ role: 'user',
46
+ content: 'Hello',
47
+ createdAt: Date.now(),
48
+ attachments: [makeImageAttachment('img1.png')],
49
+ },
50
+ ];
51
+
52
+ const result = buildStreamMessagesForModel(messages, 'data:image/png;base64,viewport', false);
53
+ assert.equal(result.droppedInlineImages, 1);
54
+ assert.equal(result.droppedViewportScreenshot, true);
55
+ assert.equal(typeof result.messages[0].content, 'string');
56
+ assert.equal(result.messages[0].content, 'Hello');
57
+ });
58
+
59
+ test('buildStreamMessagesForModel keeps image payload parts when supported', () => {
60
+ const messages: ChatMessage[] = [
61
+ {
62
+ id: 'm1',
63
+ role: 'user',
64
+ content: 'Show this',
65
+ createdAt: Date.now(),
66
+ attachments: [makeImageAttachment('img1.png')],
67
+ },
68
+ ];
69
+
70
+ const result = buildStreamMessagesForModel(messages, null, true);
71
+ assert.equal(result.droppedInlineImages, 0);
72
+ assert.equal(result.droppedViewportScreenshot, false);
73
+ assert.ok(Array.isArray(result.messages[0].content));
74
+ const parts = result.messages[0].content as Array<{ type: string }>;
75
+ assert.equal(parts[0].type, 'image_url');
76
+ });
77
+
78
+ test('buildStreamMessagesForModel keeps historical turns text-only', () => {
79
+ const messages: ChatMessage[] = [
80
+ {
81
+ id: 'm0',
82
+ role: 'user',
83
+ content: 'Earlier image',
84
+ createdAt: Date.now() - 1000,
85
+ attachments: [makeImageAttachment('old.png')],
86
+ },
87
+ {
88
+ id: 'm1',
89
+ role: 'user',
90
+ content: 'Latest image',
91
+ createdAt: Date.now(),
92
+ attachments: [makeImageAttachment('new.png')],
93
+ },
94
+ ];
95
+
96
+ const result = buildStreamMessagesForModel(messages, null, true);
97
+ assert.equal(typeof result.messages[0].content, 'string');
98
+ assert.equal(result.messages[0].content, 'Earlier image');
99
+ assert.ok(Array.isArray(result.messages[1].content));
100
+ const latestParts = result.messages[1].content as Array<{ type: string }>;
101
+ assert.equal(latestParts[0].type, 'image_url');
102
+ });
103
+
104
+ test('buildStreamMessagesForModel attaches viewport screenshot only to latest turn', () => {
105
+ const messages: ChatMessage[] = [
106
+ {
107
+ id: 'm0',
108
+ role: 'user',
109
+ content: 'Earlier question',
110
+ createdAt: Date.now() - 1000,
111
+ },
112
+ {
113
+ id: 'm1',
114
+ role: 'user',
115
+ content: 'Current question',
116
+ createdAt: Date.now(),
117
+ },
118
+ ];
119
+
120
+ const result = buildStreamMessagesForModel(messages, 'data:image/jpeg;base64,current', true);
121
+
122
+ // Earlier turn remains plain text.
123
+ assert.equal(typeof result.messages[0].content, 'string');
124
+ assert.equal(result.messages[0].content, 'Earlier question');
125
+
126
+ // Latest turn gets exactly one screenshot image part.
127
+ assert.ok(Array.isArray(result.messages[1].content));
128
+ const parts = result.messages[1].content as Array<{ type: string }>;
129
+ const imageParts = parts.filter((p) => p.type === 'image_url');
130
+ assert.equal(imageParts.length, 1);
131
+ });
@@ -0,0 +1,94 @@
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 type { FileAttachment, ChatMessage } from './types.js';
6
+ import type { StreamMessage, TextContentPart, ImageContentPart } from './stream-client.js';
7
+
8
+ export interface AttachmentFilterResult {
9
+ accepted: FileAttachment[];
10
+ droppedImages: number;
11
+ droppedFiles: number;
12
+ }
13
+
14
+ export interface StreamBuildResult {
15
+ messages: StreamMessage[];
16
+ droppedInlineImages: number;
17
+ droppedViewportScreenshot: boolean;
18
+ }
19
+
20
+ export function filterAttachmentsForModel(
21
+ attachments: FileAttachment[],
22
+ supportsImages: boolean,
23
+ supportsFileAttachments: boolean,
24
+ ): AttachmentFilterResult {
25
+ const accepted: FileAttachment[] = [];
26
+ let droppedImages = 0;
27
+ let droppedFiles = 0;
28
+
29
+ for (const attachment of attachments) {
30
+ if (attachment.isImage && attachment.imageBase64) {
31
+ if (supportsImages) {
32
+ accepted.push(attachment);
33
+ } else {
34
+ droppedImages += 1;
35
+ }
36
+ continue;
37
+ }
38
+
39
+ if (supportsFileAttachments) {
40
+ accepted.push(attachment);
41
+ } else {
42
+ droppedFiles += 1;
43
+ }
44
+ }
45
+
46
+ return { accepted, droppedImages, droppedFiles };
47
+ }
48
+
49
+ export function buildStreamMessagesForModel(
50
+ allMessages: ChatMessage[],
51
+ viewportScreenshot: string | null,
52
+ supportsImages: boolean,
53
+ ): StreamBuildResult {
54
+ let droppedInlineImages = 0;
55
+ let droppedViewportScreenshot = false;
56
+
57
+ const messages = allMessages.map((message, idx) => {
58
+ const isLastMessage = idx === allMessages.length - 1;
59
+ // Only include binary image payloads for the most recent message.
60
+ // Older turns keep text-only content to avoid unbounded request growth.
61
+ const imageAttachments = isLastMessage
62
+ ? (message.attachments?.filter((a) => a.isImage && a.imageBase64) ?? [])
63
+ : [];
64
+ const hasViewportShot = isLastMessage && Boolean(viewportScreenshot);
65
+
66
+ if (!supportsImages) {
67
+ const originalImageCount = message.attachments?.filter((a) => a.isImage && a.imageBase64).length ?? 0;
68
+ droppedInlineImages += originalImageCount;
69
+ droppedViewportScreenshot = droppedViewportScreenshot || hasViewportShot;
70
+ return { role: message.role as 'user' | 'assistant', content: message.content };
71
+ }
72
+
73
+ if (imageAttachments.length === 0 && !hasViewportShot) {
74
+ return { role: message.role as 'user' | 'assistant', content: message.content };
75
+ }
76
+
77
+ const parts: Array<TextContentPart | ImageContentPart> = [];
78
+ for (const img of imageAttachments) {
79
+ parts.push({ type: 'image_url', image_url: { url: img.imageBase64! } });
80
+ }
81
+ if (hasViewportShot) {
82
+ parts.push({ type: 'image_url', image_url: { url: viewportScreenshot! } });
83
+ parts.push({
84
+ type: 'text',
85
+ text: `${message.content}\n\n[Attached: current viewport screenshot showing the 3D model state]`,
86
+ });
87
+ } else {
88
+ parts.push({ type: 'text', text: message.content });
89
+ }
90
+ return { role: message.role as 'user' | 'assistant', content: parts };
91
+ });
92
+
93
+ return { messages, droppedInlineImages, droppedViewportScreenshot };
94
+ }