@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.
- package/.turbo/turbo-build.log +57 -50
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +10 -0
- package/dist/assets/arrow-fie-E7fe.js +20 -0
- package/dist/assets/ascii-points-source-bTjLVmUX.js +1 -0
- package/dist/assets/{basketViewActivator-Bzw51jhm.js → basketViewActivator-EHAhHlwN.js} +12 -13
- package/dist/assets/bcf-Bhx-K17f.js +281 -0
- package/dist/assets/{browser-C5TFR7sH.js → browser-CVf8ATeW.js} +6 -6
- package/dist/assets/cesium-B4ZIU9jS.js +17742 -0
- package/dist/assets/decode-worker-CYqSjk1n.js +172 -0
- package/dist/assets/e57-source-CQHxE8n3.js +1 -0
- package/dist/assets/emscripten-module.browser-DcFZLAUx.js +1 -0
- package/dist/assets/exporters-KTio0Tdm.js +5723 -0
- package/dist/assets/geometry-controller.worker-Cm2P_EJr.js +7 -0
- package/dist/assets/geometry.worker-DchLBqZ8.js +1 -0
- package/dist/assets/{ids-B7AXEv7h.js → ids-CS7VCFin.js} +5 -5
- package/dist/assets/ifc-lite-C6wEhXa6.js +7 -0
- package/dist/assets/{ifc-lite_bg-DlKs5-yM.wasm → ifc-lite_bg-CSeT3fNI.wasm} +0 -0
- package/dist/assets/{ifc-lite_bg-PqmRe3Ph.wasm → ifc-lite_bg-ns4cSnX2.wasm} +0 -0
- package/dist/assets/{index-DVNSvEMh.js → index-8k9h-ANq.js} +60997 -59926
- package/dist/assets/index-BZC2YaOP.css +1 -0
- package/dist/assets/index-HqAIQkr6.js +22 -0
- package/dist/assets/inline-worker-BpBzlmd6.js +1 -0
- package/dist/assets/las-BW6LIc_j.js +1 -0
- package/dist/assets/las-source-C_IGrgRq.js +1 -0
- package/dist/assets/laz-source-jj3xI5Y4.js +125 -0
- package/dist/assets/maplibre-gl-C4LXKM6c.js +808 -0
- package/dist/assets/{native-bridge-BiD01jI9.js → native-bridge-DNrEhx2R.js} +5 -8
- package/dist/assets/{parser.worker-Bnbrl6gy.js → parser.worker-BcjkIo89.js} +2 -2
- package/dist/assets/pcd-source-Ck0UnVDn.js +3 -0
- package/dist/assets/ply-source-C8jjyzxE.js +4 -0
- package/dist/assets/{exporters-u0sz2Upj.js → sandbox-BSn5MyEJ.js} +11745 -7412
- package/dist/assets/{server-client-DP8fMPY9.js → server-client-D-kU2XAF.js} +4 -4
- package/dist/assets/{three-CDRZThFA.js → three-DwNDHx9-.js} +163 -171
- package/dist/assets/wasm-bridge-Cha08LdC.js +1 -0
- package/dist/assets/{workerHelpers-CBbWSJmd.js → workerHelpers-pUUnk9Wc.js} +1 -1
- package/dist/assets/zip-BJqVbRkU.js +2 -0
- package/dist/index.html +10 -12
- package/package.json +11 -11
- package/src/components/mcp/PlaygroundChat.tsx +90 -52
- package/src/components/viewer/CesiumOverlay.tsx +150 -91
- package/src/components/viewer/CesiumPlacementEditor.tsx +1009 -0
- package/src/components/viewer/ChatPanel.tsx +76 -93
- package/src/components/viewer/EntityContextMenu.tsx +68 -10
- package/src/components/viewer/MainToolbar.tsx +33 -3
- package/src/components/viewer/ViewportContainer.tsx +70 -16
- package/src/components/viewer/ViewportOverlays.tsx +2 -98
- package/src/components/viewer/chat/ByokKeyModal.tsx +338 -0
- package/src/components/viewer/chat/ByokStreamingPill.tsx +62 -0
- package/src/components/viewer/chat/ByokTrustDiagram.tsx +192 -0
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +49 -52
- package/src/components/viewer/properties/ModelMetadataPanel.tsx +55 -44
- package/src/components/viewer/selectionHandlers.ts +7 -1
- package/src/lib/geo/cesium-bridge.ts +86 -50
- package/src/lib/geo/cesium-placement.test.ts +244 -0
- package/src/lib/geo/cesium-placement.ts +231 -0
- package/src/lib/geo/effective-georef.test.ts +74 -1
- package/src/lib/geo/effective-georef.ts +40 -93
- package/src/lib/geo/geo-scale.ts +104 -0
- package/src/lib/geo/reproject.test.ts +130 -0
- package/src/lib/geo/reproject.ts +37 -12
- package/src/lib/geo/terrain-elevation.ts +198 -89
- package/src/lib/lens/adapter.ts +52 -6
- package/src/lib/llm/clipboard-detect.test.ts +150 -0
- package/src/lib/llm/clipboard-detect.ts +90 -0
- package/src/lib/llm/models.ts +28 -0
- package/src/lib/llm/stream-direct.ts +16 -4
- package/src/lib/llm/types.ts +8 -0
- package/src/services/playground-model.ts +55 -0
- package/src/store/index.ts +4 -5
- package/src/store/slices/cesiumSlice.ts +100 -19
- package/src/store.ts +3 -0
- package/dist/assets/arrow-CZ5kQ26f.js +0 -20
- package/dist/assets/bcf-4K724hw0.js +0 -281
- package/dist/assets/cesium-DUOzBlqv.js +0 -17817
- package/dist/assets/decode-worker-t2EGKAxO.js +0 -1708
- package/dist/assets/emscripten-module.browser-CY5t0Vfq.js +0 -1
- package/dist/assets/geometry-controller.worker-NH8pZmrU.js +0 -7
- package/dist/assets/geometry.worker-Bp4rW_R1.js +0 -1
- package/dist/assets/ifc-lite-DfZHk36-.js +0 -7
- package/dist/assets/index-CSWgTe1s.css +0 -1
- package/dist/assets/index-XwKzDuw6.js +0 -22
- package/dist/assets/maplibre-gl-CGLcoNXc.js +0 -811
- package/dist/assets/sandbox-DPD1ROr0.js +0 -9700
- package/dist/assets/wasm-bridge-CErti6zX.js +0 -1
- package/dist/assets/zip-DBEtpeu6.js +0 -12
- 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:
|
|
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'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'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'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. $10/month) so a leaked key can'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
|
+
}
|