@ifc-lite/viewer 1.21.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/.turbo/turbo-build.log +57 -50
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +10 -0
  4. package/dist/assets/arrow-fie-E7fe.js +20 -0
  5. package/dist/assets/ascii-points-source-bTjLVmUX.js +1 -0
  6. package/dist/assets/{basketViewActivator-Bzw51jhm.js → basketViewActivator-EHAhHlwN.js} +12 -13
  7. package/dist/assets/bcf-Bhx-K17f.js +281 -0
  8. package/dist/assets/{browser-C5TFR7sH.js → browser-CVf8ATeW.js} +6 -6
  9. package/dist/assets/cesium-B4ZIU9jS.js +17742 -0
  10. package/dist/assets/decode-worker-CYqSjk1n.js +172 -0
  11. package/dist/assets/e57-source-CQHxE8n3.js +1 -0
  12. package/dist/assets/emscripten-module.browser-DcFZLAUx.js +1 -0
  13. package/dist/assets/exporters-KTio0Tdm.js +5723 -0
  14. package/dist/assets/geometry-controller.worker-Cm2P_EJr.js +7 -0
  15. package/dist/assets/geometry.worker-DchLBqZ8.js +1 -0
  16. package/dist/assets/{ids-B7AXEv7h.js → ids-CS7VCFin.js} +5 -5
  17. package/dist/assets/ifc-lite-C6wEhXa6.js +7 -0
  18. package/dist/assets/{ifc-lite_bg-DlKs5-yM.wasm → ifc-lite_bg-CSeT3fNI.wasm} +0 -0
  19. package/dist/assets/{ifc-lite_bg-PqmRe3Ph.wasm → ifc-lite_bg-ns4cSnX2.wasm} +0 -0
  20. package/dist/assets/{index-DVNSvEMh.js → index-8k9h-ANq.js} +60997 -59926
  21. package/dist/assets/index-BZC2YaOP.css +1 -0
  22. package/dist/assets/index-HqAIQkr6.js +22 -0
  23. package/dist/assets/inline-worker-BpBzlmd6.js +1 -0
  24. package/dist/assets/las-BW6LIc_j.js +1 -0
  25. package/dist/assets/las-source-C_IGrgRq.js +1 -0
  26. package/dist/assets/laz-source-jj3xI5Y4.js +125 -0
  27. package/dist/assets/maplibre-gl-C4LXKM6c.js +808 -0
  28. package/dist/assets/{native-bridge-BiD01jI9.js → native-bridge-DNrEhx2R.js} +5 -8
  29. package/dist/assets/{parser.worker-Bnbrl6gy.js → parser.worker-BcjkIo89.js} +2 -2
  30. package/dist/assets/pcd-source-Ck0UnVDn.js +3 -0
  31. package/dist/assets/ply-source-C8jjyzxE.js +4 -0
  32. package/dist/assets/{exporters-u0sz2Upj.js → sandbox-BSn5MyEJ.js} +11745 -7412
  33. package/dist/assets/{server-client-DP8fMPY9.js → server-client-D-kU2XAF.js} +4 -4
  34. package/dist/assets/{three-CDRZThFA.js → three-DwNDHx9-.js} +163 -171
  35. package/dist/assets/wasm-bridge-Cha08LdC.js +1 -0
  36. package/dist/assets/{workerHelpers-CBbWSJmd.js → workerHelpers-pUUnk9Wc.js} +1 -1
  37. package/dist/assets/zip-BJqVbRkU.js +2 -0
  38. package/dist/index.html +10 -12
  39. package/package.json +11 -11
  40. package/src/components/mcp/PlaygroundChat.tsx +90 -52
  41. package/src/components/viewer/CesiumOverlay.tsx +150 -91
  42. package/src/components/viewer/CesiumPlacementEditor.tsx +1009 -0
  43. package/src/components/viewer/ChatPanel.tsx +76 -93
  44. package/src/components/viewer/EntityContextMenu.tsx +68 -10
  45. package/src/components/viewer/MainToolbar.tsx +33 -3
  46. package/src/components/viewer/ViewportContainer.tsx +70 -16
  47. package/src/components/viewer/ViewportOverlays.tsx +2 -98
  48. package/src/components/viewer/chat/ByokKeyModal.tsx +338 -0
  49. package/src/components/viewer/chat/ByokStreamingPill.tsx +62 -0
  50. package/src/components/viewer/chat/ByokTrustDiagram.tsx +192 -0
  51. package/src/components/viewer/properties/GeoreferencingPanel.tsx +49 -52
  52. package/src/components/viewer/properties/ModelMetadataPanel.tsx +55 -44
  53. package/src/components/viewer/selectionHandlers.ts +7 -1
  54. package/src/lib/geo/cesium-bridge.ts +86 -50
  55. package/src/lib/geo/cesium-placement.test.ts +244 -0
  56. package/src/lib/geo/cesium-placement.ts +231 -0
  57. package/src/lib/geo/effective-georef.test.ts +74 -1
  58. package/src/lib/geo/effective-georef.ts +40 -93
  59. package/src/lib/geo/geo-scale.ts +104 -0
  60. package/src/lib/geo/reproject.test.ts +130 -0
  61. package/src/lib/geo/reproject.ts +37 -12
  62. package/src/lib/geo/terrain-elevation.ts +198 -89
  63. package/src/lib/lens/adapter.ts +52 -6
  64. package/src/lib/llm/clipboard-detect.test.ts +150 -0
  65. package/src/lib/llm/clipboard-detect.ts +90 -0
  66. package/src/lib/llm/models.ts +28 -0
  67. package/src/lib/llm/stream-direct.ts +16 -4
  68. package/src/lib/llm/types.ts +8 -0
  69. package/src/services/playground-model.ts +55 -0
  70. package/src/store/index.ts +4 -5
  71. package/src/store/slices/cesiumSlice.ts +100 -19
  72. package/src/store.ts +3 -0
  73. package/dist/assets/arrow-CZ5kQ26f.js +0 -20
  74. package/dist/assets/bcf-4K724hw0.js +0 -281
  75. package/dist/assets/cesium-DUOzBlqv.js +0 -17817
  76. package/dist/assets/decode-worker-t2EGKAxO.js +0 -1708
  77. package/dist/assets/emscripten-module.browser-CY5t0Vfq.js +0 -1
  78. package/dist/assets/geometry-controller.worker-NH8pZmrU.js +0 -7
  79. package/dist/assets/geometry.worker-Bp4rW_R1.js +0 -1
  80. package/dist/assets/ifc-lite-DfZHk36-.js +0 -7
  81. package/dist/assets/index-CSWgTe1s.css +0 -1
  82. package/dist/assets/index-XwKzDuw6.js +0 -22
  83. package/dist/assets/maplibre-gl-CGLcoNXc.js +0 -811
  84. package/dist/assets/sandbox-DPD1ROr0.js +0 -9700
  85. package/dist/assets/wasm-bridge-CErti6zX.js +0 -1
  86. package/dist/assets/zip-DBEtpeu6.js +0 -12
  87. package/src/components/viewer/CesiumSettingsDialog.tsx +0 -100
@@ -8,16 +8,10 @@ import {
8
8
  ZoomIn,
9
9
  ZoomOut,
10
10
  Layers,
11
- Globe2,
12
- Mountain,
13
- Building2,
14
- Satellite,
15
- X,
16
11
  } from 'lucide-react';
17
12
  import { Button } from '@/components/ui/button';
18
13
  import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
19
14
  import { useViewerStore } from '@/store';
20
- import type { CesiumDataSource } from '@/store/slices/cesiumSlice';
21
15
  import { goHomeFromStore } from '@/store/homeView';
22
16
  import { useIfc } from '@/hooks/useIfc';
23
17
  import { cn } from '@/lib/utils';
@@ -41,11 +35,6 @@ export function ViewportOverlays({ hideViewCube = false }: { hideViewCube?: bool
41
35
 
42
36
  // Cesium state
43
37
  const cesiumEnabled = useViewerStore((s) => s.cesiumEnabled);
44
- const cesiumDataSource = useViewerStore((s) => s.cesiumDataSource);
45
- const setCesiumDataSource = useViewerStore((s) => s.setCesiumDataSource);
46
- const cesiumTerrainEnabled = useViewerStore((s) => s.cesiumTerrainEnabled);
47
- const setCesiumTerrainEnabled = useViewerStore((s) => s.setCesiumTerrainEnabled);
48
- const toggleCesium = useViewerStore((s) => s.toggleCesium);
49
38
 
50
39
  // Use refs for rotation to avoid re-renders - ViewCube updates itself directly
51
40
  const cameraRotationRef = useRef({ azimuth: 45, elevation: 25 });
@@ -152,16 +141,8 @@ export function ViewportOverlays({ hideViewCube = false }: { hideViewCube?: bool
152
141
  return (
153
142
  <>
154
143
  <PointCloudPanelMount />
155
- {/* Bottom-right: Cesium settings overlay OR Navigation controls (Cesium is web-only) */}
156
- {cesiumEnabled && !isDesktop ? (
157
- <CesiumSettingsOverlay
158
- dataSource={cesiumDataSource}
159
- onDataSourceChange={setCesiumDataSource}
160
- terrainEnabled={cesiumTerrainEnabled}
161
- onTerrainChange={setCesiumTerrainEnabled}
162
- onClose={toggleCesium}
163
- />
164
- ) : (
144
+ {/* Bottom-right: Navigation controls (hidden when Cesium active Cesium is web-only) */}
145
+ {!(cesiumEnabled && !isDesktop) && (
165
146
  <div
166
147
  className={cn(
167
148
  'absolute flex flex-col gap-1 bg-background/90 backdrop-blur-sm border p-1',
@@ -250,83 +231,6 @@ export function ViewportOverlays({ hideViewCube = false }: { hideViewCube?: bool
250
231
  );
251
232
  }
252
233
 
253
- /* ------------------------------------------------------------------ */
254
- /* Cesium Settings Overlay — replaces nav controls when Cesium is on */
255
- /* ------------------------------------------------------------------ */
256
-
257
- const DATA_SOURCES: { value: CesiumDataSource; label: string; icon: typeof Globe2 }[] = [
258
- { value: 'google-photorealistic', label: 'Google 3D', icon: Globe2 },
259
- { value: 'osm-buildings', label: 'OSM', icon: Building2 },
260
- { value: 'bing-aerial', label: 'Aerial', icon: Satellite },
261
- ];
262
-
263
- function CesiumSettingsOverlay({
264
- dataSource,
265
- onDataSourceChange,
266
- terrainEnabled,
267
- onTerrainChange,
268
- onClose,
269
- }: {
270
- dataSource: CesiumDataSource;
271
- onDataSourceChange: (ds: CesiumDataSource) => void;
272
- terrainEnabled: boolean;
273
- onTerrainChange: (enabled: boolean) => void;
274
- onClose: () => void;
275
- }) {
276
- return (
277
- <div className="absolute bottom-4 right-4 z-10 pointer-events-auto bg-background/90 backdrop-blur-sm rounded-lg border shadow-lg p-2 flex flex-col gap-2 min-w-[160px]">
278
- {/* Header */}
279
- <div className="flex items-center justify-between gap-2">
280
- <span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
281
- 3D World
282
- </span>
283
- <Tooltip>
284
- <TooltipTrigger asChild>
285
- <Button variant="ghost" size="icon-sm" className="h-5 w-5" onClick={onClose}>
286
- <X className="h-3 w-3" />
287
- </Button>
288
- </TooltipTrigger>
289
- <TooltipContent side="left">Disable Cesium overlay</TooltipContent>
290
- </Tooltip>
291
- </div>
292
-
293
- {/* Data Source Buttons */}
294
- <div className="flex flex-col gap-0.5">
295
- {DATA_SOURCES.map((ds) => {
296
- const Icon = ds.icon;
297
- const active = dataSource === ds.value;
298
- return (
299
- <button
300
- key={ds.value}
301
- onClick={() => onDataSourceChange(ds.value)}
302
- className={cn(
303
- 'flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors text-left',
304
- active
305
- ? 'bg-teal-600 text-white'
306
- : 'hover:bg-muted text-foreground/80',
307
- )}
308
- >
309
- <Icon className="h-3.5 w-3.5 shrink-0" />
310
- {ds.label}
311
- </button>
312
- );
313
- })}
314
- </div>
315
-
316
- {/* Terrain Toggle */}
317
- <label className="flex items-center gap-2 px-2 py-1 cursor-pointer border-t border-border pt-2">
318
- <Mountain className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
319
- <span className="text-xs text-foreground/80">Terrain</span>
320
- <input
321
- type="checkbox"
322
- checked={terrainEnabled}
323
- onChange={(e) => onTerrainChange(e.target.checked)}
324
- className="ml-auto accent-teal-500"
325
- />
326
- </label>
327
- </div>
328
- );
329
- }
330
234
 
331
235
  /**
332
236
  * Tiny indirection so the panel can subscribe to its own slice without
@@ -0,0 +1,338 @@
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
+ * Trust-focused BYOK API key entry modal.
7
+ *
8
+ * Replaces the inline password-style strip in ChatPanel. Renders one tab per
9
+ * supported provider; each tab pairs the request-flow SVG with concrete,
10
+ * DevTools-verifiable trust claims and an "Open Console → Create Key →
11
+ * paste here" walkthrough.
12
+ *
13
+ * Clipboard handling: we deliberately do NOT do background `clipboard.readText()`
14
+ * polling. Modern browsers gate that behind either transient user activation
15
+ * or an explicit clipboard-read permission we can't request a prompt for —
16
+ * and on macOS Chromium, every silent read triggers the native Paste affordance
17
+ * even though we silently swallow the result. Instead, the input is autofocused
18
+ * on open so the user's Cmd+V lands directly in the field, and a green inline
19
+ * confirmation appears the moment the pasted value matches the provider shape.
20
+ *
21
+ * The web build ships this. Desktop also uses it (the /settings page is
22
+ * desktop-only and not deployed on Vercel).
23
+ */
24
+
25
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
26
+ import { Check, ChevronDown, ChevronUp, ExternalLink, Eye, EyeOff, Key, Trash2 } from 'lucide-react';
27
+ import {
28
+ Dialog,
29
+ DialogContent,
30
+ DialogDescription,
31
+ DialogHeader,
32
+ DialogTitle,
33
+ } from '@/components/ui/dialog';
34
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
35
+ import { Button } from '@/components/ui/button';
36
+ import { Badge } from '@/components/ui/badge';
37
+ import { toast } from '@/components/ui/toast';
38
+ import { ByokTrustDiagram } from './ByokTrustDiagram';
39
+ import { getByokModelsForSource } from '@/lib/llm/models';
40
+ import {
41
+ getApiKeys,
42
+ updateApiKeys,
43
+ subscribeApiKeys,
44
+ type ApiKeyConfig,
45
+ } from '@/services/api-keys';
46
+ import {
47
+ looksLikeProviderKey,
48
+ maskKey,
49
+ type BYOKProvider,
50
+ } from '@/lib/llm/clipboard-detect';
51
+
52
+ const REPO_BLOB = 'https://github.com/louistrue/ifc-lite/blob/main';
53
+
54
+ const PROVIDER_META: Record<BYOKProvider, {
55
+ label: string;
56
+ apiHost: string;
57
+ keyPrefix: string;
58
+ placeholder: string;
59
+ consoleUrl: string;
60
+ consoleLabel: string;
61
+ pricingHint: string;
62
+ }> = {
63
+ anthropic: {
64
+ label: 'Anthropic',
65
+ apiHost: 'api.anthropic.com',
66
+ keyPrefix: 'sk-ant-api03-',
67
+ placeholder: 'sk-ant-api03-...',
68
+ consoleUrl: 'https://console.anthropic.com/settings/keys',
69
+ consoleLabel: 'console.anthropic.com',
70
+ pricingHint: 'Pay-as-you-go on Anthropic billing. New accounts get $5 free credit.',
71
+ },
72
+ openai: {
73
+ label: 'OpenAI',
74
+ apiHost: 'api.openai.com',
75
+ keyPrefix: 'sk-',
76
+ placeholder: 'sk-...',
77
+ consoleUrl: 'https://platform.openai.com/api-keys',
78
+ consoleLabel: 'platform.openai.com',
79
+ pricingHint: 'OpenAI requires prepaid credits or a payment method on your OpenAI account.',
80
+ },
81
+ };
82
+
83
+ interface ByokKeyModalProps {
84
+ open: boolean;
85
+ onOpenChange: (open: boolean) => void;
86
+ initialProvider?: BYOKProvider;
87
+ }
88
+
89
+ export function ByokKeyModal({ open, onOpenChange, initialProvider = 'anthropic' }: ByokKeyModalProps) {
90
+ const [provider, setProvider] = useState<BYOKProvider>(initialProvider);
91
+ const [apiKeys, setApiKeys] = useState<ApiKeyConfig>(() => getApiKeys());
92
+
93
+ // Re-sync the controlled tab whenever the modal re-opens with a (possibly new) initial provider.
94
+ useEffect(() => {
95
+ if (open) setProvider(initialProvider);
96
+ }, [open, initialProvider]);
97
+
98
+ // Keep saved-state badges in sync across open/save/clear.
99
+ useEffect(() => {
100
+ setApiKeys(getApiKeys());
101
+ return subscribeApiKeys(() => setApiKeys(getApiKeys()));
102
+ }, []);
103
+
104
+ return (
105
+ <Dialog open={open} onOpenChange={onOpenChange}>
106
+ <DialogContent className="max-w-2xl">
107
+ <DialogHeader>
108
+ <DialogTitle className="flex items-center gap-2">
109
+ <Key className="h-4 w-4" />
110
+ Use your own API key
111
+ </DialogTitle>
112
+ <DialogDescription>
113
+ Unlocks frontier models. Your key stays in this browser and goes
114
+ straight to the provider — never through our servers.
115
+ </DialogDescription>
116
+ </DialogHeader>
117
+
118
+ <Tabs value={provider} onValueChange={(v) => setProvider(v as BYOKProvider)}>
119
+ <TabsList className="grid w-full grid-cols-2">
120
+ <TabsTrigger
121
+ value="anthropic"
122
+ className="flex items-center gap-1.5 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm data-[state=active]:font-semibold"
123
+ >
124
+ Anthropic
125
+ {apiKeys.anthropicKey && <Check className="h-3 w-3 text-emerald-500" />}
126
+ </TabsTrigger>
127
+ <TabsTrigger
128
+ value="openai"
129
+ className="flex items-center gap-1.5 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm data-[state=active]:font-semibold"
130
+ >
131
+ OpenAI
132
+ {apiKeys.openaiKey && <Check className="h-3 w-3 text-emerald-500" />}
133
+ </TabsTrigger>
134
+ </TabsList>
135
+
136
+ <TabsContent value="anthropic" className="mt-4">
137
+ <ProviderTab provider="anthropic" savedKey={apiKeys.anthropicKey} />
138
+ </TabsContent>
139
+ <TabsContent value="openai" className="mt-4">
140
+ <ProviderTab provider="openai" savedKey={apiKeys.openaiKey} />
141
+ </TabsContent>
142
+ </Tabs>
143
+ </DialogContent>
144
+ </Dialog>
145
+ );
146
+ }
147
+
148
+ // ── Per-provider tab body ──────────────────────────────────────────────────
149
+
150
+ function ProviderTab({ provider, savedKey }: { provider: BYOKProvider; savedKey: string }) {
151
+ const meta = PROVIDER_META[provider];
152
+
153
+ const [value, setValue] = useState('');
154
+ const [show, setShow] = useState(false);
155
+ const [walkthroughOpen, setWalkthroughOpen] = useState(false);
156
+ const inputRef = useRef<HTMLInputElement>(null);
157
+
158
+ const unlockedModels = useMemo(() => getByokModelsForSource(provider), [provider]);
159
+
160
+ // Autofocus the input so the user's Cmd+V lands directly in the field
161
+ // without an extra click. Re-runs on tab switch.
162
+ useEffect(() => {
163
+ inputRef.current?.focus();
164
+ }, [provider]);
165
+
166
+ const handleSave = useCallback((next: string) => {
167
+ const trimmed = next.trim();
168
+ if (!trimmed) return;
169
+ const field = provider === 'anthropic' ? 'anthropicKey' : 'openaiKey';
170
+ updateApiKeys({ [field]: trimmed });
171
+ setValue('');
172
+ toast.success(`${PROVIDER_META[provider].label} key saved`);
173
+ }, [provider]);
174
+
175
+ const handleClear = useCallback(() => {
176
+ const field = provider === 'anthropic' ? 'anthropicKey' : 'openaiKey';
177
+ updateApiKeys({ [field]: '' });
178
+ toast.success(`${PROVIDER_META[provider].label} key removed`);
179
+ }, [provider]);
180
+
181
+ const handleOpenConsole = useCallback(() => {
182
+ window.open(meta.consoleUrl, '_blank', 'noopener,noreferrer');
183
+ }, [meta.consoleUrl]);
184
+
185
+ const trimmedValue = value.trim();
186
+ const inputIsValid = trimmedValue.length === 0 || looksLikeProviderKey(provider, value);
187
+ const inputLooksGood = trimmedValue.length > 0 && looksLikeProviderKey(provider, value) && trimmedValue !== savedKey;
188
+
189
+ return (
190
+ <div className="space-y-4">
191
+ {/* Models unlocked */}
192
+ <div className="flex flex-wrap items-center gap-1.5">
193
+ <span className="text-xs text-muted-foreground">Unlocks:</span>
194
+ {unlockedModels.map((m) => (
195
+ <Badge key={m.id} variant="outline" className="text-[10px] font-mono">
196
+ {m.name}
197
+ </Badge>
198
+ ))}
199
+ </div>
200
+
201
+ {/* The diagram — single most important trust element */}
202
+ <div className="rounded-lg border bg-card/40 p-4">
203
+ <ByokTrustDiagram apiHost={meta.apiHost} />
204
+ </div>
205
+
206
+ {/* DevTools-verifiable trust claims */}
207
+ <ul className="space-y-2 text-xs">
208
+ <TrustBullet>
209
+ Key stored only in this browser&apos;s <code className="bg-muted px-1 rounded">localStorage</code>.{' '}
210
+ Inspect any time in DevTools.
211
+ </TrustBullet>
212
+ <TrustBullet>
213
+ Every request goes to <code className="bg-muted px-1 rounded">{meta.apiHost}</code>. Verify in DevTools →
214
+ Network → filter <code className="bg-muted px-1 rounded">{meta.apiHost.split('.').slice(-2).join('.')}</code>.
215
+ </TrustBullet>
216
+ <TrustBullet>
217
+ The whole BYOK code path is ~60 lines.{' '}
218
+ <a
219
+ href={`${REPO_BLOB}/apps/viewer/src/lib/llm/stream-direct.ts`}
220
+ target="_blank"
221
+ rel="noopener noreferrer"
222
+ className="underline inline-flex items-center gap-0.5 hover:text-foreground"
223
+ >
224
+ Read it on GitHub <ExternalLink className="h-2.5 w-2.5" />
225
+ </a>
226
+ </TrustBullet>
227
+ </ul>
228
+
229
+ {/* Paste-driven key entry. The input is autofocused on mount so Cmd+V
230
+ lands here immediately after the user returns from the provider
231
+ console — no extra click required. */}
232
+ <div className="space-y-1.5">
233
+ <label className="text-xs font-medium" htmlFor={`byok-${provider}-input`}>
234
+ {savedKey ? 'Replace existing key' : 'Paste your key'}
235
+ </label>
236
+ <div className="flex gap-2">
237
+ <div className="relative flex-1">
238
+ <input
239
+ ref={inputRef}
240
+ id={`byok-${provider}-input`}
241
+ type={show ? 'text' : 'password'}
242
+ value={value}
243
+ onChange={(e) => setValue(e.target.value)}
244
+ onKeyDown={(e) => { if (e.key === 'Enter' && inputIsValid) handleSave(value); }}
245
+ placeholder={meta.placeholder}
246
+ autoComplete="off"
247
+ spellCheck={false}
248
+ className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-ring pr-8"
249
+ />
250
+ <button
251
+ type="button"
252
+ onClick={() => setShow(!show)}
253
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
254
+ aria-label={show ? 'Hide key' : 'Show key'}
255
+ >
256
+ {show ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
257
+ </button>
258
+ </div>
259
+ <Button size="sm" onClick={() => handleSave(value)} disabled={!inputIsValid || trimmedValue.length === 0}>
260
+ Save
261
+ </Button>
262
+ </div>
263
+ {inputLooksGood && (
264
+ <p className="text-[11px] text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
265
+ <Check className="h-3 w-3" />
266
+ Looks like a {meta.label} key (<code className="font-mono">{maskKey(trimmedValue)}</code>) — press Enter or Save.
267
+ </p>
268
+ )}
269
+ {!inputIsValid && (
270
+ <p className="text-[11px] text-destructive">
271
+ That doesn&apos;t look like a {meta.label} key (expected prefix{' '}
272
+ <code className="font-mono">{meta.keyPrefix}</code>).
273
+ </p>
274
+ )}
275
+ </div>
276
+
277
+ {/* Currently configured key + remove */}
278
+ {savedKey && (
279
+ <div className="flex items-center justify-between gap-3 rounded-md border p-3 text-xs">
280
+ <div className="flex items-center gap-2 text-muted-foreground">
281
+ <Check className="h-3.5 w-3.5 text-emerald-500" />
282
+ Configured: <code className="font-mono text-foreground">{maskKey(savedKey)}</code>
283
+ </div>
284
+ <Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleClear}>
285
+ <Trash2 className="mr-1 h-3 w-3" />
286
+ Remove
287
+ </Button>
288
+ </div>
289
+ )}
290
+
291
+ {/* Walkthrough */}
292
+ <div className="rounded-md border bg-muted/20">
293
+ <button
294
+ type="button"
295
+ onClick={() => setWalkthroughOpen((v) => !v)}
296
+ aria-expanded={walkthroughOpen}
297
+ aria-controls={`byok-walkthrough-${provider}`}
298
+ className="w-full flex items-center justify-between gap-2 p-3 text-xs hover:bg-muted/30 transition-colors"
299
+ >
300
+ <span className="font-medium">Don&apos;t have a key? 60-second walkthrough</span>
301
+ {walkthroughOpen ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
302
+ </button>
303
+ {walkthroughOpen && (
304
+ <div id={`byok-walkthrough-${provider}`} className="border-t p-3 space-y-2.5 text-xs">
305
+ <ol className="space-y-2 list-decimal list-inside text-muted-foreground">
306
+ <li>
307
+ Open the {meta.label} console — opens in a new tab.
308
+ </li>
309
+ <li>
310
+ Click <strong>Create Key</strong>, name it <code className="bg-muted px-1 rounded">ifc-lite</code>.
311
+ </li>
312
+ <li>
313
+ Set a spending limit (e.g.&nbsp;$10/month) so a leaked key can&apos;t burn you. The provider enforces it.
314
+ </li>
315
+ <li>
316
+ Copy the key, come back here, paste it into the input above (the field is already focused — just press <code className="bg-muted px-1 rounded">⌘V</code>).
317
+ </li>
318
+ </ol>
319
+ <p className="text-[11px] text-muted-foreground/80">{meta.pricingHint}</p>
320
+ <Button size="sm" variant="outline" className="text-xs" onClick={handleOpenConsole}>
321
+ <ExternalLink className="mr-1.5 h-3 w-3" />
322
+ Open {meta.consoleLabel}
323
+ </Button>
324
+ </div>
325
+ )}
326
+ </div>
327
+ </div>
328
+ );
329
+ }
330
+
331
+ function TrustBullet({ children }: { children: React.ReactNode }) {
332
+ return (
333
+ <li className="flex items-start gap-2 text-muted-foreground">
334
+ <Check className="h-3.5 w-3.5 mt-0.5 flex-shrink-0 text-emerald-500" />
335
+ <span>{children}</span>
336
+ </li>
337
+ );
338
+ }
@@ -0,0 +1,62 @@
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
+ * Trust pill — small badge sitting near the model name in the chat header
7
+ * that names the actual API host requests are going to when a BYOK route is
8
+ * active. Always-on for BYOK models (not just during streaming) so users can
9
+ * see at a glance where their data is going, without having to open DevTools.
10
+ *
11
+ * Returns null for proxy/free routes — the pill is a BYOK-specific trust
12
+ * signal; we don't need a "→ our proxy" pill cluttering the UI for the
13
+ * default tier.
14
+ */
15
+
16
+ import { useEffect, useState } from 'react';
17
+ import { Lock } from 'lucide-react';
18
+ import { cn } from '@/lib/utils';
19
+ import {
20
+ Tooltip,
21
+ TooltipContent,
22
+ TooltipTrigger,
23
+ } from '@/components/ui/tooltip';
24
+ import { resolveStreamRoute } from '@/lib/llm/byok-guard';
25
+ import { getApiKeys, subscribeApiKeys, type ApiKeyConfig } from '@/services/api-keys';
26
+
27
+ interface ByokStreamingPillProps {
28
+ modelId: string;
29
+ className?: string;
30
+ }
31
+
32
+ export function ByokStreamingPill({ modelId, className }: ByokStreamingPillProps) {
33
+ const [apiKeys, setApiKeys] = useState<ApiKeyConfig>(() => getApiKeys());
34
+ useEffect(() => subscribeApiKeys(() => setApiKeys(getApiKeys())), []);
35
+
36
+ const route = resolveStreamRoute(modelId, apiKeys);
37
+ if (route.kind !== 'anthropic' && route.kind !== 'openai') return null;
38
+
39
+ const host = route.kind === 'anthropic' ? 'api.anthropic.com' : 'api.openai.com';
40
+ const shortHost = route.kind === 'anthropic' ? 'anthropic.com' : 'openai.com';
41
+
42
+ return (
43
+ <Tooltip>
44
+ <TooltipTrigger asChild>
45
+ <span
46
+ className={cn(
47
+ 'inline-flex shrink-0 items-center gap-1 rounded-full border border-emerald-500/40 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-mono text-emerald-700 dark:text-emerald-400',
48
+ className,
49
+ )}
50
+ >
51
+ <Lock className="h-2.5 w-2.5" />
52
+ {shortHost}
53
+ </span>
54
+ </TooltipTrigger>
55
+ <TooltipContent className="max-w-xs text-xs leading-relaxed">
56
+ Messages from this model go directly from your browser to{' '}
57
+ <code className="font-mono">{host}</code>. To verify, open DevTools →
58
+ Network and filter <code className="font-mono">{shortHost}</code>.
59
+ </TooltipContent>
60
+ </Tooltip>
61
+ );
62
+ }