@ifc-lite/viewer 1.17.4 → 1.17.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.turbo/turbo-build.log +16 -16
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +117 -0
  4. package/DESKTOP_CONTRACT_VERSION +1 -1
  5. package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-86rgogji.js} +1 -1
  6. package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
  7. package/dist/assets/{exporters-ChAtBmlj.js → exporters-CcPS9MK5.js} +2274 -2227
  8. package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-BFUYA08u.js} +1 -1
  9. package/dist/assets/ids-DQ5jY0E8.js +1 -0
  10. package/dist/assets/ifc-lite_bg-BINvzoCP.wasm +0 -0
  11. package/dist/assets/{index-Co8E2-FE.js → index-Bfms9I4A.js} +35160 -33084
  12. package/dist/assets/index-_bfZsDCC.css +1 -0
  13. package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-DUyLCMZS.js} +104 -104
  14. package/dist/assets/{sandbox-DZiNLNMk.js → sandbox-C8575tul.js} +4340 -4322
  15. package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-BuZK7OST.js} +1 -1
  16. package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-JsqEGDV8.js} +1 -1
  17. package/dist/index.html +8 -7
  18. package/index.html +1 -0
  19. package/package.json +7 -7
  20. package/src/App.tsx +16 -2
  21. package/src/components/viewer/CesiumOverlay.tsx +62 -19
  22. package/src/components/viewer/ChatPanel.tsx +195 -91
  23. package/src/components/viewer/MainToolbar.tsx +4 -3
  24. package/src/components/viewer/PropertiesPanel.tsx +16 -2
  25. package/src/components/viewer/SettingsPage.tsx +252 -101
  26. package/src/components/viewer/ThemeSwitch.tsx +63 -7
  27. package/src/components/viewer/ViewerLayout.tsx +1 -0
  28. package/src/components/viewer/Viewport.tsx +14 -2
  29. package/src/components/viewer/ViewportContainer.tsx +49 -64
  30. package/src/components/viewer/ViewportOverlays.tsx +5 -2
  31. package/src/components/viewer/bcf/BCFTopicDetail.tsx +4 -4
  32. package/src/components/viewer/chat/ModelSelector.tsx +90 -54
  33. package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
  34. package/src/components/viewer/properties/LocationMap.tsx +9 -7
  35. package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
  36. package/src/components/viewer/tools/SectionCapControls.tsx +237 -0
  37. package/src/components/viewer/tools/SectionPanel.tsx +39 -18
  38. package/src/components/viewer/useAnimationLoop.ts +9 -1
  39. package/src/components/viewer/useRenderUpdates.ts +1 -1
  40. package/src/hooks/ids/idsDataAccessor.ts +60 -24
  41. package/src/hooks/ingest/viewerModelIngest.ts +7 -2
  42. package/src/hooks/useIfcFederation.ts +326 -71
  43. package/src/hooks/useIfcLoader.ts +1 -0
  44. package/src/hooks/useViewControls.ts +13 -5
  45. package/src/index.css +484 -10
  46. package/src/lib/desktop-entitlement.ts +2 -4
  47. package/src/lib/geo/cesium-bridge.ts +15 -7
  48. package/src/lib/geo/effective-georef.test.ts +73 -0
  49. package/src/lib/geo/effective-georef.ts +111 -0
  50. package/src/lib/geo/reproject.ts +105 -19
  51. package/src/lib/llm/byok-guard.test.ts +77 -0
  52. package/src/lib/llm/byok-guard.ts +39 -0
  53. package/src/lib/llm/free-models.test.ts +0 -6
  54. package/src/lib/llm/models.ts +104 -42
  55. package/src/lib/llm/stream-client.ts +74 -110
  56. package/src/lib/llm/stream-direct.test.ts +130 -0
  57. package/src/lib/llm/stream-direct.ts +316 -0
  58. package/src/lib/llm/types.ts +14 -2
  59. package/src/main.tsx +1 -10
  60. package/src/services/api-keys.ts +73 -0
  61. package/src/store/constants.ts +20 -2
  62. package/src/store/index.ts +12 -5
  63. package/src/store/slices/cesiumSlice.ts +5 -0
  64. package/src/store/slices/chatSlice.test.ts +6 -76
  65. package/src/store/slices/chatSlice.ts +17 -58
  66. package/src/store/slices/sectionSlice.test.ts +87 -7
  67. package/src/store/slices/sectionSlice.ts +151 -5
  68. package/src/store/slices/uiSlice.ts +28 -5
  69. package/src/store/types.ts +26 -0
  70. package/src/utils/nativeSpatialDataStore.ts +4 -1
  71. package/src/utils/viewportUtils.ts +7 -2
  72. package/src/vite-env.d.ts +0 -4
  73. package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
  74. package/dist/assets/ids-B4jTqB1O.js +0 -1
  75. package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
  76. package/dist/assets/index-DckuDqlv.css +0 -1
  77. package/src/components/viewer/UpgradePage.tsx +0 -71
  78. package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
  79. package/src/lib/llm/ClerkChatSync.tsx +0 -74
  80. package/src/lib/llm/clerk-auth.ts +0 -62
@@ -29,7 +29,6 @@ import {
29
29
  ArrowDown,
30
30
  Zap,
31
31
  } from 'lucide-react';
32
- import { SignInButton, SignedIn, SignedOut, UserButton } from '@clerk/clerk-react';
33
32
  import { Button } from '@/components/ui/button';
34
33
  import { toast } from '@/components/ui/toast';
35
34
  import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -38,6 +37,7 @@ import { buildErrorFeedbackContent } from '@/store/slices/chatSlice';
38
37
  import { ChatMessageComponent } from './chat/ChatMessage';
39
38
  import { ModelSelector } from './chat/ModelSelector';
40
39
  import { fetchUsageSnapshot, streamChat, type StreamMessage, type TextContentPart, type ImageContentPart, type UsageInfo } from '@/lib/llm/stream-client';
40
+ import { streamAnthropicChat, streamOpenAiChat } from '@/lib/llm/stream-direct';
41
41
  import { buildStreamMessagesForModel, filterAttachmentsForModel } from '@/lib/llm/message-capabilities';
42
42
  import { buildSystemPrompt } from '@/lib/llm/system-prompt';
43
43
  import { getModelContext, parseCSV } from '@/lib/llm/context-builder';
@@ -49,11 +49,11 @@ import type { ScriptDiagnostic } from '@/lib/llm/script-diagnostics';
49
49
  import { buildRepairSessionKey, getEscalatedRepairScope, pruneMessagesForRepair } from '@/lib/llm/repair-loop';
50
50
  import type { ChatMessage, ChatRepairRequest, FileAttachment } from '@/lib/llm/types';
51
51
  import { canUsePlainCodeBlockFallback, type ScriptMutationIntent } from '@/lib/llm/script-preservation';
52
- import { Image as ImageIcon } from 'lucide-react';
53
- import { isClerkConfigured } from '@/lib/llm/clerk-auth';
54
- import { buildDesktopUpgradeUrl, hasDesktopFeatureAccess } from '@/lib/desktop-product';
55
- import { navigateToPath } from '@/services/app-navigation';
52
+ import { Check, Image as ImageIcon, Key, Eye, EyeOff, ExternalLink } from 'lucide-react';
53
+ import { hasDesktopFeatureAccess } from '@/lib/desktop-product';
56
54
  import { getModelById } from '@/lib/llm/models';
55
+ import { resolveStreamRoute } from '@/lib/llm/byok-guard';
56
+ import { getApiKeys, updateApiKeys, hasAnthropicKey, hasOpenaiKey, subscribeApiKeys } from '@/services/api-keys';
57
57
  import { useSandbox } from '@/hooks/useSandbox';
58
58
 
59
59
  // Environment variable for the proxy URL
@@ -67,7 +67,6 @@ const EXAMPLE_PROMPTS = [
67
67
  ];
68
68
 
69
69
  const CONTINUE_PROMPT = 'Continue from exactly where your last response stopped. Do not repeat previously generated text.';
70
- const DEFAULT_PRO_MONTHLY_CREDIT_LIMIT = 1000;
71
70
  const USAGE_REFRESH_INTERVAL_MS = 15_000;
72
71
  const EST_CHARS_PER_TOKEN = 4;
73
72
  const IMAGE_TOKEN_COST_EST = 850;
@@ -211,23 +210,30 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
211
210
  const consumePendingPrompt = useViewerStore((s) => s.consumeChatPendingPrompt);
212
211
  const pendingRepairRequest = useViewerStore((s) => s.chatPendingRepairRequest);
213
212
  const consumePendingRepairRequest = useViewerStore((s) => s.consumeChatPendingRepairRequest);
214
- const authToken = useViewerStore((s) => s.chatAuthToken);
215
- const hasPro = useViewerStore((s) => s.chatHasPro);
213
+ const hasByokKey = useViewerStore((s) => s.chatHasByokKey);
214
+ const setChatHasByokKey = useViewerStore((s) => s.setChatHasByokKey);
216
215
  const usage = useViewerStore((s) => s.chatUsage);
217
216
  const setChatUsage = useViewerStore((s) => s.setChatUsage);
218
217
  const desktopEntitlement = useViewerStore((s) => s.desktopEntitlement);
219
218
  const { execute } = useSandbox();
220
219
  const canUseAiAssistant = hasDesktopFeatureAccess(desktopEntitlement, 'ai_assistant');
221
- const displayUsage: UsageInfo | null = usage ?? (hasPro
222
- ? {
223
- type: 'credits',
224
- used: 0,
225
- limit: DEFAULT_PRO_MONTHLY_CREDIT_LIMIT,
226
- pct: 0,
227
- resetAt: 0,
228
- billable: false,
229
- }
230
- : null);
220
+
221
+ // Sync BYOK key availability into the store and track per-provider state
222
+ const [keyStateAnthropic, setKeyStateAnthropic] = useState(hasAnthropicKey);
223
+ const [keyStateOpenai, setKeyStateOpenai] = useState(hasOpenaiKey);
224
+ useEffect(() => {
225
+ const refresh = () => {
226
+ const a = hasAnthropicKey();
227
+ const o = hasOpenaiKey();
228
+ setKeyStateAnthropic(a);
229
+ setKeyStateOpenai(o);
230
+ setChatHasByokKey(a || o);
231
+ };
232
+ refresh();
233
+ return subscribeApiKeys(refresh);
234
+ }, [setChatHasByokKey]);
235
+
236
+ const displayUsage: UsageInfo | null = usage;
231
237
  const usageResetLabel = displayUsage?.resetAt && displayUsage.resetAt > 0
232
238
  ? new Date(displayUsage.resetAt * 1000).toLocaleDateString()
233
239
  : '—';
@@ -238,14 +244,10 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
238
244
  const [showScrollBtn, setShowScrollBtn] = useState(false);
239
245
  const [userScrolledUp, setUserScrolledUp] = useState(false);
240
246
  const [lastFinishReason, setLastFinishReason] = useState<string | null>(null);
241
- const openUpgradePage = useCallback(() => {
242
- navigateToPath(buildDesktopUpgradeUrl());
243
- }, []);
244
247
  const promptAiUpgrade = useCallback(() => {
245
248
  setChatError('AI assistant is available with Desktop Pro.');
246
249
  toast.info('AI assistant is available with Desktop Pro');
247
- openUpgradePage();
248
- }, [openUpgradePage, setChatError]);
250
+ }, [setChatError]);
249
251
 
250
252
  const inputRef = useRef<HTMLTextAreaElement>(null);
251
253
  const scrollRef = useRef<HTMLDivElement>(null);
@@ -307,7 +309,7 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
307
309
  useEffect(() => {
308
310
  let cancelled = false;
309
311
  const refreshUsage = async () => {
310
- const snapshot = await fetchUsageSnapshot(PROXY_URL, authToken);
312
+ const snapshot = await fetchUsageSnapshot(PROXY_URL);
311
313
  if (!cancelled && snapshot) {
312
314
  setChatUsage(snapshot);
313
315
  }
@@ -322,7 +324,7 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
322
324
  cancelled = true;
323
325
  window.clearInterval(timer);
324
326
  };
325
- }, [authToken, setChatUsage]);
327
+ }, [setChatUsage]);
326
328
 
327
329
  // ── Keyboard shortcuts ──
328
330
  useEffect(() => {
@@ -403,6 +405,20 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
403
405
  return;
404
406
  }
405
407
 
408
+ // Resolve the stream route BEFORE any user-visible side effects (adding
409
+ // the user message, clearing attachments, setting sending state). If the
410
+ // selected BYOK model has no key, bail out now so the chat transcript
411
+ // doesn't stack orphaned user messages on repeated sends.
412
+ const route = resolveStreamRoute(activeModel, getApiKeys());
413
+ if (route.kind === 'missing-key') {
414
+ setChatError(
415
+ route.provider === 'anthropic'
416
+ ? 'Enter your Anthropic API key above to use this model.'
417
+ : 'Enter your OpenAI API key above to use this model.',
418
+ );
419
+ return;
420
+ }
421
+
406
422
  const continuationBase = options?.continuationBase;
407
423
  const responseIntent = options?.intent ?? 'create';
408
424
  if (responseIntent !== 'repair') {
@@ -588,14 +604,8 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
588
604
  }
589
605
  };
590
606
 
591
- await streamChat({
592
- proxyUrl: PROXY_URL,
593
- model: activeModel,
594
- messages: streamMessages,
595
- system: systemPrompt,
596
- authToken,
597
- signal: abortController.signal,
598
- onChunk: (chunk) => {
607
+ // ── Shared stream callbacks ──
608
+ const handleChunk = (chunk: string) => {
599
609
  clearPendingAttachmentsOnce();
600
610
  accumulated += chunk;
601
611
  if (!responseEditState.applyFailed && responseEditState.intent !== 'repair') {
@@ -629,8 +639,8 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
629
639
  }
630
640
  setChatStatus('streaming');
631
641
  updateStreaming(accumulated);
632
- },
633
- onComplete: (fullText) => {
642
+ };
643
+ const handleComplete = (fullText: string) => {
634
644
  clearPendingAttachmentsOnce();
635
645
  const normalizedText = continuationBase
636
646
  ? stripContinuationOverlap(continuationBase, fullText)
@@ -772,22 +782,62 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
772
782
  }
773
783
 
774
784
  commitAssistantTurn();
775
- },
776
- onUsageInfo: (info: UsageInfo) => {
785
+ };
786
+ const handleUsageInfo = (info: UsageInfo) => {
777
787
  setChatUsage(info);
778
- },
779
- onFinishReason: (reason) => {
788
+ };
789
+ const handleFinishReason = (reason: string | null) => {
780
790
  setLastFinishReason(reason);
781
791
  if (reason === 'length') {
782
792
  setChatError('Response reached output limit. Click Continue to resume.');
783
793
  }
784
- },
785
- onError: (err) => {
794
+ };
795
+ const handleError = (err: Error) => {
786
796
  setChatError(err.message);
787
797
  setChatAbortController(null);
788
798
  commitAssistantTurn();
789
- },
790
- });
799
+ };
800
+
801
+ // Route to direct provider streaming for BYOK models, or through the proxy
802
+ // for free models. The route was already resolved (and the missing-key
803
+ // case handled) at the top of doSend, so this dispatch is total.
804
+ if (route.kind === 'anthropic') {
805
+ await streamAnthropicChat(route.apiKey, {
806
+ model: activeModel,
807
+ messages: streamMessages,
808
+ system: systemPrompt,
809
+ signal: abortController.signal,
810
+ onChunk: handleChunk,
811
+ onComplete: handleComplete,
812
+ onFinishReason: handleFinishReason,
813
+ onError: handleError,
814
+ });
815
+ } else if (route.kind === 'openai') {
816
+ await streamOpenAiChat(route.apiKey, {
817
+ model: activeModel,
818
+ messages: streamMessages,
819
+ system: systemPrompt,
820
+ signal: abortController.signal,
821
+ onChunk: handleChunk,
822
+ onComplete: handleComplete,
823
+ onFinishReason: handleFinishReason,
824
+ onError: handleError,
825
+ });
826
+ } else {
827
+ await streamChat({
828
+ proxyUrl: PROXY_URL,
829
+ model: activeModel,
830
+ messages: streamMessages,
831
+ system: systemPrompt,
832
+ signal: abortController.signal,
833
+ onChunk: handleChunk,
834
+ onComplete: handleComplete,
835
+ onFinishReason: handleFinishReason,
836
+ onError: handleError,
837
+ onUsageInfo: handleUsageInfo,
838
+ });
839
+ }
840
+
791
841
  if (abortController.signal.aborted) {
792
842
  commitAssistantTurn();
793
843
  const currentState = useViewerStore.getState();
@@ -797,7 +847,7 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
797
847
  }
798
848
  }
799
849
  }, [
800
- canUseAiAssistant, status, activeModel, attachments, authToken,
850
+ canUseAiAssistant, status, activeModel, attachments,
801
851
  addMessage, setChatStatus, updateStreaming, finalizeAssistant,
802
852
  setChatError, setChatAbortController, clearAttachments, setChatUsage, resizeInput,
803
853
  buildRepairPromptFromLiveState, triggerAutoRepair, execute,
@@ -1071,8 +1121,11 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1071
1121
  modelSupportsImages ? 'image/*' : '',
1072
1122
  ].filter(Boolean).join(',');
1073
1123
  const canAttachInput = modelSupportsFiles || modelSupportsImages;
1074
- const clerkEnabled = isClerkConfigured();
1075
- const showUpgradeNudge = Boolean(error && (error.includes('Upgrade to Pro') || error.includes('daily limit')));
1124
+ // Detect when selected model needs a missing BYOK key (reactive state, not raw reads)
1125
+ const modelSource = modelForUi?.source ?? 'proxy';
1126
+ const needsAnthropicKey = modelSource === 'anthropic' && !keyStateAnthropic;
1127
+ const needsOpenaiKey = modelSource === 'openai' && !keyStateOpenai;
1128
+ const needsByokKey = needsAnthropicKey || needsOpenaiKey;
1076
1129
  const showSupportEmail = Boolean(error && error.includes('louis@ltplus.com'));
1077
1130
  const canContinue = Boolean(
1078
1131
  !isActive && (streamingContent.trim().length > 0 || lastFinishReason === 'length'),
@@ -1111,7 +1164,7 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1111
1164
  <TooltipContent>Clear</TooltipContent>
1112
1165
  </Tooltip>
1113
1166
 
1114
- <ModelSelector hasPro={hasPro} />
1167
+ <ModelSelector />
1115
1168
  <div className="flex-1" />
1116
1169
 
1117
1170
  <Tooltip>
@@ -1128,31 +1181,6 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1128
1181
  <TooltipContent>Auto-run: {autoExecute ? 'ON' : 'OFF'}</TooltipContent>
1129
1182
  </Tooltip>
1130
1183
 
1131
- {clerkEnabled && (
1132
- <>
1133
- <SignedOut>
1134
- <SignInButton mode="modal">
1135
- <Button variant="ghost" size="sm" className="h-6 px-2 text-xs text-muted-foreground">
1136
- Sign in
1137
- </Button>
1138
- </SignInButton>
1139
- </SignedOut>
1140
- {!hasPro && (
1141
- <Button
1142
- variant="ghost"
1143
- size="sm"
1144
- className="h-6 px-2 text-xs text-muted-foreground"
1145
- onClick={openUpgradePage}
1146
- >
1147
- Pro
1148
- </Button>
1149
- )}
1150
- <SignedIn>
1151
- <UserButton afterSignOutUrl="/" />
1152
- </SignedIn>
1153
- </>
1154
- )}
1155
-
1156
1184
  {onClose && (
1157
1185
  <Button variant="ghost" size="icon-xs" onClick={onClose}>
1158
1186
  <X className="h-3.5 w-3.5" />
@@ -1161,14 +1189,16 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1161
1189
  </div>
1162
1190
 
1163
1191
  {!canUseAiAssistant && (
1164
- <div className="border-b bg-muted/40 px-3 py-2 text-xs text-muted-foreground flex items-center justify-between gap-3">
1165
- <span>AI assistant is available with Desktop Pro. Core viewing and scripting stay available without it.</span>
1166
- <Button variant="outline" size="sm" className="h-6 px-2 text-xs" onClick={openUpgradePage}>
1167
- Upgrade
1168
- </Button>
1192
+ <div className="border-b bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
1193
+ AI assistant requires Desktop Pro. Core viewing and scripting stay available without it.
1169
1194
  </div>
1170
1195
  )}
1171
1196
 
1197
+ {/* Inline BYOK key prompt — shown when user picks a model without the matching key */}
1198
+ {needsByokKey && canUseAiAssistant && (
1199
+ <InlineKeyPrompt provider={needsAnthropicKey ? 'anthropic' : 'openai'} />
1200
+ )}
1201
+
1172
1202
  {/* Clear confirmation */}
1173
1203
  {showClearConfirm && (
1174
1204
  <div className="px-3 py-2 bg-destructive/5 border-b flex items-center gap-2 text-xs">
@@ -1273,16 +1303,6 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1273
1303
  Continue
1274
1304
  </Button>
1275
1305
  )}
1276
- {showUpgradeNudge && clerkEnabled && (
1277
- <Button
1278
- variant="outline"
1279
- size="sm"
1280
- className="h-5 px-2 text-[10px]"
1281
- onClick={openUpgradePage}
1282
- >
1283
- Upgrade
1284
- </Button>
1285
- )}
1286
1306
  {showSupportEmail && (
1287
1307
  <a className="underline text-[10px]" href="mailto:louis@ltplus.com">
1288
1308
  Contact support
@@ -1351,7 +1371,7 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1351
1371
  </TooltipTrigger>
1352
1372
  <TooltipContent>
1353
1373
  {!canUseAiAssistant
1354
- ? 'Desktop Pro required for AI assistant'
1374
+ ? 'AI assistant not available'
1355
1375
  : canAttachInput
1356
1376
  ? 'Attach file or image (paste, drag & drop)'
1357
1377
  : 'Selected model does not support attachments'}
@@ -1367,11 +1387,11 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1367
1387
  }}
1368
1388
  onKeyDown={handleKeyDown}
1369
1389
  onPaste={handlePaste}
1370
- placeholder={canUseAiAssistant ? 'Ask anything...' : 'Desktop Pro required for AI assistant'}
1390
+ placeholder={!canUseAiAssistant ? 'AI assistant not available' : needsByokKey ? `Enter your ${needsAnthropicKey ? 'Anthropic' : 'OpenAI'} API key above` : 'Ask anything...'}
1371
1391
  rows={1}
1372
1392
  className="flex-1 resize-none rounded-md border border-input bg-background text-foreground placeholder:text-muted-foreground px-3 py-1.5 text-sm min-h-[32px] max-h-[120px] focus:outline-none focus:ring-1 focus:ring-ring"
1373
1393
  style={{ height: 'auto', overflow: 'hidden' }}
1374
- disabled={!canUseAiAssistant}
1394
+ disabled={!canUseAiAssistant || needsByokKey}
1375
1395
  />
1376
1396
 
1377
1397
  {isActive ? (
@@ -1395,7 +1415,7 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1395
1415
  variant="default"
1396
1416
  size="icon-xs"
1397
1417
  onClick={handleSend}
1398
- disabled={!inputText.trim() || !canUseAiAssistant}
1418
+ disabled={!inputText.trim() || !canUseAiAssistant || needsByokKey}
1399
1419
  className="shrink-0 mb-0.5"
1400
1420
  >
1401
1421
  <Send className="h-3.5 w-3.5" />
@@ -1438,3 +1458,87 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1438
1458
  </div>
1439
1459
  );
1440
1460
  }
1461
+
1462
+ // ── Inline BYOK key prompt (shown inside chat panel when key is missing) ──
1463
+
1464
+ const PROVIDER_INFO = {
1465
+ anthropic: {
1466
+ label: 'Anthropic',
1467
+ placeholder: 'sk-ant-api03-...',
1468
+ url: 'https://console.anthropic.com/settings/keys',
1469
+ urlLabel: 'console.anthropic.com',
1470
+ },
1471
+ openai: {
1472
+ label: 'OpenAI',
1473
+ placeholder: 'sk-...',
1474
+ url: 'https://platform.openai.com/api-keys',
1475
+ urlLabel: 'platform.openai.com',
1476
+ },
1477
+ } as const;
1478
+
1479
+ function InlineKeyPrompt({ provider }: { provider: 'anthropic' | 'openai' }) {
1480
+ const [value, setValue] = useState('');
1481
+ const [show, setShow] = useState(false);
1482
+ const [saved, setSaved] = useState(false);
1483
+ const info = PROVIDER_INFO[provider];
1484
+
1485
+ const handleSave = useCallback(() => {
1486
+ const trimmed = value.trim();
1487
+ if (!trimmed) return;
1488
+ if (provider === 'anthropic') {
1489
+ updateApiKeys({ anthropicKey: trimmed });
1490
+ } else {
1491
+ updateApiKeys({ openaiKey: trimmed });
1492
+ }
1493
+ setSaved(true);
1494
+ }, [value, provider]);
1495
+
1496
+ // Brief success state before the parent unmounts this component
1497
+ if (saved) {
1498
+ return (
1499
+ <div className="border-b bg-emerald-500/10 px-3 py-2 flex items-center gap-2 text-xs text-emerald-700 dark:text-emerald-400">
1500
+ <Check className="h-3.5 w-3.5" />
1501
+ <span>{info.label} key saved — ready to chat</span>
1502
+ </div>
1503
+ );
1504
+ }
1505
+
1506
+ return (
1507
+ <div className="border-b bg-muted/30 px-3 py-2.5 space-y-1.5">
1508
+ <div className="flex items-center gap-1.5 text-xs font-medium">
1509
+ <Key className="h-3.5 w-3.5" />
1510
+ {info.label} API key required
1511
+ </div>
1512
+ <p className="text-[11px] text-muted-foreground">
1513
+ Paste your key below — stored in your browser only, sent directly to {info.label}.{' '}
1514
+ <a href={info.url} target="_blank" rel="noopener noreferrer" className="underline inline-flex items-center gap-0.5">
1515
+ Get a key <ExternalLink className="h-2.5 w-2.5" />
1516
+ </a>
1517
+ </p>
1518
+ <div className="flex gap-1.5">
1519
+ <div className="relative flex-1">
1520
+ <input
1521
+ type={show ? 'text' : 'password'}
1522
+ value={value}
1523
+ onChange={(e) => setValue(e.target.value)}
1524
+ onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); }}
1525
+ placeholder={info.placeholder}
1526
+ autoComplete="off"
1527
+ spellCheck={false}
1528
+ className="w-full rounded border border-input bg-background px-2 py-1 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-ring pr-7"
1529
+ />
1530
+ <button
1531
+ type="button"
1532
+ onClick={() => setShow(!show)}
1533
+ className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
1534
+ >
1535
+ {show ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
1536
+ </button>
1537
+ </div>
1538
+ <Button size="sm" className="h-7 px-3 text-xs" onClick={handleSave} disabled={!value.trim()}>
1539
+ Save
1540
+ </Button>
1541
+ </div>
1542
+ </div>
1543
+ );
1544
+ }
@@ -306,6 +306,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
306
306
  const scriptPanelVisible = useViewerStore((state) => state.scriptPanelVisible);
307
307
  const setScriptPanelVisible = useViewerStore((state) => state.setScriptPanelVisible);
308
308
  // Cesium 3D overlay state
309
+ const cesiumAvailable = useViewerStore((state) => state.cesiumAvailable);
309
310
  const cesiumEnabled = useViewerStore((state) => state.cesiumEnabled);
310
311
  const toggleCesium = useViewerStore((state) => state.toggleCesium);
311
312
  const storeModels = useViewerStore((state) => state.models);
@@ -1157,8 +1158,8 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1157
1158
  </TooltipContent>
1158
1159
  </Tooltip>
1159
1160
 
1160
- {/* Cesium 3D Context toggle + settings */}
1161
- {hasModelsLoaded && (
1161
+ {/* Cesium 3D Context toggle + settings — web only, only when model has georeferencing */}
1162
+ {cesiumAvailable && !desktopShell && (
1162
1163
  <div className="flex items-center">
1163
1164
  <Tooltip>
1164
1165
  <TooltipTrigger asChild>
@@ -1293,7 +1294,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1293
1294
  <ThemeSwitch />
1294
1295
  </div>
1295
1296
  </TooltipTrigger>
1296
- <TooltipContent>Toggle theme</TooltipContent>
1297
+ <TooltipContent>Toggle theme (Shift+click for secret mode)</TooltipContent>
1297
1298
  </Tooltip>
1298
1299
 
1299
1300
  <Tooltip>
@@ -33,7 +33,7 @@ import { getNativeEntityDetails } from '@/services/desktop-native-metadata';
33
33
  import { configureMutationView } from '@/utils/configureMutationView';
34
34
  import { IfcQuery } from '@ifc-lite/query';
35
35
  import { MutablePropertyView } from '@ifc-lite/mutations';
36
- import { extractClassificationsOnDemand, extractMaterialsOnDemand, extractTypePropertiesOnDemand, extractTypeEntityOwnProperties, extractDocumentsOnDemand, extractRelationshipsOnDemand, extractGeoreferencingOnDemand, type IfcDataStore } from '@ifc-lite/parser';
36
+ import { extractClassificationsOnDemand, extractMaterialsOnDemand, extractTypePropertiesOnDemand, extractTypeEntityOwnProperties, extractDocumentsOnDemand, extractRelationshipsOnDemand, extractGeoreferencingOnDemand, extractLengthUnitScale, type IfcDataStore } from '@ifc-lite/parser';
37
37
  import { EntityFlags, RelationshipType, isSpatialStructureTypeName, isStoreyLikeSpatialTypeName } from '@ifc-lite/data';
38
38
  import type { EntityRef, FederatedModel } from '@/store/types';
39
39
 
@@ -554,6 +554,13 @@ export function PropertiesPanel() {
554
554
  return info?.hasGeoreference ? info : null;
555
555
  }, [model, ifcDataStore]);
556
556
 
557
+ // Extract IFC length unit scale (e.g. 0.001 for mm, 0.3048 for ft)
558
+ const lengthUnitScale = useMemo(() => {
559
+ const dataStore = model?.ifcDataStore ?? ifcDataStore;
560
+ if (!dataStore?.source?.length || !dataStore?.entityIndex) return 1;
561
+ return extractLengthUnitScale(dataStore.source, dataStore.entityIndex);
562
+ }, [model, ifcDataStore]);
563
+
557
564
  // Extract type-level properties (e.g., from IfcWallType's HasPropertySets)
558
565
  const typeProperties = useMemo(() => {
559
566
  if (!selectedEntity) return null;
@@ -904,7 +911,12 @@ export function PropertiesPanel() {
904
911
  }
905
912
 
906
913
  if (!selectedEntityId || (!isNativeLazySelection && (!modelQuery || !entityNode))) {
907
- // Show model metadata when a single legacy model is loaded and nothing selected
914
+ // Show model metadata when a single model is loaded and nothing selected.
915
+ // Handles both federated models (models.size >= 1) and legacy single-model path (models.size === 0).
916
+ if (models.size === 1) {
917
+ const singleModel = models.values().next().value as FederatedModel;
918
+ return <ModelMetadataPanel model={singleModel} />;
919
+ }
908
920
  if (ifcDataStore && models.size === 0 && geometryResult) {
909
921
  const legacyModel: FederatedModel = {
910
922
  id: '__legacy__',
@@ -921,6 +933,7 @@ export function PropertiesPanel() {
921
933
  };
922
934
  return <ModelMetadataPanel model={legacyModel} />;
923
935
  }
936
+ // Multi-model or no model loaded: show empty state
924
937
  return (
925
938
  <div className="h-full flex flex-col border-l-2 border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-black">
926
939
  <div className="p-3 border-b-2 border-zinc-200 dark:border-zinc-800 bg-white dark:bg-black">
@@ -1155,6 +1168,7 @@ export function PropertiesPanel() {
1155
1168
  schemaVersion={activeDataStore?.schemaVersion}
1156
1169
  coordinateInfo={(model?.geometryResult ?? geometryResult)?.coordinateInfo}
1157
1170
  geometryResult={model?.geometryResult ?? geometryResult}
1171
+ lengthUnitScale={lengthUnitScale}
1158
1172
  />
1159
1173
  </CollapsibleContent>
1160
1174
  </Collapsible>