@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.
- package/CHANGELOG.md +35 -0
- package/dist/assets/{Arrow.dom-CSgnLhN4.js → Arrow.dom-_vGzMMKs.js} +1 -1
- package/dist/assets/basketViewActivator-BZcoCL3V.js +1 -0
- package/dist/assets/{browser-qSKWrKQW.js → browser-Czmf34bo.js} +1 -1
- package/dist/assets/ifc-lite_bg-DyBKoGgk.wasm +0 -0
- package/dist/assets/index-CMQ_Dgkr.css +1 -0
- package/dist/assets/index-D7nEDctQ.js +229 -0
- package/dist/assets/{index-4Y4XaV8N.js → index-DX-Qf5fA.js} +72669 -61673
- package/dist/assets/{native-bridge-CSFDsEkg.js → native-bridge-DAOWftxE.js} +1 -1
- package/dist/assets/{wasm-bridge-Zf90ysEm.js → wasm-bridge-D7jYpn8a.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +21 -20
- package/src/App.tsx +17 -1
- package/src/components/viewer/BasketPresentationDock.tsx +8 -4
- package/src/components/viewer/ChatPanel.tsx +1402 -0
- package/src/components/viewer/CodeEditor.tsx +70 -4
- package/src/components/viewer/CommandPalette.tsx +1 -0
- package/src/components/viewer/HierarchyPanel.tsx +28 -13
- package/src/components/viewer/MainToolbar.tsx +113 -95
- package/src/components/viewer/ScriptPanel.tsx +351 -184
- package/src/components/viewer/UpgradePage.tsx +69 -0
- package/src/components/viewer/Viewport.tsx +23 -0
- package/src/components/viewer/chat/ChatMessage.tsx +144 -0
- package/src/components/viewer/chat/ExecutableCodeBlock.tsx +416 -0
- package/src/components/viewer/chat/ModelSelector.tsx +102 -0
- package/src/components/viewer/chat/renderTextContent.test.ts +23 -0
- package/src/components/viewer/chat/renderTextContent.ts +19 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +10 -3
- package/src/components/viewer/hierarchy/treeDataBuilder.test.ts +126 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +139 -38
- package/src/components/viewer/hierarchy/types.ts +6 -1
- package/src/components/viewer/hierarchy/useHierarchyTree.ts +27 -12
- package/src/hooks/useIfcCache.ts +1 -2
- package/src/hooks/useSandbox.ts +122 -6
- package/src/index.css +10 -0
- package/src/lib/attachments.ts +46 -0
- package/src/lib/llm/ClerkChatSync.tsx +74 -0
- package/src/lib/llm/clerk-auth.ts +62 -0
- package/src/lib/llm/code-extractor.ts +50 -0
- package/src/lib/llm/context-builder.test.ts +18 -0
- package/src/lib/llm/context-builder.ts +305 -0
- package/src/lib/llm/free-models.test.ts +118 -0
- package/src/lib/llm/message-capabilities.test.ts +131 -0
- package/src/lib/llm/message-capabilities.ts +94 -0
- package/src/lib/llm/models.ts +197 -0
- package/src/lib/llm/repair-loop.test.ts +91 -0
- package/src/lib/llm/repair-loop.ts +76 -0
- package/src/lib/llm/script-diagnostics.ts +445 -0
- package/src/lib/llm/script-edit-ops.test.ts +399 -0
- package/src/lib/llm/script-edit-ops.ts +954 -0
- package/src/lib/llm/script-preflight.test.ts +513 -0
- package/src/lib/llm/script-preflight.ts +990 -0
- package/src/lib/llm/script-preservation.test.ts +128 -0
- package/src/lib/llm/script-preservation.ts +152 -0
- package/src/lib/llm/stream-client.test.ts +97 -0
- package/src/lib/llm/stream-client.ts +410 -0
- package/src/lib/llm/system-prompt.test.ts +181 -0
- package/src/lib/llm/system-prompt.ts +665 -0
- package/src/lib/llm/types.ts +150 -0
- package/src/lib/scripts/templates/bim-globals.d.ts +226 -7
- package/src/lib/scripts/templates/create-building.ts +12 -12
- package/src/main.tsx +10 -1
- package/src/sdk/adapters/export-adapter.test.ts +24 -0
- package/src/sdk/adapters/export-adapter.ts +40 -16
- package/src/sdk/adapters/files-adapter.ts +39 -0
- package/src/sdk/adapters/model-compat.ts +1 -1
- package/src/sdk/adapters/mutate-adapter.ts +20 -6
- package/src/sdk/adapters/mutation-view.ts +112 -0
- package/src/sdk/adapters/query-adapter.ts +100 -4
- package/src/sdk/local-backend.ts +4 -0
- package/src/store/index.ts +15 -1
- package/src/store/slices/chatSlice.test.ts +325 -0
- package/src/store/slices/chatSlice.ts +468 -0
- package/src/store/slices/scriptSlice.test.ts +75 -0
- package/src/store/slices/scriptSlice.ts +256 -9
- package/src/vite-env.d.ts +10 -0
- package/vite.config.ts +21 -2
- package/dist/assets/ifc-lite_bg-BOvNXJA_.wasm +0 -0
- package/dist/assets/index-ByrFvN5A.css +0 -1
- package/dist/assets/index-CN7qDq7G.js +0 -216
|
@@ -0,0 +1,410 @@
|
|
|
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
|
+
* Streaming client for the LLM chat proxy.
|
|
7
|
+
*
|
|
8
|
+
* Sends chat messages to the Edge proxy and streams the response
|
|
9
|
+
* back as SSE. Extracts usage headers from the response for UI display.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A text content part in a multimodal message */
|
|
13
|
+
export interface TextContentPart {
|
|
14
|
+
type: 'text';
|
|
15
|
+
text: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** An image content part in a multimodal message */
|
|
19
|
+
export interface ImageContentPart {
|
|
20
|
+
type: 'image_url';
|
|
21
|
+
image_url: { url: string };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type MessageContent = string | Array<TextContentPart | ImageContentPart>;
|
|
25
|
+
|
|
26
|
+
export interface StreamMessage {
|
|
27
|
+
role: 'user' | 'assistant' | 'system';
|
|
28
|
+
content: MessageContent;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Usage info extracted from proxy response headers */
|
|
32
|
+
export interface UsageInfo {
|
|
33
|
+
/** 'credits' for pro, 'requests' for free */
|
|
34
|
+
type: 'credits' | 'requests';
|
|
35
|
+
/** Amount used: credits consumed (pro) or request count (free) */
|
|
36
|
+
used: number;
|
|
37
|
+
/** Limit: credit allowance (pro) or request cap (free) */
|
|
38
|
+
limit: number;
|
|
39
|
+
/** Percentage used (0-100) */
|
|
40
|
+
pct: number;
|
|
41
|
+
/** Reset time (epoch seconds) */
|
|
42
|
+
resetAt: number;
|
|
43
|
+
/** Whether this request can consume credits (pro paid model) */
|
|
44
|
+
billable?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface StreamOptions {
|
|
48
|
+
/** Proxy URL (Edge Function) */
|
|
49
|
+
proxyUrl: string;
|
|
50
|
+
/** Model ID */
|
|
51
|
+
model: string;
|
|
52
|
+
/** Conversation messages */
|
|
53
|
+
messages: StreamMessage[];
|
|
54
|
+
/** System prompt */
|
|
55
|
+
system?: string;
|
|
56
|
+
/** Auth JWT */
|
|
57
|
+
authToken?: string | null;
|
|
58
|
+
/** AbortSignal for cancellation */
|
|
59
|
+
signal?: AbortSignal;
|
|
60
|
+
/** Called for each text chunk as it arrives */
|
|
61
|
+
onChunk: (text: string) => void;
|
|
62
|
+
/** Called when the stream completes */
|
|
63
|
+
onComplete: (fullText: string) => void;
|
|
64
|
+
/** Called with the model/provider finish reason when available */
|
|
65
|
+
onFinishReason?: (finishReason: string | null) => void;
|
|
66
|
+
/** Called on error */
|
|
67
|
+
onError: (error: Error) => void;
|
|
68
|
+
/** Called with usage info from response headers */
|
|
69
|
+
onUsageInfo?: (usage: UsageInfo) => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const STREAM_REQUEST_TIMEOUT_MS = 45_000;
|
|
73
|
+
|
|
74
|
+
function parseUsageFromHeaders(headers: Headers): UsageInfo | null {
|
|
75
|
+
const creditsUsed = parseInt(headers.get('X-Credits-Used') ?? '0', 10);
|
|
76
|
+
const creditsLimit = parseInt(headers.get('X-Credits-Limit') ?? '0', 10);
|
|
77
|
+
const usageUsed = parseInt(headers.get('X-Usage-Used') ?? '0', 10);
|
|
78
|
+
const usageLimit = parseInt(headers.get('X-Usage-Limit') ?? '0', 10);
|
|
79
|
+
|
|
80
|
+
if (creditsLimit > 0) {
|
|
81
|
+
const billable = headers.get('X-Credits-Billable');
|
|
82
|
+
return {
|
|
83
|
+
type: 'credits',
|
|
84
|
+
used: creditsUsed,
|
|
85
|
+
limit: creditsLimit,
|
|
86
|
+
pct: parseInt(headers.get('X-Credits-Pct') ?? '0', 10),
|
|
87
|
+
resetAt: parseInt(headers.get('X-Credits-Reset') ?? '0', 10),
|
|
88
|
+
billable: billable === null ? undefined : billable === 'true',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (usageLimit > 0) {
|
|
93
|
+
return {
|
|
94
|
+
type: 'requests',
|
|
95
|
+
used: usageUsed,
|
|
96
|
+
limit: usageLimit,
|
|
97
|
+
pct: parseInt(headers.get('X-Usage-Pct') ?? '0', 10),
|
|
98
|
+
resetAt: parseInt(headers.get('X-Usage-Reset') ?? '0', 10),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function drainSseBuffer(buffer: string, flush: boolean = false): { events: string[]; remainder: string } {
|
|
106
|
+
if (flush) {
|
|
107
|
+
const trimmed = buffer.trim();
|
|
108
|
+
return {
|
|
109
|
+
events: trimmed ? trimmed.split('\n\n').filter(Boolean) : [],
|
|
110
|
+
remainder: '',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const parts = buffer.split('\n\n');
|
|
114
|
+
return {
|
|
115
|
+
events: parts.slice(0, -1).filter(Boolean),
|
|
116
|
+
remainder: parts.at(-1) ?? '',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Fetch current usage snapshot without sending a chat message.
|
|
122
|
+
* Used for instant UI hydration and periodic refresh.
|
|
123
|
+
*/
|
|
124
|
+
export async function fetchUsageSnapshot(proxyUrl: string, authToken?: string | null): Promise<UsageInfo | null> {
|
|
125
|
+
const isDev = Boolean((import.meta as unknown as { env?: Record<string, unknown> }).env?.DEV);
|
|
126
|
+
const headers: Record<string, string> = {};
|
|
127
|
+
if (authToken) {
|
|
128
|
+
headers['Authorization'] = `Bearer ${authToken}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const snapshotUrl = `${proxyUrl}${proxyUrl.includes('?') ? '&' : '?'}usage=1`;
|
|
132
|
+
const appSnapshotUrl = '/api/chat?usage=1';
|
|
133
|
+
const canFallbackToAppProxy = isDev && snapshotUrl !== appSnapshotUrl;
|
|
134
|
+
const fetchSnapshot = (url: string) => fetch(url, { method: 'GET', headers });
|
|
135
|
+
|
|
136
|
+
let response: Response;
|
|
137
|
+
try {
|
|
138
|
+
response = await fetchSnapshot(snapshotUrl);
|
|
139
|
+
} catch {
|
|
140
|
+
if (!canFallbackToAppProxy) return null;
|
|
141
|
+
try {
|
|
142
|
+
response = await fetchSnapshot(appSnapshotUrl);
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!response.ok && response.status === 404 && canFallbackToAppProxy) {
|
|
149
|
+
try {
|
|
150
|
+
const retry = await fetchSnapshot(appSnapshotUrl);
|
|
151
|
+
if (retry.ok || retry.status !== 404) {
|
|
152
|
+
response = retry;
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// keep original response
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!response.ok) return null;
|
|
160
|
+
return parseUsageFromHeaders(response.headers);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Stream a chat completion from the LLM proxy.
|
|
165
|
+
* Parses SSE format (data: {...}\n\n).
|
|
166
|
+
*/
|
|
167
|
+
export async function streamChat(options: StreamOptions): Promise<void> {
|
|
168
|
+
const { proxyUrl, model, messages, system, authToken, signal, onChunk, onComplete, onError, onUsageInfo, onFinishReason } = options;
|
|
169
|
+
const isDev = Boolean((import.meta as unknown as { env?: Record<string, unknown> }).env?.DEV);
|
|
170
|
+
|
|
171
|
+
const headers: Record<string, string> = {
|
|
172
|
+
'Content-Type': 'application/json',
|
|
173
|
+
};
|
|
174
|
+
if (authToken) {
|
|
175
|
+
headers['Authorization'] = `Bearer ${authToken}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const requestBody = JSON.stringify({ messages, model, system });
|
|
179
|
+
const fetchChat = async (url: string) => {
|
|
180
|
+
const controller = new AbortController();
|
|
181
|
+
const timeoutId = setTimeout(() => controller.abort(new Error('Chat request timed out. Please try again.')), STREAM_REQUEST_TIMEOUT_MS);
|
|
182
|
+
const abortFromParent = () => controller.abort(signal?.reason);
|
|
183
|
+
if (signal) {
|
|
184
|
+
if (signal.aborted) {
|
|
185
|
+
clearTimeout(timeoutId);
|
|
186
|
+
controller.abort(signal.reason);
|
|
187
|
+
} else {
|
|
188
|
+
signal.addEventListener('abort', abortFromParent, { once: true });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
return await fetch(url, {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
headers,
|
|
195
|
+
body: requestBody,
|
|
196
|
+
signal: controller.signal,
|
|
197
|
+
});
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (controller.signal.aborted && !signal?.aborted && controller.signal.reason instanceof Error) {
|
|
200
|
+
throw controller.signal.reason;
|
|
201
|
+
}
|
|
202
|
+
throw error;
|
|
203
|
+
} finally {
|
|
204
|
+
clearTimeout(timeoutId);
|
|
205
|
+
signal?.removeEventListener('abort', abortFromParent);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
const canFallbackToAppProxy = isDev && proxyUrl !== '/api/chat';
|
|
209
|
+
|
|
210
|
+
let response: Response;
|
|
211
|
+
try {
|
|
212
|
+
response = await fetchChat(proxyUrl);
|
|
213
|
+
} catch (err) {
|
|
214
|
+
if (signal?.aborted) return;
|
|
215
|
+
if (!canFallbackToAppProxy) {
|
|
216
|
+
onError(err instanceof Error ? err : new Error(String(err)));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
// Local dev resilience: if direct API URL is down/unreachable, retry once
|
|
220
|
+
// through app-relative proxy path.
|
|
221
|
+
try {
|
|
222
|
+
response = await fetchChat('/api/chat');
|
|
223
|
+
} catch (fallbackErr) {
|
|
224
|
+
if (signal?.aborted) return;
|
|
225
|
+
onError(fallbackErr instanceof Error ? fallbackErr : new Error(String(fallbackErr)));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Local dev resilience: if direct API URL 404s (common when vercel dev
|
|
231
|
+
// port/process changes), retry once through the app proxy path.
|
|
232
|
+
if (!response.ok && response.status === 404 && canFallbackToAppProxy) {
|
|
233
|
+
try {
|
|
234
|
+
const retry = await fetchChat('/api/chat');
|
|
235
|
+
if (retry.ok || retry.status !== 404) {
|
|
236
|
+
response = retry;
|
|
237
|
+
}
|
|
238
|
+
} catch {
|
|
239
|
+
// ignore fallback failure, original response handling below will surface error
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!response.ok) {
|
|
244
|
+
let errorDetail = `HTTP ${response.status}`;
|
|
245
|
+
try {
|
|
246
|
+
const errorBody = await response.json() as {
|
|
247
|
+
error?: string;
|
|
248
|
+
code?: string;
|
|
249
|
+
providerMessage?: string;
|
|
250
|
+
model?: string;
|
|
251
|
+
type?: string;
|
|
252
|
+
upgrade?: boolean;
|
|
253
|
+
contactEmail?: string;
|
|
254
|
+
resetAt?: number;
|
|
255
|
+
};
|
|
256
|
+
errorDetail = errorBody.error || errorDetail;
|
|
257
|
+
|
|
258
|
+
if (response.status === 403 && errorBody.upgrade) {
|
|
259
|
+
errorDetail = 'Upgrade to Pro to use this model.';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (response.status === 401) {
|
|
263
|
+
errorDetail = 'Authentication expired. Please sign out and sign in again.';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (response.status === 429) {
|
|
267
|
+
if (errorBody.type === 'credits') {
|
|
268
|
+
const contactEmail = errorBody.contactEmail as string | undefined;
|
|
269
|
+
const contactSuffix = contactEmail ? ` Need more? Reach out at ${contactEmail}.` : '';
|
|
270
|
+
errorDetail = `Monthly credits used up. Resets ${errorBody.resetAt ? new Date(errorBody.resetAt).toLocaleDateString() : 'next month'}.${contactSuffix}`;
|
|
271
|
+
} else if (errorBody.type === 'request_cap') {
|
|
272
|
+
errorDetail = errorBody.error || 'Daily limit reached. Upgrade to Pro for more.';
|
|
273
|
+
} else {
|
|
274
|
+
errorDetail = errorBody.error || 'Limit reached. Please try again later.';
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (response.status === 502 && errorBody.code === 'provider_model_not_found') {
|
|
279
|
+
const providerMessage = errorBody.providerMessage?.trim();
|
|
280
|
+
const modelLabel = errorBody.model ? ` ${errorBody.model}` : '';
|
|
281
|
+
if (providerMessage) {
|
|
282
|
+
errorDetail = `Provider routing unavailable for${modelLabel}. ${providerMessage}`;
|
|
283
|
+
} else {
|
|
284
|
+
errorDetail = `Provider routing unavailable for${modelLabel}. Try again shortly or switch model.`;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (errorBody.code === 'provider_error' && errorBody.providerMessage) {
|
|
289
|
+
errorDetail = `${errorBody.error ?? `Request failed (${response.status})`}\nProvider: ${errorBody.providerMessage}`;
|
|
290
|
+
}
|
|
291
|
+
} catch {
|
|
292
|
+
// ignore parse failure
|
|
293
|
+
}
|
|
294
|
+
onError(new Error(errorDetail));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Extract usage info from response headers
|
|
299
|
+
if (onUsageInfo) {
|
|
300
|
+
const usage = parseUsageFromHeaders(response.headers);
|
|
301
|
+
if (usage) {
|
|
302
|
+
onUsageInfo(usage);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!response.body) {
|
|
307
|
+
onError(new Error('No response body'));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Parse SSE stream
|
|
312
|
+
const reader = response.body.getReader();
|
|
313
|
+
const decoder = new TextDecoder();
|
|
314
|
+
let buffer = '';
|
|
315
|
+
let fullText = '';
|
|
316
|
+
let finishReason: string | null = null;
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
while (true) {
|
|
320
|
+
const { done, value } = await reader.read();
|
|
321
|
+
if (done) break;
|
|
322
|
+
|
|
323
|
+
buffer += decoder.decode(value, { stream: true });
|
|
324
|
+
|
|
325
|
+
const drained = drainSseBuffer(buffer);
|
|
326
|
+
buffer = drained.remainder;
|
|
327
|
+
|
|
328
|
+
for (const event of drained.events) {
|
|
329
|
+
for (const line of event.split('\n')) {
|
|
330
|
+
if (!line.startsWith('data: ')) continue;
|
|
331
|
+
const data = line.slice(6);
|
|
332
|
+
|
|
333
|
+
if (data === '[DONE]') continue;
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
const parsed = JSON.parse(data) as {
|
|
337
|
+
__ifcLiteUsage?: UsageInfo;
|
|
338
|
+
choices?: Array<{
|
|
339
|
+
delta?: { content?: string };
|
|
340
|
+
finish_reason?: string | null;
|
|
341
|
+
}>;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// Final usage update emitted by proxy after stream-end reconciliation.
|
|
345
|
+
if (parsed.__ifcLiteUsage && onUsageInfo) {
|
|
346
|
+
onUsageInfo(parsed.__ifcLiteUsage);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const content = parsed.choices?.[0]?.delta?.content;
|
|
351
|
+
if (content) {
|
|
352
|
+
fullText += content;
|
|
353
|
+
onChunk(content);
|
|
354
|
+
}
|
|
355
|
+
const chunkFinishReason = parsed.choices?.[0]?.finish_reason;
|
|
356
|
+
if (chunkFinishReason) {
|
|
357
|
+
finishReason = chunkFinishReason;
|
|
358
|
+
}
|
|
359
|
+
} catch {
|
|
360
|
+
// Skip malformed SSE lines
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
buffer += decoder.decode();
|
|
366
|
+
const drained = drainSseBuffer(buffer, true);
|
|
367
|
+
for (const event of drained.events) {
|
|
368
|
+
for (const line of event.split('\n')) {
|
|
369
|
+
if (!line.startsWith('data: ')) continue;
|
|
370
|
+
const data = line.slice(6);
|
|
371
|
+
|
|
372
|
+
if (data === '[DONE]') continue;
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
const parsed = JSON.parse(data) as {
|
|
376
|
+
__ifcLiteUsage?: UsageInfo;
|
|
377
|
+
choices?: Array<{
|
|
378
|
+
delta?: { content?: string };
|
|
379
|
+
finish_reason?: string | null;
|
|
380
|
+
}>;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
if (parsed.__ifcLiteUsage && onUsageInfo) {
|
|
384
|
+
onUsageInfo(parsed.__ifcLiteUsage);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const content = parsed.choices?.[0]?.delta?.content;
|
|
389
|
+
if (content) {
|
|
390
|
+
fullText += content;
|
|
391
|
+
onChunk(content);
|
|
392
|
+
}
|
|
393
|
+
const chunkFinishReason = parsed.choices?.[0]?.finish_reason;
|
|
394
|
+
if (chunkFinishReason) {
|
|
395
|
+
finishReason = chunkFinishReason;
|
|
396
|
+
}
|
|
397
|
+
} catch {
|
|
398
|
+
// Skip malformed SSE lines
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
} catch (err) {
|
|
403
|
+
if (signal?.aborted) return;
|
|
404
|
+
onError(err instanceof Error ? err : new Error(String(err)));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
onFinishReason?.(finishReason);
|
|
409
|
+
onComplete(fullText);
|
|
410
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
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 { NAMESPACE_SCHEMAS } from '@ifc-lite/sandbox/schema';
|
|
8
|
+
import { buildSystemPrompt } from './system-prompt.js';
|
|
9
|
+
import { createPatchDiagnostic } from './script-diagnostics.js';
|
|
10
|
+
|
|
11
|
+
test('system prompt includes all schema namespaces and methods', () => {
|
|
12
|
+
const prompt = buildSystemPrompt();
|
|
13
|
+
|
|
14
|
+
for (const schema of NAMESPACE_SCHEMAS) {
|
|
15
|
+
assert.match(
|
|
16
|
+
prompt,
|
|
17
|
+
new RegExp(`###\\s+bim\\.${schema.name}\\s+—`),
|
|
18
|
+
`Missing namespace heading for bim.${schema.name}`,
|
|
19
|
+
);
|
|
20
|
+
for (const method of schema.methods) {
|
|
21
|
+
assert.match(
|
|
22
|
+
prompt,
|
|
23
|
+
new RegExp(`bim\\.${schema.name}\\.${method.name}\\(`),
|
|
24
|
+
`Missing method reference for bim.${schema.name}.${method.name}()`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('system prompt includes script editor revision context when provided', () => {
|
|
31
|
+
const prompt = buildSystemPrompt(undefined, undefined, {
|
|
32
|
+
content: 'const n = 1;',
|
|
33
|
+
revision: 42,
|
|
34
|
+
selection: { from: 6, to: 7 },
|
|
35
|
+
});
|
|
36
|
+
assert.match(prompt, /Current script revision:\s+42/);
|
|
37
|
+
assert.match(prompt, /Current selection:\s+from=6, to=7/);
|
|
38
|
+
assert.match(prompt, /```ifc-script-edits/);
|
|
39
|
+
assert.match(prompt, /<<<<<<< SEARCH/);
|
|
40
|
+
assert.match(prompt, /Copy every SEARCH block exactly from the CURRENT script/);
|
|
41
|
+
assert.match(prompt, /Each SEARCH block must match exactly one location/);
|
|
42
|
+
assert.match(prompt, /The system also understands legacy JSON edit ops, but SEARCH\/REPLACE is the default/);
|
|
43
|
+
assert.match(prompt, /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\./);
|
|
44
|
+
assert.match(prompt, /Do NOT answer with a detached snippet/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('system prompt includes storey hierarchy context when provided', () => {
|
|
48
|
+
const prompt = buildSystemPrompt({
|
|
49
|
+
models: [{ name: 'Tower', entityCount: 500 }],
|
|
50
|
+
typeCounts: { IfcWall: 120 },
|
|
51
|
+
selectedCount: 0,
|
|
52
|
+
storeys: [
|
|
53
|
+
{ modelName: 'Tower', name: 'Level 01', elevation: 0, height: 3.5, elementCount: 42 },
|
|
54
|
+
{ modelName: 'Tower', name: 'Level 02', elevation: 3.5, height: 3.5, elementCount: 40 },
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
assert.match(prompt, /CURRENT MODEL STATE/);
|
|
58
|
+
assert.match(prompt, /Storeys: Tower: Level 01 @ 0m, height≈3.5m, elements=42 \| Tower: Level 02 @ 3.5m, height≈3.5m, elements=40/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('system prompt includes selected entity IFC context when provided', () => {
|
|
62
|
+
const prompt = buildSystemPrompt({
|
|
63
|
+
models: [{ name: 'Tower', entityCount: 500 }],
|
|
64
|
+
typeCounts: { IfcWall: 120 },
|
|
65
|
+
selectedCount: 2,
|
|
66
|
+
selectedEntities: [
|
|
67
|
+
{
|
|
68
|
+
modelName: 'Tower',
|
|
69
|
+
name: 'Facade Panel A',
|
|
70
|
+
type: 'IfcCurtainWall',
|
|
71
|
+
selectionKind: 'occurrence',
|
|
72
|
+
storeyName: 'Level 10',
|
|
73
|
+
storeyElevation: 31.5,
|
|
74
|
+
propertySets: ['Pset_CurtainWallCommon'],
|
|
75
|
+
typePropertySets: ['Pset_CurtainWallTypeCommon'],
|
|
76
|
+
quantitySets: ['Qto_CurtainWallBaseQuantities'],
|
|
77
|
+
materialName: 'Aluminium',
|
|
78
|
+
classifications: ['A-123'],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
modelName: 'Tower',
|
|
82
|
+
name: 'Exterior Wall Type',
|
|
83
|
+
type: 'IfcWallType',
|
|
84
|
+
selectionKind: 'type',
|
|
85
|
+
propertySets: ['Pset_WallCommon'],
|
|
86
|
+
classifications: ['A-WALL'],
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
assert.match(prompt, /2 entities currently selected in the viewer/);
|
|
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
|
+
});
|
|
93
|
+
|
|
94
|
+
test('system prompt includes method-specific create contract guidance', () => {
|
|
95
|
+
const prompt = buildSystemPrompt();
|
|
96
|
+
assert.match(prompt, /BIM\.CREATE CONTRACT CHEAT SHEET/);
|
|
97
|
+
assert.match(prompt, /addIfcRoof.*mono-pitch roof slab.*Do NOT use `Profile`, `Height`, or `ExtrusionHeight`/s);
|
|
98
|
+
assert.match(prompt, /addIfcGableRoof.*dual-pitch house roofs/s);
|
|
99
|
+
assert.match(prompt, /addIfcWallDoor.*wall-local `Position`/s);
|
|
100
|
+
assert.match(prompt, /addIfcWallWindow.*wall-local `Position`/s);
|
|
101
|
+
assert.match(prompt, /Wall-hosted openings: use `Openings` inside `addIfcWall/);
|
|
102
|
+
assert.match(prompt, /resolve the target storeys first and then add geometry to EACH intended storey/);
|
|
103
|
+
assert.match(prompt, /When CURRENT MODEL STATE includes storeys, use those storey names\/elevations as the source of truth/);
|
|
104
|
+
assert.match(prompt, /inspect the actual model first.*bim\.query\.selection\(\).*bim\.query\.storeys\(\).*bim\.query\.path\(entity\).*bim\.query\.materials\(entity\).*bim\.query\.classifications\(entity\)/s);
|
|
105
|
+
assert.match(prompt, /Materials are usually NOT ordinary property-set values/);
|
|
106
|
+
assert.match(prompt, /Prefer `bim\.query\.classifications\(entity\)` over guessing ad-hoc classification properties/);
|
|
107
|
+
assert.match(prompt, /const material = bim\.query\.materials\(wall\);/);
|
|
108
|
+
assert.match(prompt, /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/);
|
|
109
|
+
assert.match(prompt, /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/);
|
|
110
|
+
assert.match(prompt, /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/);
|
|
111
|
+
assert.match(prompt, /IFC export preserves edits to type-owned property sets when you export after applying mutations/);
|
|
112
|
+
assert.match(prompt, /addIfcDoor` and `addIfcWindow`: these create standalone world-aligned elements/);
|
|
113
|
+
assert.match(prompt, /If doors or windows appear rotated 90° relative to a wall/);
|
|
114
|
+
assert.match(prompt, /If repeated elements appear only at one level/);
|
|
115
|
+
assert.match(prompt, /house, pitched-roof, or gable-roof requests, prefer `addIfcGableRoof`/);
|
|
116
|
+
assert.match(prompt, /If the user asks for a house roof, pitched roof, or gable roof, default to `addIfcGableRoof`/);
|
|
117
|
+
assert.match(prompt, /convert it to radians first .*addIfcRoof.*addIfcGableRoof/s);
|
|
118
|
+
assert.match(prompt, /addElement.*Use `IfcType`, `Placement:/s);
|
|
119
|
+
assert.match(prompt, /Use `IfcType` not `Type`; use `Placement` not `Position`/);
|
|
120
|
+
assert.match(prompt, /Many advanced methods are world-placement based/);
|
|
121
|
+
assert.match(prompt, /addIfcPlate.*`Position`, `Width`, `Depth`, `Thickness`/);
|
|
122
|
+
assert.match(prompt, /Mixed multi-level scripts often combine both/);
|
|
123
|
+
assert.match(prompt, /those calls should usually use `elevation`.*`Start`.*`End`.*`Position`/s);
|
|
124
|
+
assert.match(prompt, /const elevation = i \* storeyHeight;/);
|
|
125
|
+
assert.match(prompt, /When a fix targets an existing script, preserve the project handle, storey handles, loop variables/);
|
|
126
|
+
assert.match(prompt, /If a previous repair was rejected for losing context, keep the full script intact/);
|
|
127
|
+
assert.match(prompt, /If repeated world-placement elements stack at the base level/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('system prompt adapts task focus for repair turns', () => {
|
|
131
|
+
const prompt = buildSystemPrompt(undefined, undefined, {
|
|
132
|
+
content: 'const wall = bim.create.addIfcWall(h, storey, { Start: [0,0,0] });',
|
|
133
|
+
revision: 7,
|
|
134
|
+
selection: { from: 0, to: 0 },
|
|
135
|
+
}, {
|
|
136
|
+
userPrompt: 'fix the revision conflict and keep the current script',
|
|
137
|
+
diagnostics: [
|
|
138
|
+
createPatchDiagnostic(
|
|
139
|
+
'patch_revision_conflict',
|
|
140
|
+
'Edit ops targeted revision 3 but the current editor revision is 4.',
|
|
141
|
+
'error',
|
|
142
|
+
{ attemptedOpIds: ['declare-width-depth'] },
|
|
143
|
+
),
|
|
144
|
+
],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
assert.match(prompt, /## CURRENT TASK FOCUS/);
|
|
148
|
+
assert.match(prompt, /Primary intent: `repair`/);
|
|
149
|
+
assert.match(prompt, /For repair turns, answer with patch blocks only and do not include a full runnable script fence/);
|
|
150
|
+
assert.match(prompt, /use them as anchors, but fix the stated root cause even if multiple related spans must change/);
|
|
151
|
+
assert.match(prompt, /Root causes:/);
|
|
152
|
+
assert.match(prompt, /\[root-cause:stale_patch_target\]/);
|
|
153
|
+
assert.match(prompt, /\[patch:patch_revision_conflict\] Edit ops targeted revision 3 but the current editor revision is 4/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('system prompt explains uploaded file runtime access', () => {
|
|
157
|
+
const prompt = buildSystemPrompt(undefined, [
|
|
158
|
+
{
|
|
159
|
+
id: 'entities-1',
|
|
160
|
+
name: 'entities-1.csv',
|
|
161
|
+
type: 'text/csv',
|
|
162
|
+
size: 256,
|
|
163
|
+
textContent: 'GlobalId,Description\nabc,Wall A\n',
|
|
164
|
+
csvColumns: ['GlobalId', 'Description'],
|
|
165
|
+
csvData: [{ GlobalId: 'abc', Description: 'Wall A' }],
|
|
166
|
+
},
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
assert.match(prompt, /UPLOADED FILES/);
|
|
170
|
+
assert.match(prompt, /bim\.files\.list\(\)/);
|
|
171
|
+
assert.match(prompt, /bim\.files\.csv\(name\)/);
|
|
172
|
+
assert.match(prompt, /Scripts can access the full attachment contents at runtime/);
|
|
173
|
+
assert.match(prompt, /Do not use `fetch\(\)` for chat attachments/);
|
|
174
|
+
assert.match(prompt, /Attachment-driven model edit \(common workflow\)/);
|
|
175
|
+
assert.match(prompt, /bim\.mutate\.setAttribute\(entity, "Description", byGuid\[guid\]\)/);
|
|
176
|
+
assert.match(prompt, /Type-level edit/);
|
|
177
|
+
assert.match(prompt, /const typeProps = bim\.query\.typeProperties\(wall\);/);
|
|
178
|
+
assert.match(prompt, /bim\.mutate\.setProperty\(typeProps\.type, "Pset_WallCommon", "Reference", "EXT"\)/);
|
|
179
|
+
assert.match(prompt, /bim\.export\.ifc\(entities, \{ filename: "updated\.ifc", includeMutations: true \}\)/);
|
|
180
|
+
assert.match(prompt, /Text preview:/);
|
|
181
|
+
});
|