Open History to see the last commit
1import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
2import { cloudSpeechDisclosure } from "@t3tools/shared/speech";
3import type { PreparedConnection } from "@t3tools/client-runtime/connection";
4import {
5 configuredSecretSources,
6 secretRequestReference,
7} from "@t3tools/client-runtime/secret-request";
8import {
9 getCloudSpeechKeys,
10 getCloudSpeechModels,
11 setCloudSpeechKey,
12} from "@t3tools/client-runtime/voice-input";
13import {
14 AuthOrchestrationOperateScope,
15 AuthSettingsWriteScope,
16 CloudSpeechApiKey,
17 CloudSpeechModelId,
18 CLOUD_SPEECH_LABELS,
19 CLOUD_SPEECH_PROVIDER_NAMES,
20 isSecretReference,
21 type CloudSpeechModel,
22 type CloudSpeechProvider,
23 type SecretProviderId,
24 type SettingsSecretValue,
25 type VoiceTranscriptionDestination,
26} from "@t3tools/contracts";
27import * as Effect from "effect/Effect";
28import * as Option from "effect/Option";
29import * as Schema from "effect/Schema";
30import type { HttpClient } from "effect/http";
31import { Atom, AsyncResult } from "effect/reactivity";
32import { useId, useState } from "react";
33import { isSecretsFeatureEnabled } from "../../env";
34import {
35 inEnvironmentSpeechRuntime,
36 runEnvironmentSpeech,
37} from "../../speech/runEnvironmentSpeech";
38import {
39 chooseAudioDestination,
40 pendingCloudAudioChoice,
41 useAudioChoice,
42} from "../../speech/voiceConsent";
43import type { EnvironmentPresentation } from "../../state/environments";
44import { useEnvironmentScope, usePreparedConnection } from "../../state/session";
45import { Button } from "../ui/button";
46import {
47 Combobox,
48 ComboboxEmpty,
49 ComboboxInput,
50 ComboboxItem,
51 ComboboxList,
52 ComboboxPopup,
53 useComboboxFilter,
54} from "../ui/combobox";
55import {
56 Dialog,
57 DialogDescription,
58 DialogFooter,
59 DialogHeader,
60 DialogPanel,
61 DialogPopup,
62 DialogTitle,
63} from "../ui/dialog";
64import { Input } from "../ui/input";
65import { Label } from "../ui/label";
66import { SecretInput } from "./SecretInput";
67import { SecretSourceSelect } from "./SecretSourceInput";
68
69// Per dialog instance, not an Atom.family: key presence must be read fresh each time it opens.
70function useCloudRead<A, E>(
71 prepared: PreparedConnection | null,
72 read: (prepared: PreparedConnection) => Effect.Effect<A, E, HttpClient.HttpClient>,
73) {
74 const make = (connection: PreparedConnection | null) => ({
75 prepared: connection,
76 atom: Atom.make(
77 connection ? inEnvironmentSpeechRuntime(read(connection)) : Effect.succeed(null),
78 ),
79 });
80 const [current, setCurrent] = useState(() => make(prepared));
81 if (current.prepared !== prepared) setCurrent(make(prepared));
82 return [useAtomValue(current.atom), useAtomRefresh(current.atom)] as const;
83}
84const isApiKey = Schema.is(CloudSpeechApiKey);
85const isModelId = Schema.is(CloudSpeechModelId);
86type CloudDestination = Extract<VoiceTranscriptionDestination, { kind: "cloud" }>;
87
88function ModelPicker({
89 models,
90 value,
91 onChange,
92 disabled,
93 current,
94}: {
95 models: ReadonlyArray<CloudSpeechModel>;
96 value: string;
97 onChange: (next: string) => void;
98 disabled: boolean;
99 /** The grant's model, flagged when the catalog no longer lists it. */
100 current: string | undefined;
101}) {
102 const { contains } = useComboboxFilter();
103 const selected = models.find((model) => model.id === value) ?? null;
104 const delisted = current !== undefined && !models.some((model) => model.id === current);
105 return (
106 <div className="grid gap-1">
107 <Combobox
108 items={models}
109 itemToStringLabel={(model: CloudSpeechModel) => model.name}
110 itemToStringValue={(model: CloudSpeechModel) => model.id}
111 filter={(model: CloudSpeechModel, query) =>
112 contains(model.name, query) || contains(model.id, query)
113 }
114 value={selected}
115 onValueChange={(model) => onChange(model?.id ?? "")}
116 autoHighlight
117 disabled={disabled}
118 >
119 <ComboboxInput
120 id="voice-cloud-model"
121 aria-label="OpenRouter model"
122 placeholder="Search transcription models"
123 autoFocus
124 size="sm"
125 />
126 <ComboboxPopup>
127 <ComboboxEmpty>No transcription model matches.</ComboboxEmpty>
128 <ComboboxList className="max-h-64">
129 {(model: CloudSpeechModel) => (
130 <ComboboxItem key={model.id} value={model}>
131 <span className="flex min-w-0 flex-col">
132 <span className="truncate">{model.name}</span>
133 <span className="truncate text-xs text-muted-foreground">{model.id}</span>
134 </span>
135 </ComboboxItem>
136 )}
137 </ComboboxList>
138 </ComboboxPopup>
139 </Combobox>
140 {delisted ? (
141 <p className="text-sm text-destructive" role="alert">
142 OpenRouter no longer lists {current}. Dictation stays off until you choose another model.
143 </p>
144 ) : selected ? (
145 <p className="truncate text-xs text-muted-foreground">{selected.id}</p>
146 ) : null}
147 </div>
148 );
149}
150
151export function CloudVoiceConfiguration({
152 environment,
153 provider,
154 active,
155 close,
156}: {
157 environment: Pick<EnvironmentPresentation, "environmentId" | "label" | "serverConfig">;
158 provider: CloudSpeechProvider;
159 active: boolean;
160 close: () => void;
161}) {
162 const id = environment.environmentId;
163 const name = CLOUD_SPEECH_LABELS[provider];
164 const company = CLOUD_SPEECH_PROVIDER_NAMES[provider];
165 const routed = provider === "openrouter";
166 const formId = useId();
167 const prepared = Option.getOrNull(usePreparedConnection(id));
168 const canUse = useEnvironmentScope(id, AuthOrchestrationOperateScope);
169 const canManage = useEnvironmentScope(id, AuthSettingsWriteScope);
170 const [presence, refresh] = useCloudRead(canUse ? prepared : null, getCloudSpeechKeys);
171 const [models, refreshModels] = useCloudRead(
172 routed && canUse ? prepared : null,
173 getCloudSpeechModels,
174 );
175 const choice = useAudioChoice();
176 const grant = choice === "off" ? null : choice;
177 const currentModel =
178 active && grant?.destination.kind === "cloud" && grant.destination.provider === "openrouter"
179 ? grant.destination.model
180 : undefined;
181 const [remove, setRemove] = useState(false);
182 const [busy, setBusy] = useState(false);
183 const [error, setError] = useState("");
184 const [model, setModel] = useState(currentModel ?? "");
185 const saved = AsyncResult.isSuccess(presence) ? presence.value?.[provider] : undefined;
186 const existing = environment.serverConfig?.settings?.cloudSpeechCredentials?.[provider];
187 const sources =
188 isSecretsFeatureEnabled && environment.serverConfig?.secretReferences
189 ? configuredSecretSources(environment.serverConfig.settings?.secretProviders ?? {})
190 : [];
191 const reference =
192 isSecretReference(existing) && sources.some((s) => s.id === existing.provider)
193 ? existing
194 : null;
195 const [sourceId, setSourceId] = useState<SecretProviderId | null>(reference?.provider ?? null);
196 const [draft, setDraft] = useState(reference?.reference ?? "");
197 const source = sources.find((s) => s.id === sourceId);
198 const credential =
199 sourceId !== null
200 ? source && secretRequestReference(source, draft)
201 : isApiKey(draft.trim())
202 ? draft.trim()
203 : undefined;
204 const keyChanged =
205 credential !== undefined &&
206 !(
207 isSecretReference(credential) &&
208 reference?.provider === credential.provider &&
209 reference.reference === credential.reference
210 );
211 const connected = canUse && prepared !== null;
212 // Fixed providers edit the key only when nothing is saved or the device already uses them.
213 const editingKey = canManage && saved !== undefined && (routed || active || !saved);
214 const listedModels = AsyncResult.isSuccess(models) ? (models.value?.models ?? null) : null;
215 const modelChosen = isModelId(model) && (listedModels?.some((m) => m.id === model) ?? false);
216 const keyReady = draft.trim() ? credential !== undefined : Boolean(saved);
217 const modelChanged = model !== (currentModel ?? "");
218 const destination = (): CloudDestination =>
219 routed && isModelId(model)
220 ? { kind: "cloud", provider: "openrouter", environmentId: id, model }
221 : { kind: "cloud", provider: provider as "openai" | "xai", environmentId: id };
222 const enable = () => chooseAudioDestination(destination());
223 const canSubmit = routed
224 ? modelChosen && keyReady && (!active || modelChanged || keyChanged)
225 : credential !== undefined;
226
227 const saveKey = async (value: SettingsSecretValue | null, thenEnable: boolean) => {
228 if (!connected || !canManage || busy) return;
229 setError("");
230 setBusy(true);
231 try {
232 const commit = thenEnable ? pendingCloudAudioChoice(destination()) : null;
233 await runEnvironmentSpeech(setCloudSpeechKey(prepared, provider, value));
234 refresh();
235 commit?.();
236 close();
237 } catch {
238 setError("Couldn’t save this change. Check your connection and try again.");
239 }
240 setBusy(false);
241 };
242 const submit = () => {
243 if (!canSubmit || busy) return;
244 const grantChanges = !active || modelChanged;
245 if (credential !== undefined && (!routed || keyChanged))
246 return void saveKey(credential, grantChanges);
247 // Model-only changes never write to the environment.
248 if (grantChanges) enable();
249 close();
250 };
251
252 const keyField = (
253 <>
254 <Label htmlFor="voice-cloud-key">{routed && saved ? "Replace API key" : "API key"}</Label>
255 <div className="flex min-w-0 items-start gap-2">
256 {sources.length > 0 && (
257 <SecretSourceSelect
258 value={sourceId}
259 sources={sources}
260 label={`${company} API key`}
261 disabled={busy}
262 onValueChange={(next) => {
263 setSourceId(next);
264 setDraft("");
265 setError("");
266 }}
267 />
268 )}
269 <div className="min-w-0 flex-1">
270 {sourceId !== null ? (
271 <Input
272 key={sourceId}
273 id="voice-cloud-key"
274 name="reference"
275 aria-label={`${company} API key reference`}
276 autoFocus={!routed}
277 placeholder={source?.referencePlaceholder}
278 value={draft}
279 disabled={busy}
280 autoComplete="off"
281 spellCheck={false}
282 onChange={(event) => setDraft(event.target.value)}
283 />
284 ) : (
285 <SecretInput
286 id="voice-cloud-key"
287 name="key"
288 aria-label={`${company} API key`}
289 autoFocus={!routed}
290 placeholder="Enter API key"
291 value={draft}
292 configured={saved === true}
293 disabled={busy}
294 maxLength={4096}
295 onChange={(event) => setDraft(event.target.value)}
296 />
297 )}
298 </div>
299 </div>
300 <p className="text-sm text-muted-foreground">
301 {draft.trim() && !credential
302 ? (source?.invalidReferenceMessage ?? "Enter an API key, not a URL or secret reference.")
303 : active && !routed
304 ? "Updating the key keeps dictation enabled."
305 : routed && saved
306 ? "Leave blank to keep the saved key."
307 : `Anyone with dictation access to ${environment.label} can bill this key.`}
308 </p>
309 </>
310 );
311
312 return (
313 <Dialog
314 open
315 onOpenChange={(open) => {
316 if (!open && !busy) close();
317 }}
318 >
319 <DialogPopup className="max-w-md" showCloseButton={!busy}>
320 <DialogHeader>
321 <DialogTitle>
322 {remove
323 ? `Remove the ${company} key?`
324 : active
325 ? routed
326 ? "Manage OpenRouter dictation"
327 : `Manage ${company} key`
328 : `Use ${name} for dictation?`}
329 </DialogTitle>
330 <DialogDescription>
331 {remove
332 ? `Everyone using ${name} for dictation on ${environment.label} will lose access.`
333 : active && !routed
334 ? `Anyone with dictation access to ${environment.label} can use this API key.`
335 : cloudSpeechDisclosure(provider, environment.label)}
336 </DialogDescription>
337 </DialogHeader>
338 {!remove && (
339 <DialogPanel>
340 {!connected ? (
341 <p>Connect to {environment.label} with dictation access to continue.</p>
342 ) : AsyncResult.isFailure(presence) && saved === undefined ? (
343 <Button size="sm" variant="outline" onClick={refresh}>
344 Retry loading API key
345 </Button>
346 ) : routed &&
347 AsyncResult.isSuccess(presence) &&
348 presence.value &&
349 saved === undefined ? (
350 <p>Update {environment.label} to use OpenRouter dictation.</p>
351 ) : saved === undefined ? (
352 <p role="status">Checking for an API key…</p>
353 ) : routed ? (
354 <form
355 id={formId}
356 className="grid gap-3"
357 onSubmit={(event) => {
358 event.preventDefault();
359 submit();
360 }}
361 >
362 <div className="grid gap-2">
363 <Label htmlFor="voice-cloud-model">Model</Label>
364 {listedModels ? (
365 <ModelPicker
366 models={listedModels}
367 value={model}
368 onChange={setModel}
369 disabled={busy}
370 current={currentModel}
371 />
372 ) : AsyncResult.isFailure(models) ? (
373 <Button size="sm" variant="outline" type="button" onClick={refreshModels}>
374 Retry loading models
375 </Button>
376 ) : (
377 <p role="status" className="text-sm text-muted-foreground">
378 Loading transcription models…
379 </p>
380 )}
381 </div>
382 {editingKey ? (
383 <div className="grid gap-2">{keyField}</div>
384 ) : !saved ? (
385 <p>Ask an administrator on {environment.label} to add an API key.</p>
386 ) : (
387 <p className="text-sm text-muted-foreground">
388 Uses the API key saved on {environment.label}. This choice applies only to this
389 device.
390 </p>
391 )}
392 </form>
393 ) : editingKey ? (
394 <form
395 id={formId}
396 className="grid gap-2"
397 onSubmit={(event) => {
398 event.preventDefault();
399 submit();
400 }}
401 >
402 {keyField}
403 </form>
404 ) : !saved ? (
405 <p>Ask an administrator on {environment.label} to add an API key.</p>
406 ) : (
407 <p className="text-sm text-muted-foreground">
408 This choice applies only to this device. Choose Off to stop sharing.
409 </p>
410 )}
411 </DialogPanel>
412 )}
413 {error && (
414 <p className="px-6 text-sm text-destructive" role="alert">
415 {error}
416 </p>
417 )}
418 <DialogFooter>
419 {connected && canManage && saved && (active || routed) && !remove && (
420 <div className="sm:mr-auto">
421 <Button
422 type="button"
423 size="sm"
424 variant="ghost"
425 disabled={busy}
426 onClick={() => setRemove(true)}
427 >
428 Remove key…
429 </Button>
430 </div>
431 )}
432 <Button type="button" size="sm" variant="outline" disabled={busy} onClick={close}>
433 Cancel
434 </Button>
435 {connected &&
436 (remove ? (
437 <Button
438 type="button"
439 size="sm"
440 variant="destructive"
441 disabled={busy}
442 onClick={() => void saveKey(null, false)}
443 >
444 Remove key
445 </Button>
446 ) : routed ? (
447 saved !== undefined && (saved || canManage) ? (
448 <Button type="submit" form={formId} size="sm" disabled={busy || !canSubmit}>
449 {active ? "Save" : "Save and enable"}
450 </Button>
451 ) : null
452 ) : editingKey ? (
453 <Button type="submit" form={formId} size="sm" disabled={busy || !credential}>
454 {active ? "Save key" : "Save and enable"}
455 </Button>
456 ) : saved && !active ? (
457 <Button
458 type="button"
459 size="sm"
460 onClick={() => {
461 enable();
462 close();
463 }}
464 >
465 Use {name}
466 </Button>
467 ) : null)}
468 </DialogFooter>
469 </DialogPopup>
470 </Dialog>
471 );
472}