@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.
- package/.turbo/turbo-build.log +20 -17
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +630 -0
- package/DESKTOP_CONTRACT_VERSION +1 -1
- package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-Cm1QEk_R.js} +1 -1
- package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
- package/dist/assets/{exporters-ChAtBmlj.js → exporters-B_OBqIyD.js} +3479 -2845
- package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-xHHy-9DV.js} +1 -1
- package/dist/assets/ids-DQ5jY0E8.js +1 -0
- package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
- package/dist/assets/{index-Co8E2-FE.js → index-BKq-M3Mk.js} +55873 -40593
- package/dist/assets/index-COnQRuqY.css +1 -0
- package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-SHXiQwFW.js} +104 -104
- package/dist/assets/sandbox-jez21HtV.js +9627 -0
- package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-ncOQVNso.js} +1 -1
- package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-DyfBSB8z.js} +1 -1
- package/dist/index.html +8 -7
- package/index.html +1 -0
- package/package.json +13 -13
- package/src/App.tsx +16 -2
- package/src/apache-arrow.d.ts +30 -0
- package/src/components/viewer/AddElementPanel.tsx +758 -0
- package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
- package/src/components/viewer/CesiumOverlay.tsx +62 -19
- package/src/components/viewer/ChatPanel.tsx +259 -93
- package/src/components/viewer/CommandPalette.tsx +56 -7
- package/src/components/viewer/EntityContextMenu.tsx +168 -4
- package/src/components/viewer/ExportChangesButton.tsx +25 -5
- package/src/components/viewer/ExportDialog.tsx +19 -1
- package/src/components/viewer/MainToolbar.tsx +73 -13
- package/src/components/viewer/PropertiesPanel.tsx +237 -23
- package/src/components/viewer/SearchInline.tsx +669 -0
- package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
- package/src/components/viewer/SearchModal.filter.tsx +514 -0
- package/src/components/viewer/SearchModal.text.tsx +388 -0
- package/src/components/viewer/SearchModal.tsx +235 -0
- package/src/components/viewer/SettingsPage.tsx +252 -101
- package/src/components/viewer/ThemeSwitch.tsx +63 -7
- package/src/components/viewer/ToolOverlays.tsx +5 -0
- package/src/components/viewer/ViewerLayout.tsx +25 -4
- package/src/components/viewer/Viewport.tsx +25 -3
- package/src/components/viewer/ViewportContainer.tsx +51 -64
- package/src/components/viewer/ViewportOverlays.tsx +5 -2
- package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
- package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
- package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
- package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +4 -4
- package/src/components/viewer/chat/ModelSelector.tsx +90 -54
- package/src/components/viewer/lists/ListPanel.tsx +14 -21
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
- package/src/components/viewer/properties/LocationMap.tsx +9 -7
- package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
- package/src/components/viewer/properties/RawStepCard.tsx +332 -0
- package/src/components/viewer/properties/RawStepRow.tsx +261 -0
- package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
- package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
- package/src/components/viewer/properties/raw-step-format.ts +193 -0
- package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
- package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
- package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
- package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
- package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
- package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
- package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
- package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
- package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
- package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
- package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
- package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
- package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
- package/src/components/viewer/schedule/generate-schedule.ts +648 -0
- package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
- package/src/components/viewer/schedule/schedule-animator.ts +488 -0
- package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
- package/src/components/viewer/schedule/schedule-selection.ts +163 -0
- package/src/components/viewer/schedule/schedule-utils.ts +223 -0
- package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
- package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
- package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
- package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
- package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
- package/src/components/viewer/selectionHandlers.ts +446 -0
- package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
- package/src/components/viewer/tools/SectionCapControls.tsx +237 -0
- package/src/components/viewer/tools/SectionPanel.tsx +39 -18
- package/src/components/viewer/useAnimationLoop.ts +9 -1
- package/src/components/viewer/useDuplicateShortcut.ts +77 -0
- package/src/components/viewer/useMouseControls.ts +9 -1
- package/src/components/viewer/useRenderUpdates.ts +1 -1
- package/src/hooks/ids/idsDataAccessor.ts +60 -24
- package/src/hooks/ingest/viewerModelIngest.ts +7 -2
- package/src/hooks/useIfcFederation.ts +326 -71
- package/src/hooks/useIfcLoader.ts +23 -10
- package/src/hooks/useKeyboardShortcuts.ts +25 -0
- package/src/hooks/useSandbox.ts +1 -1
- package/src/hooks/useSearchIndex.ts +125 -0
- package/src/hooks/useViewControls.ts +13 -5
- package/src/index.css +550 -10
- package/src/lib/desktop-entitlement.ts +2 -4
- package/src/lib/geo/cesium-bridge.ts +15 -7
- package/src/lib/geo/effective-georef.test.ts +73 -0
- package/src/lib/geo/effective-georef.ts +111 -0
- package/src/lib/geo/reproject.ts +105 -19
- package/src/lib/llm/byok-guard.test.ts +77 -0
- package/src/lib/llm/byok-guard.ts +39 -0
- package/src/lib/llm/free-models.test.ts +0 -6
- package/src/lib/llm/models.ts +104 -42
- package/src/lib/llm/stream-client.ts +74 -110
- package/src/lib/llm/stream-direct.test.ts +130 -0
- package/src/lib/llm/stream-direct.ts +316 -0
- package/src/lib/llm/system-prompt.test.ts +14 -0
- package/src/lib/llm/system-prompt.ts +102 -1
- package/src/lib/llm/types.ts +20 -2
- package/src/lib/recent-files.ts +38 -4
- package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
- package/src/lib/scripts/templates/construction-schedule.ts +223 -0
- package/src/lib/scripts/templates.ts +7 -0
- package/src/lib/search/common-ifc-types.ts +36 -0
- package/src/lib/search/filter-evaluate.test.ts +537 -0
- package/src/lib/search/filter-evaluate.ts +610 -0
- package/src/lib/search/filter-rules.test.ts +119 -0
- package/src/lib/search/filter-rules.ts +198 -0
- package/src/lib/search/filter-schema.test.ts +233 -0
- package/src/lib/search/filter-schema.ts +146 -0
- package/src/lib/search/recent-searches.test.ts +116 -0
- package/src/lib/search/recent-searches.ts +93 -0
- package/src/lib/search/result-export.test.ts +101 -0
- package/src/lib/search/result-export.ts +104 -0
- package/src/lib/search/saved-filters.test.ts +118 -0
- package/src/lib/search/saved-filters.ts +154 -0
- package/src/lib/search/tier0-scan.test.ts +196 -0
- package/src/lib/search/tier0-scan.ts +237 -0
- package/src/lib/search/tier1-index.test.ts +242 -0
- package/src/lib/search/tier1-index.ts +448 -0
- package/src/main.tsx +1 -10
- package/src/sdk/adapters/export-adapter.test.ts +434 -1
- package/src/sdk/adapters/export-adapter.ts +404 -1
- package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
- package/src/sdk/adapters/export-schedule-splice.ts +87 -0
- package/src/sdk/adapters/model-compat.ts +8 -2
- package/src/sdk/adapters/schedule-adapter.ts +73 -0
- package/src/sdk/adapters/store-adapter.ts +201 -0
- package/src/sdk/adapters/visibility-adapter.ts +3 -0
- package/src/sdk/local-backend.ts +16 -8
- package/src/services/api-keys.ts +73 -0
- package/src/services/desktop-export.ts +3 -1
- package/src/services/desktop-native-metadata.ts +41 -18
- package/src/services/file-dialog.ts +4 -1
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/basketVisibleSet.ts +3 -0
- package/src/store/constants.ts +20 -2
- package/src/store/globalId.ts +4 -1
- package/src/store/index.ts +82 -6
- package/src/store/slices/addElementMeshes.ts +365 -0
- package/src/store/slices/addElementSlice.ts +275 -0
- package/src/store/slices/annotationsSlice.test.ts +133 -0
- package/src/store/slices/annotationsSlice.ts +251 -0
- package/src/store/slices/cesiumSlice.ts +5 -0
- package/src/store/slices/chatSlice.test.ts +6 -76
- package/src/store/slices/chatSlice.ts +17 -58
- package/src/store/slices/dataSlice.test.ts +23 -4
- package/src/store/slices/dataSlice.ts +1 -1
- package/src/store/slices/modelSlice.test.ts +67 -9
- package/src/store/slices/modelSlice.ts +39 -7
- package/src/store/slices/mutationSlice.ts +964 -3
- package/src/store/slices/overlayCompositor.test.ts +164 -0
- package/src/store/slices/overlaySlice.test.ts +93 -0
- package/src/store/slices/overlaySlice.ts +151 -0
- package/src/store/slices/pinboardSlice.test.ts +6 -1
- package/src/store/slices/playbackSlice.ts +128 -0
- package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
- package/src/store/slices/schedule-edit-helpers.ts +179 -0
- package/src/store/slices/scheduleSlice.test.ts +694 -0
- package/src/store/slices/scheduleSlice.ts +1330 -0
- package/src/store/slices/searchSlice.test.ts +342 -0
- package/src/store/slices/searchSlice.ts +341 -0
- package/src/store/slices/sectionSlice.test.ts +87 -7
- package/src/store/slices/sectionSlice.ts +151 -5
- package/src/store/slices/selectionSlice.test.ts +46 -0
- package/src/store/slices/selectionSlice.ts +20 -0
- package/src/store/slices/uiSlice.ts +28 -5
- package/src/store/types.ts +26 -0
- package/src/store.ts +14 -0
- package/src/utils/nativeSpatialDataStore.ts +4 -1
- package/src/utils/viewportUtils.ts +7 -2
- package/src/vite-env.d.ts +0 -4
- package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
- package/dist/assets/ids-B4jTqB1O.js +0 -1
- package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
- package/dist/assets/index-DckuDqlv.css +0 -1
- package/dist/assets/sandbox-DZiNLNMk.js +0 -5933
- package/src/components/viewer/UpgradePage.tsx +0 -71
- package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
- package/src/lib/llm/ClerkChatSync.tsx +0 -74
- 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
|
|
package/src/lib/llm/types.ts
CHANGED
|
@@ -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' | '
|
|
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 (
|
|
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';
|
package/src/lib/recent-files.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
39
|
-
const existing = getRecentFiles().filter(f => !
|
|
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 {
|
|
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();
|