@ifc-lite/viewer 1.17.4 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/.turbo/turbo-build.log +20 -17
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +630 -0
  4. package/DESKTOP_CONTRACT_VERSION +1 -1
  5. package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  6. package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
  7. package/dist/assets/{exporters-ChAtBmlj.js → exporters-B_OBqIyD.js} +3479 -2845
  8. package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-xHHy-9DV.js} +1 -1
  9. package/dist/assets/ids-DQ5jY0E8.js +1 -0
  10. package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
  11. package/dist/assets/{index-Co8E2-FE.js → index-BKq-M3Mk.js} +55873 -40593
  12. package/dist/assets/index-COnQRuqY.css +1 -0
  13. package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-SHXiQwFW.js} +104 -104
  14. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  15. package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-ncOQVNso.js} +1 -1
  16. package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-DyfBSB8z.js} +1 -1
  17. package/dist/index.html +8 -7
  18. package/index.html +1 -0
  19. package/package.json +13 -13
  20. package/src/App.tsx +16 -2
  21. package/src/apache-arrow.d.ts +30 -0
  22. package/src/components/viewer/AddElementPanel.tsx +758 -0
  23. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  24. package/src/components/viewer/CesiumOverlay.tsx +62 -19
  25. package/src/components/viewer/ChatPanel.tsx +259 -93
  26. package/src/components/viewer/CommandPalette.tsx +56 -7
  27. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  28. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  29. package/src/components/viewer/ExportDialog.tsx +19 -1
  30. package/src/components/viewer/MainToolbar.tsx +73 -13
  31. package/src/components/viewer/PropertiesPanel.tsx +237 -23
  32. package/src/components/viewer/SearchInline.tsx +669 -0
  33. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  34. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  35. package/src/components/viewer/SearchModal.text.tsx +388 -0
  36. package/src/components/viewer/SearchModal.tsx +235 -0
  37. package/src/components/viewer/SettingsPage.tsx +252 -101
  38. package/src/components/viewer/ThemeSwitch.tsx +63 -7
  39. package/src/components/viewer/ToolOverlays.tsx +5 -0
  40. package/src/components/viewer/ViewerLayout.tsx +25 -4
  41. package/src/components/viewer/Viewport.tsx +25 -3
  42. package/src/components/viewer/ViewportContainer.tsx +51 -64
  43. package/src/components/viewer/ViewportOverlays.tsx +5 -2
  44. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  45. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  46. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  47. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  48. package/src/components/viewer/bcf/BCFTopicDetail.tsx +4 -4
  49. package/src/components/viewer/chat/ModelSelector.tsx +90 -54
  50. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  51. package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
  52. package/src/components/viewer/properties/LocationMap.tsx +9 -7
  53. package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
  54. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  55. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  56. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  57. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  58. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  59. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  60. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  61. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  62. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  63. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  64. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  65. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  66. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  67. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  68. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  69. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  70. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  71. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  72. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  73. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  74. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  75. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  76. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  77. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  78. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  79. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  80. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  81. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  82. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  83. package/src/components/viewer/selectionHandlers.ts +446 -0
  84. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  85. package/src/components/viewer/tools/SectionCapControls.tsx +237 -0
  86. package/src/components/viewer/tools/SectionPanel.tsx +39 -18
  87. package/src/components/viewer/useAnimationLoop.ts +9 -1
  88. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  89. package/src/components/viewer/useMouseControls.ts +9 -1
  90. package/src/components/viewer/useRenderUpdates.ts +1 -1
  91. package/src/hooks/ids/idsDataAccessor.ts +60 -24
  92. package/src/hooks/ingest/viewerModelIngest.ts +7 -2
  93. package/src/hooks/useIfcFederation.ts +326 -71
  94. package/src/hooks/useIfcLoader.ts +23 -10
  95. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  96. package/src/hooks/useSandbox.ts +1 -1
  97. package/src/hooks/useSearchIndex.ts +125 -0
  98. package/src/hooks/useViewControls.ts +13 -5
  99. package/src/index.css +550 -10
  100. package/src/lib/desktop-entitlement.ts +2 -4
  101. package/src/lib/geo/cesium-bridge.ts +15 -7
  102. package/src/lib/geo/effective-georef.test.ts +73 -0
  103. package/src/lib/geo/effective-georef.ts +111 -0
  104. package/src/lib/geo/reproject.ts +105 -19
  105. package/src/lib/llm/byok-guard.test.ts +77 -0
  106. package/src/lib/llm/byok-guard.ts +39 -0
  107. package/src/lib/llm/free-models.test.ts +0 -6
  108. package/src/lib/llm/models.ts +104 -42
  109. package/src/lib/llm/stream-client.ts +74 -110
  110. package/src/lib/llm/stream-direct.test.ts +130 -0
  111. package/src/lib/llm/stream-direct.ts +316 -0
  112. package/src/lib/llm/system-prompt.test.ts +14 -0
  113. package/src/lib/llm/system-prompt.ts +102 -1
  114. package/src/lib/llm/types.ts +20 -2
  115. package/src/lib/recent-files.ts +38 -4
  116. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  117. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  118. package/src/lib/scripts/templates.ts +7 -0
  119. package/src/lib/search/common-ifc-types.ts +36 -0
  120. package/src/lib/search/filter-evaluate.test.ts +537 -0
  121. package/src/lib/search/filter-evaluate.ts +610 -0
  122. package/src/lib/search/filter-rules.test.ts +119 -0
  123. package/src/lib/search/filter-rules.ts +198 -0
  124. package/src/lib/search/filter-schema.test.ts +233 -0
  125. package/src/lib/search/filter-schema.ts +146 -0
  126. package/src/lib/search/recent-searches.test.ts +116 -0
  127. package/src/lib/search/recent-searches.ts +93 -0
  128. package/src/lib/search/result-export.test.ts +101 -0
  129. package/src/lib/search/result-export.ts +104 -0
  130. package/src/lib/search/saved-filters.test.ts +118 -0
  131. package/src/lib/search/saved-filters.ts +154 -0
  132. package/src/lib/search/tier0-scan.test.ts +196 -0
  133. package/src/lib/search/tier0-scan.ts +237 -0
  134. package/src/lib/search/tier1-index.test.ts +242 -0
  135. package/src/lib/search/tier1-index.ts +448 -0
  136. package/src/main.tsx +1 -10
  137. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  138. package/src/sdk/adapters/export-adapter.ts +404 -1
  139. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  140. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  141. package/src/sdk/adapters/model-compat.ts +8 -2
  142. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  143. package/src/sdk/adapters/store-adapter.ts +201 -0
  144. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  145. package/src/sdk/local-backend.ts +16 -8
  146. package/src/services/api-keys.ts +73 -0
  147. package/src/services/desktop-export.ts +3 -1
  148. package/src/services/desktop-native-metadata.ts +41 -18
  149. package/src/services/file-dialog.ts +4 -1
  150. package/src/services/tauri-modules.d.ts +25 -0
  151. package/src/store/basketVisibleSet.ts +3 -0
  152. package/src/store/constants.ts +20 -2
  153. package/src/store/globalId.ts +4 -1
  154. package/src/store/index.ts +82 -6
  155. package/src/store/slices/addElementMeshes.ts +365 -0
  156. package/src/store/slices/addElementSlice.ts +275 -0
  157. package/src/store/slices/annotationsSlice.test.ts +133 -0
  158. package/src/store/slices/annotationsSlice.ts +251 -0
  159. package/src/store/slices/cesiumSlice.ts +5 -0
  160. package/src/store/slices/chatSlice.test.ts +6 -76
  161. package/src/store/slices/chatSlice.ts +17 -58
  162. package/src/store/slices/dataSlice.test.ts +23 -4
  163. package/src/store/slices/dataSlice.ts +1 -1
  164. package/src/store/slices/modelSlice.test.ts +67 -9
  165. package/src/store/slices/modelSlice.ts +39 -7
  166. package/src/store/slices/mutationSlice.ts +964 -3
  167. package/src/store/slices/overlayCompositor.test.ts +164 -0
  168. package/src/store/slices/overlaySlice.test.ts +93 -0
  169. package/src/store/slices/overlaySlice.ts +151 -0
  170. package/src/store/slices/pinboardSlice.test.ts +6 -1
  171. package/src/store/slices/playbackSlice.ts +128 -0
  172. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  173. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  174. package/src/store/slices/scheduleSlice.test.ts +694 -0
  175. package/src/store/slices/scheduleSlice.ts +1330 -0
  176. package/src/store/slices/searchSlice.test.ts +342 -0
  177. package/src/store/slices/searchSlice.ts +341 -0
  178. package/src/store/slices/sectionSlice.test.ts +87 -7
  179. package/src/store/slices/sectionSlice.ts +151 -5
  180. package/src/store/slices/selectionSlice.test.ts +46 -0
  181. package/src/store/slices/selectionSlice.ts +20 -0
  182. package/src/store/slices/uiSlice.ts +28 -5
  183. package/src/store/types.ts +26 -0
  184. package/src/store.ts +14 -0
  185. package/src/utils/nativeSpatialDataStore.ts +4 -1
  186. package/src/utils/viewportUtils.ts +7 -2
  187. package/src/vite-env.d.ts +0 -4
  188. package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
  189. package/dist/assets/ids-B4jTqB1O.js +0 -1
  190. package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
  191. package/dist/assets/index-DckuDqlv.css +0 -1
  192. package/dist/assets/sandbox-DZiNLNMk.js +0 -5933
  193. package/src/components/viewer/UpgradePage.tsx +0 -71
  194. package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
  195. package/src/lib/llm/ClerkChatSync.tsx +0 -74
  196. package/src/lib/llm/clerk-auth.ts +0 -62
@@ -0,0 +1,316 @@
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
+ * Direct browser-to-provider streaming for BYOK (Bring Your Own Key) models.
7
+ *
8
+ * Anthropic: Uses the official @anthropic-ai/sdk with `dangerouslyAllowBrowser`.
9
+ * OpenAI: Uses fetch against the OpenAI chat completions API (same SSE format
10
+ * the proxy already returns, so SSE parsing is shared).
11
+ *
12
+ * Keys are stored in localStorage and sent directly to the provider.
13
+ * They never pass through our server.
14
+ */
15
+
16
+ import Anthropic from '@anthropic-ai/sdk';
17
+ import { readSseStream, type StreamMessage, type StreamOptions } from './stream-client.js';
18
+
19
+ const STREAM_REQUEST_TIMEOUT_MS = 45_000;
20
+
21
+ // ── Anthropic ──────────────────────────────────────────────────────────────
22
+
23
+ type AnthropicMediaType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
24
+
25
+ type AnthropicContentBlock =
26
+ | { type: 'text'; text: string }
27
+ | { type: 'image'; source: { type: 'base64'; media_type: AnthropicMediaType; data: string } };
28
+
29
+ function toAnthropicMessages(
30
+ messages: StreamMessage[],
31
+ ): Array<{ role: 'user' | 'assistant'; content: string | AnthropicContentBlock[] }> {
32
+ return messages
33
+ .filter((m) => m.role === 'user' || m.role === 'assistant')
34
+ .map((m) => {
35
+ if (typeof m.content === 'string') {
36
+ return { role: m.role as 'user' | 'assistant', content: m.content };
37
+ }
38
+ // Multimodal content — convert OpenAI-style parts to Anthropic format
39
+ const blocks: AnthropicContentBlock[] = m.content.map((part) => {
40
+ if (part.type === 'text') {
41
+ return { type: 'text' as const, text: part.text };
42
+ }
43
+ // image_url → Anthropic image block
44
+ const dataUrl = part.image_url.url;
45
+ const match = dataUrl.match(/^data:(image\/[^;]+);base64,(.+)$/);
46
+ if (match) {
47
+ return {
48
+ type: 'image' as const,
49
+ source: {
50
+ type: 'base64' as const,
51
+ media_type: match[1] as AnthropicMediaType,
52
+ data: match[2],
53
+ },
54
+ };
55
+ }
56
+ // Fallback: pass URL as text
57
+ return { type: 'text' as const, text: `[Image: ${dataUrl.slice(0, 100)}]` };
58
+ });
59
+ return { role: m.role as 'user' | 'assistant', content: blocks };
60
+ });
61
+ }
62
+
63
+ export async function streamAnthropicChat(
64
+ apiKey: string,
65
+ options: Omit<StreamOptions, 'proxyUrl' | 'authToken' | 'onUsageInfo'>,
66
+ ): Promise<void> {
67
+ const { model, messages, system, signal, onChunk, onComplete, onError, onFinishReason } = options;
68
+
69
+ const client = new Anthropic({
70
+ apiKey,
71
+ dangerouslyAllowBrowser: true,
72
+ });
73
+
74
+ let fullText = '';
75
+ try {
76
+ const stream = client.messages.stream({
77
+ model,
78
+ max_tokens: 8192,
79
+ temperature: 0.3,
80
+ system: system || undefined,
81
+ messages: toAnthropicMessages(messages),
82
+ });
83
+
84
+ // Wire up abort signal
85
+ if (signal) {
86
+ const onAbort = () => stream.abort();
87
+ signal.addEventListener('abort', onAbort, { once: true });
88
+ stream.on('end', () => signal.removeEventListener('abort', onAbort));
89
+ }
90
+
91
+ stream.on('text', (text) => {
92
+ fullText += text;
93
+ onChunk(text);
94
+ });
95
+
96
+ const finalMessage = await stream.finalMessage();
97
+
98
+ if (signal?.aborted) return;
99
+
100
+ const stopReason = finalMessage.stop_reason;
101
+ onFinishReason?.(stopReason === 'end_turn' ? 'stop' : stopReason);
102
+ onComplete(fullText);
103
+ } catch (err) {
104
+ if (signal?.aborted) return;
105
+
106
+ if (err instanceof Anthropic.APIError) {
107
+ const msg = err.status === 401
108
+ ? 'Invalid Anthropic API key. Check your key in Settings.'
109
+ : err.status === 429
110
+ ? 'Anthropic rate limit reached. Please wait and try again.'
111
+ : `Anthropic error (${err.status}): ${err.message}`;
112
+ onError(new Error(msg));
113
+ } else {
114
+ onError(err instanceof Error ? err : new Error(String(err)));
115
+ }
116
+ }
117
+ }
118
+
119
+ // ── OpenAI ─────────────────────────────────────────────────────────────────
120
+
121
+ import { getModelById } from './models.js';
122
+
123
+ /**
124
+ * Stream an OpenAI model. Automatically picks the right API:
125
+ * - Chat Completions (`/v1/chat/completions`) for standard chat models
126
+ * - Responses (`/v1/responses`) for Codex-style models
127
+ */
128
+ export async function streamOpenAiChat(
129
+ apiKey: string,
130
+ options: Omit<StreamOptions, 'proxyUrl' | 'authToken' | 'onUsageInfo'>,
131
+ ): Promise<void> {
132
+ const modelDef = getModelById(options.model);
133
+ if (modelDef?.openaiApi === 'responses') {
134
+ return streamOpenAiResponses(apiKey, options);
135
+ }
136
+ return streamOpenAiChatCompletions(apiKey, options);
137
+ }
138
+
139
+ /** Standard Chat Completions API (GPT-5.4, GPT-5.4 Mini, etc.) */
140
+ async function streamOpenAiChatCompletions(
141
+ apiKey: string,
142
+ options: Omit<StreamOptions, 'proxyUrl' | 'authToken' | 'onUsageInfo'>,
143
+ ): Promise<void> {
144
+ const { model, messages, system, signal, onChunk, onComplete, onError, onFinishReason } = options;
145
+
146
+ const allMessages: StreamMessage[] = system
147
+ ? [{ role: 'system', content: system }, ...messages]
148
+ : [...messages];
149
+
150
+ const { response, cleanup } = await openAiFetch(
151
+ 'https://api.openai.com/v1/chat/completions',
152
+ {
153
+ model,
154
+ messages: allMessages.map((m) => ({ role: m.role, content: m.content })),
155
+ stream: true,
156
+ temperature: 0.3,
157
+ max_completion_tokens: 8192,
158
+ },
159
+ apiKey,
160
+ signal,
161
+ onError,
162
+ );
163
+ if (!response) return;
164
+
165
+ if (!response.body) { cleanup(); onError(new Error('No response body')); return; }
166
+
167
+ let fullText = '';
168
+ let finishReason: string | null = null;
169
+
170
+ const ok = await readSseStream(response.body, signal, (data) => {
171
+ const parsed = JSON.parse(data) as {
172
+ choices?: Array<{ delta?: { content?: string }; finish_reason?: string | null }>;
173
+ };
174
+ const content = parsed.choices?.[0]?.delta?.content;
175
+ if (content) { fullText += content; onChunk(content); }
176
+ const fr = parsed.choices?.[0]?.finish_reason;
177
+ if (fr) finishReason = fr;
178
+ }, onError);
179
+
180
+ cleanup();
181
+ if (ok) { onFinishReason?.(finishReason); onComplete(fullText); }
182
+ }
183
+
184
+ /** Responses API for Codex-style models (GPT-5.3 Codex) */
185
+ async function streamOpenAiResponses(
186
+ apiKey: string,
187
+ options: Omit<StreamOptions, 'proxyUrl' | 'authToken' | 'onUsageInfo'>,
188
+ ): Promise<void> {
189
+ const { model, messages, system, signal, onChunk, onComplete, onError, onFinishReason } = options;
190
+
191
+ // Build the input array: system instructions + conversation
192
+ const input: Array<{ role: string; content: string | unknown[] }> = [];
193
+ if (system) {
194
+ input.push({ role: 'developer', content: system });
195
+ }
196
+ for (const m of messages) {
197
+ input.push({ role: m.role, content: m.content });
198
+ }
199
+
200
+ const { response, cleanup } = await openAiFetch(
201
+ 'https://api.openai.com/v1/responses',
202
+ {
203
+ model,
204
+ input,
205
+ stream: true,
206
+ max_output_tokens: 8192,
207
+ },
208
+ apiKey,
209
+ signal,
210
+ onError,
211
+ );
212
+ if (!response) return;
213
+
214
+ if (!response.body) { cleanup(); onError(new Error('No response body')); return; }
215
+
216
+ let fullText = '';
217
+ // Map Responses API terminal events → chat-style finish_reason.
218
+ // `response.incomplete` is any non-completed terminal state: when the
219
+ // reason is `max_output_tokens` — or simply absent — map to 'length' so
220
+ // the ChatPanel "Continue" UX can resume a truncated Codex reply. Other
221
+ // explicit reasons (e.g. `content_filter`) pass through unchanged.
222
+ let finishReason: string | null = 'stop';
223
+
224
+ const ok = await readSseStream(response.body, signal, (data) => {
225
+ const event = JSON.parse(data) as {
226
+ type?: string;
227
+ delta?: string;
228
+ response?: {
229
+ status?: string;
230
+ incomplete_details?: { reason?: string } | null;
231
+ };
232
+ };
233
+ if (event.type === 'response.output_text.delta' && event.delta) {
234
+ fullText += event.delta;
235
+ onChunk(event.delta);
236
+ } else if (event.type === 'response.incomplete') {
237
+ const reason = event.response?.incomplete_details?.reason;
238
+ finishReason = reason == null || reason === 'max_output_tokens' ? 'length' : reason;
239
+ } else if (event.type === 'response.completed') {
240
+ finishReason = 'stop';
241
+ }
242
+ }, onError);
243
+
244
+ cleanup();
245
+ if (ok) { onFinishReason?.(finishReason); onComplete(fullText); }
246
+ }
247
+
248
+ // ── Shared helpers ─────────────────────────────────────────────────────────
249
+
250
+ async function openAiFetch(
251
+ url: string,
252
+ body: Record<string, unknown>,
253
+ apiKey: string,
254
+ signal: AbortSignal | undefined,
255
+ onError: (err: Error) => void,
256
+ ): Promise<{ response: Response | null; cleanup: () => void }> {
257
+ const controller = new AbortController();
258
+ const timeoutId = setTimeout(
259
+ () => controller.abort(new Error('Chat request timed out. Please try again.')),
260
+ STREAM_REQUEST_TIMEOUT_MS,
261
+ );
262
+ const abortFromParent = () => controller.abort(signal?.reason);
263
+ if (signal) {
264
+ if (signal.aborted) { clearTimeout(timeoutId); return { response: null, cleanup: () => {} }; }
265
+ signal.addEventListener('abort', abortFromParent, { once: true });
266
+ }
267
+
268
+ // cleanup() clears the connect timeout and removes the abort listener.
269
+ // Callers must call it AFTER streaming completes, not before — otherwise
270
+ // user cancellation during SSE consumption won't abort the fetch.
271
+ const cleanup = () => {
272
+ clearTimeout(timeoutId);
273
+ signal?.removeEventListener('abort', abortFromParent);
274
+ };
275
+
276
+ let response: Response;
277
+ try {
278
+ response = await fetch(url, {
279
+ method: 'POST',
280
+ headers: {
281
+ Authorization: `Bearer ${apiKey}`,
282
+ 'Content-Type': 'application/json',
283
+ },
284
+ body: JSON.stringify(body),
285
+ signal: controller.signal,
286
+ });
287
+ } catch (err) {
288
+ cleanup();
289
+ if (signal?.aborted) return { response: null, cleanup: () => {} };
290
+ if (controller.signal.aborted && controller.signal.reason instanceof Error) {
291
+ onError(controller.signal.reason);
292
+ } else {
293
+ onError(err instanceof Error ? err : new Error(String(err)));
294
+ }
295
+ return { response: null, cleanup: () => {} };
296
+ }
297
+
298
+ if (!response.ok) {
299
+ cleanup();
300
+ let detail = `OpenAI error (${response.status})`;
301
+ try {
302
+ const errBody = (await response.json()) as { error?: { message?: string } };
303
+ if (response.status === 401) {
304
+ detail = 'Invalid OpenAI API key. Check your key in the chat panel.';
305
+ } else if (response.status === 429) {
306
+ detail = 'OpenAI rate limit reached. Please wait and try again.';
307
+ } else if (errBody.error?.message) {
308
+ detail = `OpenAI: ${errBody.error.message}`;
309
+ }
310
+ } catch { /* ignore parse failure */ }
311
+ onError(new Error(detail));
312
+ return { response: null, cleanup: () => {} };
313
+ }
314
+
315
+ return { response, cleanup };
316
+ }
@@ -91,6 +91,20 @@ test('system prompt includes selected entity IFC context when provided', () => {
91
91
  assert.match(prompt, /Selected entities: Tower: IfcCurtainWall "Facade Panel A", kind=occurrence, storey=Level 10@31.5m, psets=Pset_CurtainWallCommon, typePsets=Pset_CurtainWallTypeCommon, qsets=Qto_CurtainWallBaseQuantities, material=Aluminium, classifications=A-123 \| Tower: IfcWallType "Exterior Wall Type", kind=type, psets=Pset_WallCommon, classifications=A-WALL/);
92
92
  });
93
93
 
94
+ test('system prompt includes the BIM.STORE cheat sheet and routing rule', () => {
95
+ const prompt = buildSystemPrompt();
96
+
97
+ // Section heading + the three method examples
98
+ assert.match(prompt, /## BIM\.STORE CHEAT SHEET/);
99
+ assert.match(prompt, /bim\.store\.setPositionalAttribute\(profile, 3, 0\.6\)/);
100
+ assert.match(prompt, /bim\.store\.addEntity\("arch"/);
101
+ assert.match(prompt, /bim\.store\.removeEntity\(unwantedRef\)/);
102
+
103
+ // Routing rule disambiguating store / mutate / create
104
+ assert.match(prompt, /bim\.store\.setPositionalAttribute\(entity, index, value\)`? for positional STEP-argument edits/);
105
+ assert.match(prompt, /Do NOT use `bim\.create` for these/);
106
+ });
107
+
94
108
  test('system prompt includes method-specific create contract guidance', () => {
95
109
  const prompt = buildSystemPrompt();
96
110
  assert.match(prompt, /BIM\.CREATE CONTRACT CHEAT SHEET/);
@@ -100,6 +100,69 @@ function buildIntentMethodSection(intent: LlmTaskIntent): string {
100
100
  return lines.join('\n');
101
101
  }
102
102
 
103
+ function buildStoreCheatSheet(): string {
104
+ const storeNamespace = NAMESPACE_SCHEMAS.find((schema) => schema.name === 'store');
105
+ if (!storeNamespace) return '';
106
+
107
+ return [
108
+ '## BIM.STORE CHEAT SHEET',
109
+ '`bim.store.*` edits a parsed model in place — use it when the user already has',
110
+ 'a model loaded and wants raw STEP-level edits, NOT when building a new model from',
111
+ 'scratch (that\'s `bim.create`).',
112
+ '',
113
+ '- `addEntity(modelId, { type, attributes })` — inject a STEP entity. `attributes`',
114
+ ' follows EntityExtractor output: numbers → REAL/integer, `"#42"` → ref, `".AREA."` → enum,',
115
+ ' `null` → `$`, arrays → STEP list. Returns `{ modelId, expressId }`.',
116
+ '- `removeEntity(entity)` — tombstones existing source entities or forgets overlay-only ones.',
117
+ '- `setPositionalAttribute(entity, index, value)` — edit a non-IfcRoot attribute by',
118
+ ' zero-based STEP argument index. Use this for `IfcRectangleProfileDef.XDim` (index 3),',
119
+ ' `YDim` (index 4), `IfcCartesianPoint.Coordinates` (index 0), etc. Use `bim.mutate.setAttribute`',
120
+ ' for IfcRoot attributes (Name, Description, ObjectType, Tag).',
121
+ '- High-level builders anchor a new element to an existing IfcBuildingStorey:',
122
+ ' `addColumn(modelId, storeyId, { Position, Width, Depth, Height })`',
123
+ ' `addWall(modelId, storeyId, { Start, End, Thickness, Height })`',
124
+ ' `addBeam(modelId, storeyId, { Start, End, Width, Height })`',
125
+ ' `addSlab(modelId, storeyId, { Position, Width, Depth, Thickness })` // rectangle',
126
+ ' `addSlab(modelId, storeyId, { Profile: "polygon", OuterCurve: [[x,y],…], Thickness })`',
127
+ ' `addRoof(modelId, storeyId, { … same shape as addSlab — emits .FLAT_ROOF. })`',
128
+ ' `addPlate(modelId, storeyId, { … same shape as addSlab — IfcPlate, PredefinedType? })`',
129
+ ' `addSpace(modelId, storeyId, { Position, Width, Depth, Height, LongName? })` // room rectangle',
130
+ ' `addSpace(modelId, storeyId, { Profile: "polygon", OuterCurve, Height })` // room polygon',
131
+ ' `addDoor(modelId, storeyId, { Position, Width, Height, FrameThickness?, OperationType? })`',
132
+ ' `addWindow(modelId, storeyId, { Position, Width, Height, FrameThickness?, PartitioningType? })`',
133
+ ' `addMember(modelId, storeyId, { Start, End, Width, Height, PredefinedType? })` // brace/post/strut',
134
+ ' Each emits ~12 STEP entities (placement chain → profile → solid → representation +',
135
+ ' IfcRelContainedInSpatialStructure, except `addSpace` which uses IfcRelAggregates).',
136
+ ' Coords are storey-local metres. Polygon outlines need ≥3 points; the polyline is auto-closed.',
137
+ '- Edits accumulate in an overlay; they show up after `bim.export.ifc(bim.query.all())`',
138
+ ' or when the viewer next renders. Use `bim.mutate.undo(modelId)` to roll back.',
139
+ '',
140
+ 'Canonical examples:',
141
+ '```js',
142
+ '// Resize a rectangular profile from 0.3×0.4 to 0.6×0.4',
143
+ 'const profile = bim.query.byId("arch", 35);',
144
+ 'bim.store.setPositionalAttribute(profile, 3, 0.6); // XDim',
145
+ '',
146
+ '// Drop a wall on the first storey',
147
+ 'const storeyId = bim.query.byType("IfcBuildingStorey")[0].ref.expressId;',
148
+ 'bim.store.addWall("arch", storeyId, {',
149
+ ' Start: [0, 0, 0], End: [5, 0, 0],',
150
+ ' Thickness: 0.2, Height: 3, Name: "North Wall",',
151
+ '});',
152
+ '',
153
+ '// Add a custom IfcCartesianPoint, then reference it from another entity',
154
+ 'const pt = bim.store.addEntity("arch", {',
155
+ ' type: "IfcCartesianPoint",',
156
+ ' attributes: [[1.0, 2.0, 0.0]],',
157
+ '});',
158
+ 'console.log("Allocated", pt.expressId);',
159
+ '',
160
+ '// Drop an entity entirely',
161
+ 'bim.store.removeEntity(unwantedRef);',
162
+ '```',
163
+ ].join('\n');
164
+ }
165
+
103
166
  function buildCreateContractCheatSheet(): string {
104
167
  const createNamespace = NAMESPACE_SCHEMAS.find((schema) => schema.name === 'create');
105
168
  if (!createNamespace) return '## BIM.CREATE CONTRACT CHEAT SHEET';
@@ -280,6 +343,7 @@ export function buildSystemPrompt(
280
343
  const intent = inferPromptIntent(task);
281
344
  const intentSection = buildIntentMethodSection(intent);
282
345
  const createContractCheatSheet = buildCreateContractCheatSheet();
346
+ const storeCheatSheet = buildStoreCheatSheet();
283
347
  const placementSemantics = buildPlacementSemanticsSection();
284
348
  const inspectionGuidance = buildInspectionGuidance();
285
349
 
@@ -326,9 +390,11 @@ ${intentSection}
326
390
  4. Keep scripts concise — avoid unnecessary abstractions
327
391
  5. Coordinates are in meters. Z is up. Do NOT assume every create method is storey-relative — use the method-specific placement rules below.
328
392
  6. For create or explicit rewrite turns, wrap runnable code in a \`\`\`js\`\`\` fence. For repair turns, return exactly one \`\`\`ifc-script-edits\`\`\` fence containing SEARCH/REPLACE blocks and no \`\`\`js\`\`\` fence.
329
- 7. If the user asks to modify existing data, use \`bim.mutate\` or \`bim.query\` — NOT \`bim.create\`
393
+ 7. If the user asks to modify existing data, use \`bim.mutate\`, \`bim.store\`, or \`bim.query\` — NOT \`bim.create\`
330
394
  - Use \`bim.mutate.setAttribute(entity, "Description", "...")\` for root IFC attributes like \`Name\`, \`Description\`, \`ObjectType\`, or \`Tag\`
331
395
  - Use \`bim.mutate.setProperty(entity, "Pset_Name", "PropName", value)\` only for IfcPropertySet or quantity data
396
+ - Use \`bim.store.setPositionalAttribute(entity, index, value)\` for positional STEP-argument edits (profile dimensions, \`IfcCartesianPoint.Coordinates\`, and other index-addressed attributes — even when they have a symbolic name) — see BIM.STORE CHEAT SHEET
397
+ - Use \`bim.store.addEntity\` / \`bim.store.removeEntity\` to inject or drop raw STEP entities in an already-loaded model. Do NOT use \`bim.create\` for these — \`bim.create\` builds a fresh project
332
398
  - Distinguish occurrence vs type edits: occurrence/entity-specific changes belong on the occurrence; shared defaults and inherited type properties belong on the related \`Ifc...Type\` entity
333
399
  - If CURRENT MODEL STATE marks a selection as \`kind=type\`, treat it as a type object and avoid describing it as one physical placed occurrence
334
400
  - When an occurrence is selected, inspect \`bim.query.typeProperties(entity)\` before editing inherited values; mutate the type entity when the intent is to change all occurrences that share that type
@@ -353,6 +419,8 @@ ${intentSection}
353
419
 
354
420
  ${createContractCheatSheet}
355
421
 
422
+ ${storeCheatSheet}
423
+
356
424
  ${placementSemantics}
357
425
  - \`addIfcDoor\` and \`addIfcWindow\` do not infer host-wall orientation. If you place them next to angled walls, they will stay world-aligned unless you build the wall void another way.
358
426
  - For storey-relative methods, \`Z=0\` usually means floor level of that storey.
@@ -393,6 +461,39 @@ for (let i = 0; i < storeyCount; i++) {
393
461
  - If repeated elements appear only at one level, you probably reused one storey reference instead of iterating over the intended storeys.
394
462
  - If repeated world-placement elements stack at the base level, first check whether their Z coordinates include the current storey elevation.
395
463
 
464
+ ## SCHEDULING / 4D (IfcTask, IfcWorkSchedule, IfcRelSequence)
465
+ - ifc-lite ships a Gantt panel in the lower workspace that plays a construction-sequence animation driven by IfcTask dates and the products each task controls.
466
+ - Creating a schedule from scratch:
467
+ \`\`\`js
468
+ const h = bim.create.project({ Name: "Demo" });
469
+ const storey = bim.create.addIfcBuildingStorey(h, { Name: "Ground", Elevation: 0 });
470
+ const wallA = bim.create.addIfcWall(h, storey, { Start: [0,0,0], End: [5,0,0], Thickness: 0.2, Height: 3 });
471
+
472
+ const schedule = bim.create.addIfcWorkSchedule(h, {
473
+ Name: "Construction schedule",
474
+ StartTime: "2024-05-01T08:00:00",
475
+ FinishTime: "2024-06-30T17:00:00",
476
+ PredefinedType: "PLANNED",
477
+ });
478
+ const task = bim.create.addIfcTask(h, {
479
+ Name: "Install wall A",
480
+ PredefinedType: "INSTALLATION",
481
+ ScheduleStart: "2024-05-06T08:00:00",
482
+ ScheduleFinish: "2024-05-10T17:00:00",
483
+ ScheduleDuration: "P5D",
484
+ });
485
+ bim.create.assignTasksToWorkSchedule(h, schedule, [task]);
486
+ bim.create.assignProductsToTask(h, task, [wallA]); // products reveal in the 4D animation
487
+ // bim.create.addIfcRelSequence(h, prevTask, task, { SequenceType: "FINISH_START", TimeLag: "P2D" });
488
+ // bim.create.nestTasks(h, summaryTask, [task]); // WBS hierarchy
489
+ \`\`\`
490
+ - Dates are ISO 8601 (\`2024-05-01T08:00:00\`). Durations are ISO 8601 (\`P5D\`, \`PT8H\`).
491
+ - IfcTask.PredefinedType is an enum — prefer CONSTRUCTION, INSTALLATION, DEMOLITION, RENOVATION over free strings.
492
+ - For milestones (e.g. "handover"), set \`IsMilestone: true\` and omit or equate start/finish.
493
+ - \`assignProductsToTask\` is the bridge that lets the 4D Gantt animation reveal/hide elements as time advances. Always bind tasks to the elements they construct when the user wants the viewport to animate.
494
+ - Reading an existing schedule: \`bim.schedule.data()\` returns { workSchedules, tasks, sequences }. Use it to inspect or validate a construction plan.
495
+ - **CSV / Excel / PDF → schedule workflow:** when the user attaches a spreadsheet or PDF with activities and dates, parse it with \`bim.files.csv(name)\` (for CSV) or \`bim.files.text(name)\` (for text-extracted PDF/Excel content converted upstream). Map each row to an \`addIfcTask(...)\` call and resolve the \`products\` column — an IFC type like \`IfcWall\` expands to every matching entity, a globalId maps to one specific entity — into \`expressId\`s to feed \`assignProductsToTask\`. The \`Construction schedule (4D)\` script template is a ready-made starting point.
496
+
396
497
  ## API REFERENCE
397
498
  ${apiRef}
398
499
 
@@ -124,9 +124,23 @@ export interface FileAttachment {
124
124
  imageBase64?: string;
125
125
  /** Whether this is an image attachment */
126
126
  isImage?: boolean;
127
+ /** Base64-encoded PDF data (for PDF attachments — Anthropic native document blocks) */
128
+ pdfBase64?: string;
129
+ /** Whether this is a PDF attachment */
130
+ isPdf?: boolean;
131
+ /** Whether this is a binary spreadsheet (xlsx/xls/ods) that we can't parse here yet. */
132
+ isSpreadsheetBinary?: boolean;
127
133
  }
128
134
 
129
- export type ModelTier = 'free' | 'pro';
135
+ export type ModelTier = 'free' | 'byok';
136
+
137
+ /**
138
+ * Where requests for this model are routed.
139
+ * - 'proxy': through the server-side proxy (free models)
140
+ * - 'anthropic': direct browser-to-Anthropic API (user's own key)
141
+ * - 'openai': direct browser-to-OpenAI API (user's own key)
142
+ */
143
+ export type ModelSource = 'proxy' | 'anthropic' | 'openai';
130
144
 
131
145
  /** Relative cost indicator for paid models */
132
146
  export type ModelCost = '$' | '$$' | '$$$';
@@ -136,6 +150,8 @@ export interface LLMModel {
136
150
  name: string;
137
151
  provider: string;
138
152
  tier: ModelTier;
153
+ /** Where requests are routed — proxy (free) or direct to provider (BYOK) */
154
+ source: ModelSource;
139
155
  contextWindow: number;
140
156
  /** Whether this model accepts image inputs in chat content */
141
157
  supportsImages: boolean;
@@ -143,8 +159,10 @@ export interface LLMModel {
143
159
  supportsFileAttachments: boolean;
144
160
  /** Notes shown in model selector */
145
161
  notes?: string;
146
- /** Relative cost indicator (pro models only) */
162
+ /** Relative cost indicator (BYOK models only) */
147
163
  cost?: ModelCost;
164
+ /** OpenAI API variant: 'chat' (default) or 'responses' (Codex-style models) */
165
+ openaiApi?: 'chat' | 'responses';
148
166
  }
149
167
 
150
168
  export type ChatStatus = 'idle' | 'sending' | 'streaming' | 'error';
@@ -24,8 +24,21 @@ export interface RecentFileEntry {
24
24
  name: string;
25
25
  size: number;
26
26
  timestamp: number;
27
+ /** Native filesystem path (Tauri only) — enables direct re-open from disk. */
28
+ path?: string;
29
+ /** Last-modified epoch in ms when known (Tauri stat). */
30
+ modifiedMs?: number | null;
27
31
  }
28
32
 
33
+ // Input shape for `recordRecentFiles` — accepts the optional native fields
34
+ // so callers can persist a path / modifiedMs without lying about the type.
35
+ export type RecentFileInput = {
36
+ name: string;
37
+ size: number;
38
+ path?: string;
39
+ modifiedMs?: number | null;
40
+ };
41
+
29
42
  // ── localStorage (metadata) ─────────────────────────────────────────────
30
43
 
31
44
  export function getRecentFiles(): RecentFileEntry[] {
@@ -33,17 +46,30 @@ export function getRecentFiles(): RecentFileEntry[] {
33
46
  catch { return []; }
34
47
  }
35
48
 
36
- export function recordRecentFiles(files: { name: string; size: number }[]) {
49
+ // Path-aware dedup key: when a native filesystem path is available it
50
+ // uniquely identifies the file (so `A/model.ifc` and `B/model.ifc` are
51
+ // kept separate); otherwise fall back to the name (browser uploads
52
+ // don't expose paths).
53
+ function recentKey(f: { name: string; path?: string }): string {
54
+ return f.path ? `path:${f.path}` : `name:${f.name}`;
55
+ }
56
+
57
+ export function recordRecentFiles(files: RecentFileInput[]) {
37
58
  try {
38
- const names = new Set(files.map(f => f.name));
39
- const existing = getRecentFiles().filter(f => !names.has(f.name));
59
+ const incomingKeys = new Set(files.map(recentKey));
60
+ const existing = getRecentFiles().filter(f => !incomingKeys.has(recentKey(f)));
40
61
  const entries: RecentFileEntry[] = files.map(f => ({
41
62
  name: f.name,
42
63
  size: f.size,
43
64
  timestamp: Date.now(),
65
+ path: f.path,
66
+ modifiedMs: f.modifiedMs ?? null,
44
67
  }));
45
68
  localStorage.setItem(KEY, JSON.stringify([...entries, ...existing].slice(0, 10)));
46
- } catch { /* noop */ }
69
+ } catch (err) {
70
+ // eslint-disable-next-line no-console
71
+ console.warn('[recent-files] failed to persist recent files metadata', err);
72
+ }
47
73
  }
48
74
 
49
75
  /** Format bytes into human-readable size */
@@ -104,6 +130,14 @@ export async function cacheFileBlobs(files: File[]): Promise<void> {
104
130
 
105
131
  /** Retrieve a cached file blob and reconstruct a File object. */
106
132
  export async function getCachedFile(target: string | RecentFileEntry): Promise<File | null> {
133
+ // Path-bearing entries (Tauri filesystem) are uniquely keyed by path
134
+ // in the recents list, but the IndexedDB cache is name-keyed. A
135
+ // name-only hit could resolve `A/model.ifc` to the cached blob from
136
+ // `B/model.ifc`, opening the wrong file silently. Defer to the
137
+ // caller's native re-open path instead.
138
+ if (typeof target !== 'string' && target.path) {
139
+ return null;
140
+ }
107
141
  const name = typeof target === 'string' ? target : target.name;
108
142
  try {
109
143
  const db = await openDB();