@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
@@ -18,10 +18,11 @@ import { useWebGPU } from '@/hooks/useWebGPU';
18
18
  import { openIfcFileDialog } from '@/services/file-dialog';
19
19
  import { logToDesktopTerminal } from '@/services/desktop-logger';
20
20
  import { cacheFileBlobs, formatFileSize, getCachedFile, getRecentFiles, recordRecentFiles, type RecentFileEntry } from '@/lib/recent-files';
21
- import { isTauri } from '@/utils/ifcConfig';
21
+ import { isTauri } from '@/lib/platform';
22
22
  import { Upload, MousePointer, Layers, Info, Command, AlertTriangle, ChevronDown, ExternalLink, Plus, Clock3 } from 'lucide-react';
23
23
  import type { MeshData, CoordinateInfo, GeometryResult } from '@ifc-lite/geometry';
24
- import { extractGeoreferencingOnDemand, type IfcDataStore, type MapConversion, type ProjectedCRS } from '@ifc-lite/parser';
24
+ import { type IfcDataStore } from '@ifc-lite/parser';
25
+ import { getEffectiveGeoreference } from '@/lib/geo/effective-georef';
25
26
 
26
27
  const ZERO_VEC3 = { x: 0, y: 0, z: 0 };
27
28
  const DEFAULT_COORDINATE_INFO: CoordinateInfo = {
@@ -43,6 +44,7 @@ export function ViewportContainer() {
43
44
  const cesiumEnabled = useViewerStore((s) => s.cesiumEnabled);
44
45
  const georefMutations = useViewerStore((s) => s.georefMutations);
45
46
  const setCesiumSourceModelId = useViewerStore((s) => s.setCesiumSourceModelId);
47
+ const setCesiumAvailable = useViewerStore((s) => s.setCesiumAvailable);
46
48
  // Subscribe to mutationVersion so Cesium reacts to georef edits
47
49
  const mutationVersion = useViewerStore((s) => s.mutationVersion);
48
50
  const fileInputRef = useRef<HTMLInputElement>(null);
@@ -176,82 +178,64 @@ export function ViewportContainer() {
176
178
  const georef = useMemo(() => {
177
179
  if (!cesiumEnabled) return null;
178
180
 
179
- // Helper: merge original georef with mutations for a model
180
- function mergeGeoref(
181
- originalCRS: ProjectedCRS | undefined,
182
- originalConv: MapConversion | undefined,
183
- modelId: string,
184
- ): { mapConversion: MapConversion; projectedCRS: ProjectedCRS } | null {
185
- const muts = georefMutations.get(modelId);
186
- const mutCRS = muts?.projectedCRS;
187
- const mutConv = muts?.mapConversion;
188
-
189
- // Build merged ProjectedCRS — mutation fields override originals
190
- const hasCRS = originalCRS || mutCRS;
191
- if (!hasCRS) return null;
192
- const projectedCRS: ProjectedCRS = {
193
- id: originalCRS?.id ?? 0,
194
- name: (mutCRS?.name ?? originalCRS?.name ?? '') as string,
195
- description: mutCRS?.description ?? originalCRS?.description,
196
- geodeticDatum: mutCRS?.geodeticDatum ?? originalCRS?.geodeticDatum,
197
- verticalDatum: mutCRS?.verticalDatum ?? originalCRS?.verticalDatum,
198
- mapProjection: mutCRS?.mapProjection ?? originalCRS?.mapProjection,
199
- mapZone: mutCRS?.mapZone ?? originalCRS?.mapZone,
200
- mapUnit: mutCRS?.mapUnit ?? originalCRS?.mapUnit,
201
- };
202
-
203
- // Need at least an EPSG name to resolve projection
204
- if (!projectedCRS.name) return null;
205
-
206
- // Build merged MapConversion
207
- const mapConversion: MapConversion = {
208
- id: originalConv?.id ?? 0,
209
- sourceCRS: originalConv?.sourceCRS ?? 0,
210
- targetCRS: originalConv?.targetCRS ?? 0,
211
- eastings: (mutConv?.eastings ?? originalConv?.eastings ?? 0) as number,
212
- northings: (mutConv?.northings ?? originalConv?.northings ?? 0) as number,
213
- orthogonalHeight: (mutConv?.orthogonalHeight ?? originalConv?.orthogonalHeight ?? 0) as number,
214
- xAxisAbscissa: mutConv?.xAxisAbscissa ?? originalConv?.xAxisAbscissa,
215
- xAxisOrdinate: mutConv?.xAxisOrdinate ?? originalConv?.xAxisOrdinate,
216
- scale: mutConv?.scale ?? originalConv?.scale,
217
- };
218
-
219
- return { mapConversion, projectedCRS };
220
- }
221
-
222
181
  // Check federated models first
223
182
  for (const [modelId, model] of storeModels) {
224
183
  const ds = model.ifcDataStore;
225
184
  if (!ds) continue;
226
- const original = extractGeoreferencingOnDemand(ds as IfcDataStore);
227
- const merged = mergeGeoref(
228
- original?.projectedCRS,
229
- original?.mapConversion,
230
- modelId,
185
+ const effective = getEffectiveGeoreference(
186
+ ds as IfcDataStore,
187
+ model.geometryResult?.coordinateInfo,
188
+ georefMutations.get(modelId),
231
189
  );
232
- if (merged) {
233
- // Return coordinateInfo from the SAME model to avoid mismatched transforms
234
- const coordInfo = model.geometryResult?.coordinateInfo;
235
- return { hasGeoreference: true, ...merged, sourceModelId: modelId, coordinateInfo: coordInfo };
190
+ if (effective?.projectedCRS?.name && effective.mapConversion) {
191
+ return { ...effective, sourceModelId: modelId };
236
192
  }
237
193
  }
238
194
 
239
195
  // Fallback to legacy single-model
240
196
  if (ifcDataStore) {
241
- const original = extractGeoreferencingOnDemand(ifcDataStore as IfcDataStore);
242
- const merged = mergeGeoref(
243
- original?.projectedCRS,
244
- original?.mapConversion,
245
- '__legacy__',
197
+ const effective = getEffectiveGeoreference(
198
+ ifcDataStore as IfcDataStore,
199
+ mergedGeometryResult?.coordinateInfo,
200
+ georefMutations.get('__legacy__'),
246
201
  );
247
- if (merged) {
248
- return { hasGeoreference: true, ...merged, sourceModelId: '__legacy__', coordinateInfo: mergedGeometryResult?.coordinateInfo };
202
+ if (effective?.projectedCRS?.name && effective.mapConversion) {
203
+ return { ...effective, sourceModelId: '__legacy__' };
249
204
  }
250
205
  }
251
206
 
252
207
  return null;
253
208
  }, [cesiumEnabled, storeModels, ifcDataStore, georefMutations, mutationVersion, mergedGeometryResult]);
254
209
 
210
+ // Determine whether Cesium button should be visible (model has georef or user added it via mutations).
211
+ // Runs independently of cesiumEnabled so the button appears/disappears reactively.
212
+ useEffect(() => {
213
+ function hasGeoref(): boolean {
214
+ // Check federated models
215
+ for (const [modelId, model] of storeModels) {
216
+ const ds = model.ifcDataStore;
217
+ if (!ds) continue;
218
+ const effective = getEffectiveGeoreference(
219
+ ds as IfcDataStore,
220
+ model.geometryResult?.coordinateInfo,
221
+ georefMutations.get(modelId),
222
+ );
223
+ if (effective?.projectedCRS?.name) return true;
224
+ }
225
+ // Fallback to legacy single-model
226
+ if (ifcDataStore) {
227
+ const effective = getEffectiveGeoreference(
228
+ ifcDataStore as IfcDataStore,
229
+ mergedGeometryResult?.coordinateInfo,
230
+ georefMutations.get('__legacy__'),
231
+ );
232
+ if (effective?.projectedCRS?.name) return true;
233
+ }
234
+ return false;
235
+ }
236
+ setCesiumAvailable(hasGeoref());
237
+ }, [storeModels, ifcDataStore, georefMutations, mutationVersion, setCesiumAvailable, mergedGeometryResult]);
238
+
255
239
  // Sync the active Cesium source model ID so terrain actions are scoped correctly
256
240
  useEffect(() => {
257
241
  setCesiumSourceModelId(georef?.sourceModelId ?? null);
@@ -847,13 +831,14 @@ export function ViewportContainer() {
847
831
  </div>
848
832
  )}
849
833
 
850
- {/* Cesium 3D world context overlay — rendered behind the WebGPU canvas */}
851
- {cesiumEnabled && georef && (
834
+ {/* Cesium 3D world context overlay — rendered behind the WebGPU canvas (web only) */}
835
+ {cesiumEnabled && georef && !isTauri() && (
852
836
  <CesiumOverlay
853
837
  mapConversion={georef.mapConversion}
854
838
  projectedCRS={georef.projectedCRS}
855
839
  coordinateInfo={georef.coordinateInfo}
856
840
  geometryResult={mergedGeometryResult}
841
+ lengthUnitScale={georef.lengthUnitScale}
857
842
  />
858
843
  )}
859
844
  <Viewport
@@ -862,7 +847,7 @@ export function ViewportContainer() {
862
847
  coordinateInfo={mergedGeometryResult?.coordinateInfo}
863
848
  computedIsolatedIds={computedIsolatedIds}
864
849
  modelIdToIndex={modelIdToIndex}
865
- cesiumActive={cesiumEnabled && georef !== null}
850
+ cesiumActive={cesiumEnabled && georef !== null && !isTauri()}
866
851
  releaseGeometryAfterStream={false}
867
852
  onGeometryReleased={releaseGeometryMemory}
868
853
  />
@@ -21,6 +21,9 @@ import type { CesiumDataSource } from '@/store/slices/cesiumSlice';
21
21
  import { goHomeFromStore } from '@/store/homeView';
22
22
  import { useIfc } from '@/hooks/useIfc';
23
23
  import { cn } from '@/lib/utils';
24
+ import { isTauri } from '@/lib/platform';
25
+
26
+ const isDesktop = isTauri();
24
27
  import { ViewCube, type ViewCubeRef } from './ViewCube';
25
28
  import { AxisHelper, type AxisHelperRef } from './AxisHelper';
26
29
 
@@ -146,8 +149,8 @@ export function ViewportOverlays({ hideViewCube = false }: { hideViewCube?: bool
146
149
 
147
150
  return (
148
151
  <>
149
- {/* Bottom-right: Cesium settings overlay OR Navigation controls */}
150
- {cesiumEnabled ? (
152
+ {/* Bottom-right: Cesium settings overlay OR Navigation controls (Cesium is web-only) */}
153
+ {cesiumEnabled && !isDesktop ? (
151
154
  <CesiumSettingsOverlay
152
155
  dataSource={cesiumDataSource}
153
156
  onDataSourceChange={setCesiumDataSource}
@@ -215,12 +215,12 @@ export function BCFTopicDetail({
215
215
  <img
216
216
  src={vp.snapshot}
217
217
  alt="Viewpoint"
218
- className="w-full aspect-video object-cover cursor-pointer hover:opacity-90 transition-opacity"
218
+ className="w-full max-h-48 object-contain bg-muted cursor-pointer hover:opacity-90 transition-opacity"
219
219
  onClick={() => onActivateViewpoint(vp)}
220
220
  />
221
221
  ) : (
222
222
  <div
223
- className="w-full aspect-video bg-muted flex items-center justify-center cursor-pointer hover:bg-muted/80 transition-colors"
223
+ className="w-full aspect-video bg-muted flex items-center justify-center cursor-pointer hover:bg-muted/80 transition-colors min-h-[120px]"
224
224
  onClick={() => onActivateViewpoint(vp)}
225
225
  >
226
226
  <Camera className="h-6 w-6 text-muted-foreground" />
@@ -294,7 +294,7 @@ export function BCFTopicDetail({
294
294
  <img
295
295
  src={associatedViewpoint.snapshot}
296
296
  alt="Associated viewpoint"
297
- className="w-full h-16 object-cover"
297
+ className="w-full max-h-24 object-contain bg-muted"
298
298
  />
299
299
  </div>
300
300
  )}
@@ -327,7 +327,7 @@ export function BCFTopicDetail({
327
327
  <img
328
328
  src={selectedViewpoint.snapshot}
329
329
  alt="Selected viewpoint"
330
- className="w-10 h-10 object-cover rounded"
330
+ className="w-12 h-10 object-contain rounded bg-muted"
331
331
  />
332
332
  )}
333
333
  <div className="flex-1 min-w-0">
@@ -4,11 +4,14 @@
4
4
 
5
5
  /**
6
6
  * ModelSelector — dropdown to pick the LLM model.
7
- * Free models available to everyone. Pro models show cost indicator and lock icon.
7
+ * Free models available to everyone via server proxy.
8
+ * BYOK models selectable always — greyed out with lock icon when key is missing,
9
+ * fully styled when key is configured. Selecting a locked model triggers the
10
+ * inline key prompt in ChatPanel.
8
11
  */
9
12
 
10
- import { useCallback } from 'react';
11
- import { Lock } from 'lucide-react';
13
+ import { useCallback, useEffect, useState } from 'react';
14
+ import { Check, Key } from 'lucide-react';
12
15
  import {
13
16
  Select,
14
17
  SelectContent,
@@ -17,27 +20,43 @@ import {
17
20
  SelectValue,
18
21
  } from '@/components/ui/select';
19
22
  import { useViewerStore } from '@/store';
20
- import { FREE_MODELS, PRO_MODELS, getModelById } from '@/lib/llm/models';
21
-
22
- interface ModelSelectorProps {
23
- /** Whether the user has a pro subscription */
24
- hasPro?: boolean;
25
- }
23
+ import { FREE_MODELS, getModelById, getByokModelsForSource } from '@/lib/llm/models';
24
+ import type { LLMModel } from '@/lib/llm/types';
25
+ import { hasAnthropicKey, hasOpenaiKey, subscribeApiKeys } from '@/services/api-keys';
26
26
 
27
27
  function formatContextWindow(tokens: number): string {
28
28
  if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(0)}M`;
29
29
  return `${(tokens / 1_000).toFixed(0)}K`;
30
30
  }
31
31
 
32
- export function ModelSelector({ hasPro = false }: ModelSelectorProps) {
32
+ function CostBadge({ cost }: { cost?: LLMModel['cost'] }) {
33
+ if (!cost) return null;
34
+ const color = cost === '$$$' ? 'text-amber-500' : cost === '$$' ? 'text-blue-500' : 'text-emerald-500';
35
+ return <span className={`text-[10px] font-mono ${color}`}>{cost}</span>;
36
+ }
37
+
38
+ export function ModelSelector() {
33
39
  const activeModel = useViewerStore((s) => s.chatActiveModel);
34
40
  const setActiveModel = useViewerStore((s) => s.setChatActiveModel);
35
41
 
42
+ const [hasAnthropic, setHasAnthropic] = useState(hasAnthropicKey);
43
+ const [hasOpenai, setHasOpenai] = useState(hasOpenaiKey);
44
+
45
+ useEffect(() => {
46
+ const refresh = () => {
47
+ setHasAnthropic(hasAnthropicKey());
48
+ setHasOpenai(hasOpenaiKey());
49
+ };
50
+ return subscribeApiKeys(refresh);
51
+ }, []);
52
+
36
53
  const handleChange = useCallback((value: string) => {
37
54
  setActiveModel(value);
38
55
  }, [setActiveModel]);
39
56
 
40
57
  const current = getModelById(activeModel);
58
+ const anthropicModels = getByokModelsForSource('anthropic');
59
+ const openaiModels = getByokModelsForSource('openai');
41
60
 
42
61
  return (
43
62
  <Select value={activeModel} onValueChange={handleChange}>
@@ -45,57 +64,74 @@ export function ModelSelector({ hasPro = false }: ModelSelectorProps) {
45
64
  <SelectValue>
46
65
  <span className="truncate flex items-center gap-1">
47
66
  {current?.name ?? activeModel}
48
- {current?.cost && (
49
- <span className={`text-[10px] font-mono ${
50
- current.cost === '$$$' ? 'text-amber-500' : current.cost === '$$' ? 'text-blue-500' : 'text-emerald-500'
51
- }`}>
52
- {current.cost}
53
- </span>
54
- )}
67
+ <CostBadge cost={current?.cost} />
55
68
  </span>
56
69
  </SelectValue>
57
70
  </SelectTrigger>
58
71
  <SelectContent>
59
72
  {/* Free tier */}
60
- <div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
61
- Free
62
- </div>
63
- {FREE_MODELS.map((m) => (
64
- <SelectItem key={m.id} value={m.id} className="text-xs">
65
- <span className="flex items-center gap-1.5">
66
- <span>{m.name}</span>
67
- <span className="text-muted-foreground text-[10px]">{m.provider}</span>
68
- <span className="text-muted-foreground/50 text-[10px]">{formatContextWindow(m.contextWindow)}</span>
69
- </span>
70
- </SelectItem>
71
- ))}
73
+ {FREE_MODELS.length > 0 && (
74
+ <>
75
+ <div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
76
+ Free
77
+ </div>
78
+ {FREE_MODELS.map((m) => (
79
+ <SelectItem key={m.id} value={m.id} className="text-xs">
80
+ <span className="flex items-center gap-1.5">
81
+ <span>{m.name}</span>
82
+ <span className="text-muted-foreground text-[10px]">{m.provider}</span>
83
+ <span className="text-muted-foreground/50 text-[10px]">{formatContextWindow(m.contextWindow)}</span>
84
+ </span>
85
+ </SelectItem>
86
+ ))}
87
+ </>
88
+ )}
89
+
90
+ {/* Anthropic BYOK */}
91
+ {anthropicModels.length > 0 && (
92
+ <>
93
+ <div className="px-2 py-1 mt-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-1">
94
+ Anthropic
95
+ {hasAnthropic
96
+ ? <Check className="h-2.5 w-2.5 text-emerald-500" />
97
+ : <Key className="h-2.5 w-2.5" />
98
+ }
99
+ </div>
100
+ {anthropicModels.map((m) => (
101
+ <SelectItem key={m.id} value={m.id} className={`text-xs ${!hasAnthropic ? 'opacity-50' : ''}`}>
102
+ <span className="flex items-center gap-1.5">
103
+ <span>{m.name}</span>
104
+ <CostBadge cost={m.cost} />
105
+ <span className="text-muted-foreground/50 text-[10px]">{formatContextWindow(m.contextWindow)}</span>
106
+ {!hasAnthropic && <Key className="h-3 w-3 text-muted-foreground/50" />}
107
+ </span>
108
+ </SelectItem>
109
+ ))}
110
+ </>
111
+ )}
72
112
 
73
- {/* Pro tier */}
74
- <div className="px-2 py-1 mt-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-1">
75
- Pro
76
- </div>
77
- {PRO_MODELS.map((m) => (
78
- <SelectItem
79
- key={m.id}
80
- value={m.id}
81
- disabled={!hasPro}
82
- className="text-xs"
83
- >
84
- <span className="flex items-center gap-1.5">
85
- <span>{m.name}</span>
86
- <span className="text-muted-foreground text-[10px]">{m.provider}</span>
87
- {m.cost && (
88
- <span className={`text-[10px] font-mono ${
89
- m.cost === '$$$' ? 'text-amber-500' : m.cost === '$$' ? 'text-blue-500' : 'text-emerald-500'
90
- }`}>
91
- {m.cost}
113
+ {/* OpenAI BYOK */}
114
+ {openaiModels.length > 0 && (
115
+ <>
116
+ <div className="px-2 py-1 mt-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-1">
117
+ OpenAI
118
+ {hasOpenai
119
+ ? <Check className="h-2.5 w-2.5 text-emerald-500" />
120
+ : <Key className="h-2.5 w-2.5" />
121
+ }
122
+ </div>
123
+ {openaiModels.map((m) => (
124
+ <SelectItem key={m.id} value={m.id} className={`text-xs ${!hasOpenai ? 'opacity-50' : ''}`}>
125
+ <span className="flex items-center gap-1.5">
126
+ <span>{m.name}</span>
127
+ <CostBadge cost={m.cost} />
128
+ <span className="text-muted-foreground/50 text-[10px]">{formatContextWindow(m.contextWindow)}</span>
129
+ {!hasOpenai && <Key className="h-3 w-3 text-muted-foreground/50" />}
92
130
  </span>
93
- )}
94
- <span className="text-muted-foreground/50 text-[10px]">{formatContextWindow(m.contextWindow)}</span>
95
- {!hasPro && <Lock className="h-3 w-3 text-muted-foreground/50" />}
96
- </span>
97
- </SelectItem>
98
- ))}
131
+ </SelectItem>
132
+ ))}
133
+ </>
134
+ )}
99
135
  </SelectContent>
100
136
  </Select>
101
137
  );