@odori/cli 0.0.8 → 0.0.10
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/dist/{chunk-UPYHTDXP.js → chunk-AJBK4XRB.js} +128 -11
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/dist/{registry-snapshot-GV6FFZXV.js → registry-snapshot-TKH2KAC3.js} +1 -1
- package/package.json +3 -3
- package/src/cli.ts +16 -0
- package/src/commands/dev.ts +2 -0
- package/src/commands/narrate.ts +74 -0
- package/src/providers.ts +98 -2
- package/src/registry-snapshot.json +1 -1
- package/src/server.ts +10 -0
- package/studio/src/Studio.tsx +2 -4
- package/studio/src/components/GenerateBed.tsx +148 -0
- package/studio/src/components/Settings.tsx +266 -50
- package/studio/src/components/ui.tsx +11 -1
- package/studio/src/integrations.ts +29 -0
- package/studio/src/studio.css +170 -3
- package/studio/src/views/AssetsView.tsx +6 -0
- package/studio/src/virtual.d.ts +1 -0
- package/studio/src/views/IntegrationsView.tsx +0 -270
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider status, shared by the two places that show it.
|
|
3
|
+
*
|
|
4
|
+
* The settings menu owns configuration and the Integrations view owns
|
|
5
|
+
* generation, so both need to know what is connected. Status is a source and
|
|
6
|
+
* nothing else — a key can be sent to the local server, but it never comes
|
|
7
|
+
* back.
|
|
8
|
+
*/
|
|
9
|
+
export type ProviderStatus = {
|
|
10
|
+
name: string;
|
|
11
|
+
title: string;
|
|
12
|
+
kind: string;
|
|
13
|
+
keyVariable: string;
|
|
14
|
+
docsUrl: string;
|
|
15
|
+
source: "environment" | "stored" | null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const loadProviders = async (): Promise<ProviderStatus[]> => {
|
|
19
|
+
const response = await fetch("/__odori/integrations");
|
|
20
|
+
if (!response.ok) throw new Error(`${response.status}`);
|
|
21
|
+
const body = (await response.json()) as {providers: ProviderStatus[]};
|
|
22
|
+
return body.providers;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Fired on `window` after a key is stored or removed, so a view that gates on
|
|
27
|
+
* connection state catches up without waiting for a reload.
|
|
28
|
+
*/
|
|
29
|
+
export const INTEGRATIONS_CHANGED = "odori:integrations-changed";
|
package/studio/src/studio.css
CHANGED
|
@@ -344,9 +344,176 @@ a.button {
|
|
|
344
344
|
}
|
|
345
345
|
/* Right-aligned: the trigger is the last thing in the header, so a menu
|
|
346
346
|
growing rightward from its left edge would leave the window. */
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
347
|
+
/* The settings dialog: pages down the left, one page's controls on the right. */
|
|
348
|
+
.settings-dialog {
|
|
349
|
+
background: var(--elevated);
|
|
350
|
+
border: 1px solid var(--border-strong);
|
|
351
|
+
border-radius: var(--radius-lg);
|
|
352
|
+
box-shadow: var(--shadow);
|
|
353
|
+
display: flex;
|
|
354
|
+
/* One size regardless of the page shown: a dialog that grows and shrinks
|
|
355
|
+
as you switch pages reads as two dialogs. The body scrolls instead. */
|
|
356
|
+
height: min(480px, 82vh);
|
|
357
|
+
overflow: hidden;
|
|
358
|
+
width: min(640px, 92vw);
|
|
359
|
+
}
|
|
360
|
+
.settings-nav {
|
|
361
|
+
border-right: 1px solid var(--border);
|
|
362
|
+
display: flex;
|
|
363
|
+
flex-direction: column;
|
|
364
|
+
flex: none;
|
|
365
|
+
gap: 2px;
|
|
366
|
+
padding: 14px 10px;
|
|
367
|
+
width: 168px;
|
|
368
|
+
}
|
|
369
|
+
.settings-nav-title {
|
|
370
|
+
color: var(--subtle);
|
|
371
|
+
font-size: 11px;
|
|
372
|
+
letter-spacing: 0.04em;
|
|
373
|
+
margin: 0 0 8px;
|
|
374
|
+
padding: 0 8px;
|
|
375
|
+
text-transform: uppercase;
|
|
376
|
+
}
|
|
377
|
+
.settings-nav-item {
|
|
378
|
+
background: transparent;
|
|
379
|
+
border: none;
|
|
380
|
+
border-radius: var(--radius-sm);
|
|
381
|
+
color: var(--muted);
|
|
382
|
+
cursor: pointer;
|
|
383
|
+
font: inherit;
|
|
384
|
+
font-size: 13px;
|
|
385
|
+
padding: 7px 8px;
|
|
386
|
+
text-align: left;
|
|
387
|
+
}
|
|
388
|
+
.settings-nav-item:hover {
|
|
389
|
+
color: var(--foreground);
|
|
390
|
+
}
|
|
391
|
+
.settings-nav-item[data-selected="true"] {
|
|
392
|
+
background: var(--active);
|
|
393
|
+
color: var(--foreground);
|
|
394
|
+
}
|
|
395
|
+
.settings-body {
|
|
396
|
+
display: flex;
|
|
397
|
+
flex: 1;
|
|
398
|
+
flex-direction: column;
|
|
399
|
+
gap: 18px;
|
|
400
|
+
min-width: 0;
|
|
401
|
+
overflow-y: auto;
|
|
402
|
+
padding: 14px 18px 20px;
|
|
403
|
+
}
|
|
404
|
+
.settings-body-head {
|
|
405
|
+
align-items: center;
|
|
406
|
+
display: flex;
|
|
407
|
+
justify-content: space-between;
|
|
408
|
+
}
|
|
409
|
+
.settings-body-title {
|
|
410
|
+
font-size: 15px;
|
|
411
|
+
font-weight: 600;
|
|
412
|
+
margin: 0;
|
|
413
|
+
}
|
|
414
|
+
.settings-group {
|
|
415
|
+
display: grid;
|
|
416
|
+
gap: 8px;
|
|
417
|
+
}
|
|
418
|
+
.settings-group-title {
|
|
419
|
+
color: var(--subtle);
|
|
420
|
+
font-size: 11px;
|
|
421
|
+
letter-spacing: 0.04em;
|
|
422
|
+
margin: 0;
|
|
423
|
+
text-transform: uppercase;
|
|
424
|
+
}
|
|
425
|
+
.settings-options {
|
|
426
|
+
display: grid;
|
|
427
|
+
gap: 2px;
|
|
428
|
+
}
|
|
429
|
+
.settings-option {
|
|
430
|
+
align-items: baseline;
|
|
431
|
+
background: transparent;
|
|
432
|
+
border: none;
|
|
433
|
+
border-radius: var(--radius-sm);
|
|
434
|
+
color: var(--muted);
|
|
435
|
+
cursor: pointer;
|
|
436
|
+
display: flex;
|
|
437
|
+
font: inherit;
|
|
438
|
+
font-size: 13px;
|
|
439
|
+
gap: 10px;
|
|
440
|
+
justify-content: space-between;
|
|
441
|
+
padding: 7px 9px;
|
|
442
|
+
text-align: left;
|
|
443
|
+
}
|
|
444
|
+
.settings-option:hover {
|
|
445
|
+
color: var(--foreground);
|
|
446
|
+
}
|
|
447
|
+
.settings-option[data-selected="true"] {
|
|
448
|
+
background: var(--active);
|
|
449
|
+
color: var(--foreground);
|
|
450
|
+
}
|
|
451
|
+
.settings-option-hint {
|
|
452
|
+
color: var(--subtle);
|
|
453
|
+
font-size: 11px;
|
|
454
|
+
}
|
|
455
|
+
.settings-note {
|
|
456
|
+
color: var(--subtle);
|
|
457
|
+
font-size: 12px;
|
|
458
|
+
line-height: 1.5;
|
|
459
|
+
margin: 0;
|
|
460
|
+
}
|
|
461
|
+
.settings-provider {
|
|
462
|
+
border: 1px solid var(--border);
|
|
463
|
+
border-radius: var(--radius);
|
|
464
|
+
display: grid;
|
|
465
|
+
gap: 10px;
|
|
466
|
+
padding: 12px 14px;
|
|
467
|
+
}
|
|
468
|
+
.settings-provider-head {
|
|
469
|
+
align-items: center;
|
|
470
|
+
display: flex;
|
|
471
|
+
gap: 12px;
|
|
472
|
+
justify-content: space-between;
|
|
473
|
+
}
|
|
474
|
+
.settings-provider-id {
|
|
475
|
+
display: grid;
|
|
476
|
+
gap: 3px;
|
|
477
|
+
min-width: 0;
|
|
478
|
+
}
|
|
479
|
+
.settings-provider-name {
|
|
480
|
+
font-size: 13px;
|
|
481
|
+
font-weight: 600;
|
|
482
|
+
}
|
|
483
|
+
.settings-provider-sub {
|
|
484
|
+
align-items: center;
|
|
485
|
+
color: var(--subtle);
|
|
486
|
+
display: flex;
|
|
487
|
+
font-size: 12px;
|
|
488
|
+
gap: 6px;
|
|
489
|
+
}
|
|
490
|
+
.settings-provider-sub a {
|
|
491
|
+
color: inherit;
|
|
492
|
+
}
|
|
493
|
+
.settings-provider-dot {
|
|
494
|
+
background: var(--subtle);
|
|
495
|
+
border-radius: 999px;
|
|
496
|
+
flex: none;
|
|
497
|
+
height: 6px;
|
|
498
|
+
width: 6px;
|
|
499
|
+
}
|
|
500
|
+
.settings-provider-sub[data-tone="ok"] .settings-provider-dot {
|
|
501
|
+
background: var(--success);
|
|
502
|
+
}
|
|
503
|
+
.settings-provider-form {
|
|
504
|
+
display: flex;
|
|
505
|
+
gap: 8px;
|
|
506
|
+
}
|
|
507
|
+
.settings-integration-input {
|
|
508
|
+
flex: 1;
|
|
509
|
+
min-width: 0;
|
|
510
|
+
background: var(--field, transparent);
|
|
511
|
+
border: 1px solid var(--border);
|
|
512
|
+
border-radius: 6px;
|
|
513
|
+
color: inherit;
|
|
514
|
+
font: inherit;
|
|
515
|
+
font-size: 12px;
|
|
516
|
+
padding: 6px 10px;
|
|
350
517
|
}
|
|
351
518
|
.menu-heading {
|
|
352
519
|
color: var(--subtle);
|
|
@@ -2,6 +2,7 @@ import {resolveEntryLayout} from "odori";
|
|
|
2
2
|
import {project, videos} from "virtual:odori-project";
|
|
3
3
|
import {AudioClip} from "../components/AudioClip";
|
|
4
4
|
import {Empty, SectionTitle} from "../components/ui";
|
|
5
|
+
import {GenerateBed} from "../components/GenerateBed";
|
|
5
6
|
|
|
6
7
|
const isImage = (url: string) => /\.(png|jpe?g|gif|svg|webp|avif)$/i.test(url);
|
|
7
8
|
const isAudio = (url: string) => /\.(m4a|mp3|wav|aac|ogg|opus|flac)$/i.test(url);
|
|
@@ -88,6 +89,11 @@ export const AssetsView = () => {
|
|
|
88
89
|
)}
|
|
89
90
|
</section>
|
|
90
91
|
|
|
92
|
+
<section>
|
|
93
|
+
<SectionTitle>Generate a bed</SectionTitle>
|
|
94
|
+
<GenerateBed />
|
|
95
|
+
</section>
|
|
96
|
+
|
|
91
97
|
<section>
|
|
92
98
|
<SectionTitle>Fonts</SectionTitle>
|
|
93
99
|
{/* Studio's own chrome never loaded these faces, so a name rendered in
|
package/studio/src/virtual.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ declare module "virtual:odori-project" {
|
|
|
17
17
|
exportDir: string;
|
|
18
18
|
audioDir: string;
|
|
19
19
|
audio: Array<{name: string; url: string; relativeFile: string; bytes: number}>;
|
|
20
|
+
version: string;
|
|
20
21
|
sourceHash: string;
|
|
21
22
|
assets: Array<{reference: string; url: string}>;
|
|
22
23
|
files: {
|
|
@@ -1,270 +0,0 @@
|
|
|
1
|
-
import {useEffect, useState} from "react";
|
|
2
|
-
import {Badge, Button, Empty, SectionTitle} from "../components/ui";
|
|
3
|
-
|
|
4
|
-
type ProviderStatus = {
|
|
5
|
-
name: string;
|
|
6
|
-
title: string;
|
|
7
|
-
kind: string;
|
|
8
|
-
keyVariable: string;
|
|
9
|
-
docsUrl: string;
|
|
10
|
-
source: "environment" | "stored" | null;
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Where generation providers are connected.
|
|
15
|
-
*
|
|
16
|
-
* The page is deliberately one-way about secrets: a key can be typed here and
|
|
17
|
-
* sent to the local server once, but it never comes back — status is a source
|
|
18
|
-
* ("environment", "stored") and nothing else. A key from the environment wins
|
|
19
|
-
* over a stored one and cannot be edited here, because the place to change an
|
|
20
|
-
* environment is the environment.
|
|
21
|
-
*/
|
|
22
|
-
export const IntegrationsView = () => {
|
|
23
|
-
const [providers, setProviders] = useState<ProviderStatus[] | null>(null);
|
|
24
|
-
const [error, setError] = useState<string | null>(null);
|
|
25
|
-
|
|
26
|
-
const load = async () => {
|
|
27
|
-
try {
|
|
28
|
-
const response = await fetch("/__odori/integrations");
|
|
29
|
-
if (!response.ok) throw new Error(`${response.status}`);
|
|
30
|
-
const body = (await response.json()) as {providers: ProviderStatus[]};
|
|
31
|
-
setProviders(body.providers);
|
|
32
|
-
} catch {
|
|
33
|
-
setError("Studio could not reach its own server. Is odori dev still running?");
|
|
34
|
-
}
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
useEffect(() => {
|
|
38
|
-
void load();
|
|
39
|
-
}, []);
|
|
40
|
-
|
|
41
|
-
if (error) return <Empty title="Integrations unavailable">{error}</Empty>;
|
|
42
|
-
if (!providers) return <Empty title="Loading" />;
|
|
43
|
-
|
|
44
|
-
return (
|
|
45
|
-
<div className="integrations-view">
|
|
46
|
-
<section>
|
|
47
|
-
<SectionTitle>Music</SectionTitle>
|
|
48
|
-
<p className="hint">
|
|
49
|
-
A provider generates at author time only: the track lands in <code>public/audio</code>, prepared and
|
|
50
|
-
committed, and the render never calls a network. Keys live in <code>~/.config/odori</code> on this machine —
|
|
51
|
-
never in the project, and never readable from this page.
|
|
52
|
-
</p>
|
|
53
|
-
<ul className="integration-list">
|
|
54
|
-
{providers.map((provider) => (
|
|
55
|
-
<IntegrationRow key={provider.name} provider={provider} onChanged={load} />
|
|
56
|
-
))}
|
|
57
|
-
</ul>
|
|
58
|
-
</section>
|
|
59
|
-
{providers.some((provider) => provider.source !== null) ? (
|
|
60
|
-
<section>
|
|
61
|
-
<SectionTitle>Generate a bed</SectionTitle>
|
|
62
|
-
<GeneratePanel />
|
|
63
|
-
</section>
|
|
64
|
-
) : null}
|
|
65
|
-
</div>
|
|
66
|
-
);
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
type GenerateReport = {
|
|
70
|
-
destination: string;
|
|
71
|
-
role: string;
|
|
72
|
-
url: string | null;
|
|
73
|
-
registered: {file: string; already: boolean} | null;
|
|
74
|
-
before: {lufs: number; peak: number; range: number};
|
|
75
|
-
after: {lufs: number; peak: number};
|
|
76
|
-
warnings: string[];
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Prompt to registered role, without a terminal. The server walks the same
|
|
81
|
-
* path the CLI does — provider, prepare, register — and the numbers it prints
|
|
82
|
-
* there render here, warnings included, with the result playable in place.
|
|
83
|
-
*/
|
|
84
|
-
const GeneratePanel = () => {
|
|
85
|
-
const [prompt, setPrompt] = useState("");
|
|
86
|
-
const [seconds, setSeconds] = useState(60);
|
|
87
|
-
const [role, setRole] = useState("");
|
|
88
|
-
const [busy, setBusy] = useState(false);
|
|
89
|
-
const [error, setError] = useState<string | null>(null);
|
|
90
|
-
const [report, setReport] = useState<GenerateReport | null>(null);
|
|
91
|
-
|
|
92
|
-
const generate = async () => {
|
|
93
|
-
setBusy(true);
|
|
94
|
-
setError(null);
|
|
95
|
-
setReport(null);
|
|
96
|
-
try {
|
|
97
|
-
const response = await fetch("/__odori/generate", {
|
|
98
|
-
method: "POST",
|
|
99
|
-
headers: {"content-type": "application/json"},
|
|
100
|
-
body: JSON.stringify({prompt: prompt.trim(), seconds, role: role.trim() || undefined}),
|
|
101
|
-
});
|
|
102
|
-
const body = (await response.json()) as GenerateReport & {error?: string};
|
|
103
|
-
if (!response.ok) {
|
|
104
|
-
setError(body.error ?? "Generation failed.");
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
setReport(body);
|
|
108
|
-
} catch {
|
|
109
|
-
setError("Studio could not reach its own server. Is odori dev still running?");
|
|
110
|
-
} finally {
|
|
111
|
-
setBusy(false);
|
|
112
|
-
}
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
return (
|
|
116
|
-
<div className="integration generate-panel">
|
|
117
|
-
<textarea
|
|
118
|
-
className="generate-prompt"
|
|
119
|
-
rows={2}
|
|
120
|
-
placeholder="steady ambient bed, no drums, warm"
|
|
121
|
-
value={prompt}
|
|
122
|
-
onChange={(event) => setPrompt(event.target.value)}
|
|
123
|
-
disabled={busy}
|
|
124
|
-
aria-label="Prompt"
|
|
125
|
-
/>
|
|
126
|
-
<div className="generate-controls">
|
|
127
|
-
<label className="generate-field">
|
|
128
|
-
seconds
|
|
129
|
-
<input
|
|
130
|
-
type="number"
|
|
131
|
-
min={5}
|
|
132
|
-
max={300}
|
|
133
|
-
value={seconds}
|
|
134
|
-
onChange={(event) => setSeconds(Number(event.target.value) || 60)}
|
|
135
|
-
disabled={busy}
|
|
136
|
-
/>
|
|
137
|
-
</label>
|
|
138
|
-
<label className="generate-field">
|
|
139
|
-
role
|
|
140
|
-
<input
|
|
141
|
-
type="text"
|
|
142
|
-
placeholder="bed.main"
|
|
143
|
-
value={role}
|
|
144
|
-
onChange={(event) => setRole(event.target.value)}
|
|
145
|
-
disabled={busy}
|
|
146
|
-
/>
|
|
147
|
-
</label>
|
|
148
|
-
<Button variant="primary" disabled={busy || !prompt.trim()} onClick={() => void generate()}>
|
|
149
|
-
{busy ? "Generating…" : "Generate"}
|
|
150
|
-
</Button>
|
|
151
|
-
</div>
|
|
152
|
-
{busy ? <p className="hint">The provider is composing, then the track is levelled to the stem target. A minute is normal.</p> : null}
|
|
153
|
-
{error ? <p className="hint">{error}</p> : null}
|
|
154
|
-
{report ? (
|
|
155
|
-
<div className="generate-report">
|
|
156
|
-
{report.url ? <audio controls src={report.url} style={{width: "100%"}} /> : null}
|
|
157
|
-
<p className="hint">
|
|
158
|
-
{report.destination} · {report.before.lufs.toFixed(1)} → {report.after.lufs.toFixed(1)} LUFS · range{" "}
|
|
159
|
-
{report.before.range.toFixed(1)} LU
|
|
160
|
-
{report.registered
|
|
161
|
-
? report.registered.already
|
|
162
|
-
? ` · "${report.role}" was already registered`
|
|
163
|
-
: ` · registered "${report.role}" in ${report.registered.file}`
|
|
164
|
-
: ""}
|
|
165
|
-
</p>
|
|
166
|
-
{report.warnings.map((warning) => (
|
|
167
|
-
<p key={warning} className="hint">
|
|
168
|
-
{warning}
|
|
169
|
-
</p>
|
|
170
|
-
))}
|
|
171
|
-
<p className="integration-usage">
|
|
172
|
-
<code>{`<Audio src="${report.role}" fadeIn="1s" fadeOut="1.5s" duckUnder />`}</code>
|
|
173
|
-
</p>
|
|
174
|
-
</div>
|
|
175
|
-
) : null}
|
|
176
|
-
</div>
|
|
177
|
-
);
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
const IntegrationRow = ({provider, onChanged}: {provider: ProviderStatus; onChanged: () => Promise<void>}) => {
|
|
181
|
-
const [draft, setDraft] = useState("");
|
|
182
|
-
const [busy, setBusy] = useState(false);
|
|
183
|
-
const [message, setMessage] = useState<string | null>(null);
|
|
184
|
-
|
|
185
|
-
const submit = async (key: string) => {
|
|
186
|
-
setBusy(true);
|
|
187
|
-
setMessage(null);
|
|
188
|
-
try {
|
|
189
|
-
const response = await fetch("/__odori/integrations", {
|
|
190
|
-
method: "POST",
|
|
191
|
-
headers: {"content-type": "application/json"},
|
|
192
|
-
body: JSON.stringify({provider: provider.name, key}),
|
|
193
|
-
});
|
|
194
|
-
const body = (await response.json()) as {error?: string; verified?: boolean | null};
|
|
195
|
-
if (!response.ok) {
|
|
196
|
-
setMessage(body.error ?? "The key was not accepted.");
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
setDraft("");
|
|
200
|
-
setMessage(
|
|
201
|
-
key === ""
|
|
202
|
-
? "Key removed."
|
|
203
|
-
: body.verified
|
|
204
|
-
? "Connected. The key was checked against the provider and stored on this machine."
|
|
205
|
-
: "Stored. The provider could not be reached to check it, so the first generation will tell.",
|
|
206
|
-
);
|
|
207
|
-
await onChanged();
|
|
208
|
-
} catch {
|
|
209
|
-
setMessage("Studio could not reach its own server.");
|
|
210
|
-
} finally {
|
|
211
|
-
setBusy(false);
|
|
212
|
-
}
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
const connected = provider.source !== null;
|
|
216
|
-
const fromEnvironment = provider.source === "environment";
|
|
217
|
-
|
|
218
|
-
return (
|
|
219
|
-
<li className="integration">
|
|
220
|
-
<div className="integration-head">
|
|
221
|
-
<span className="integration-title">{provider.title}</span>
|
|
222
|
-
{connected ? (
|
|
223
|
-
<Badge tone="success">{fromEnvironment ? `connected · ${provider.keyVariable}` : "connected"}</Badge>
|
|
224
|
-
) : (
|
|
225
|
-
<Badge tone="warning">not configured</Badge>
|
|
226
|
-
)}
|
|
227
|
-
<a className="integration-docs" href={provider.docsUrl} target="_blank" rel="noreferrer">
|
|
228
|
-
docs
|
|
229
|
-
</a>
|
|
230
|
-
</div>
|
|
231
|
-
{fromEnvironment ? (
|
|
232
|
-
<p className="hint">
|
|
233
|
-
The key comes from <code>{provider.keyVariable}</code> in the environment, which always wins over a stored
|
|
234
|
-
one. Change it where the environment is set.
|
|
235
|
-
</p>
|
|
236
|
-
) : (
|
|
237
|
-
<form
|
|
238
|
-
className="integration-form"
|
|
239
|
-
onSubmit={(event) => {
|
|
240
|
-
event.preventDefault();
|
|
241
|
-
if (draft.trim()) void submit(draft.trim());
|
|
242
|
-
}}
|
|
243
|
-
>
|
|
244
|
-
<input
|
|
245
|
-
className="integration-input"
|
|
246
|
-
type="password"
|
|
247
|
-
autoComplete="off"
|
|
248
|
-
placeholder={connected ? "Replace the stored key" : `Paste an API key (${provider.keyVariable})`}
|
|
249
|
-
value={draft}
|
|
250
|
-
onChange={(event) => setDraft(event.target.value)}
|
|
251
|
-
disabled={busy}
|
|
252
|
-
aria-label={`${provider.title} API key`}
|
|
253
|
-
/>
|
|
254
|
-
<Button disabled={busy || !draft.trim()} onClick={() => void submit(draft.trim())}>
|
|
255
|
-
{connected ? "Replace" : "Connect"}
|
|
256
|
-
</Button>
|
|
257
|
-
{connected ? (
|
|
258
|
-
<Button disabled={busy} onClick={() => void submit("")}>
|
|
259
|
-
Disconnect
|
|
260
|
-
</Button>
|
|
261
|
-
) : null}
|
|
262
|
-
</form>
|
|
263
|
-
)}
|
|
264
|
-
{message ? <p className="hint">{message}</p> : null}
|
|
265
|
-
<p className="integration-usage">
|
|
266
|
-
<code>odori bed "steady ambient, no drums" --generate --role bed.main</code>
|
|
267
|
-
</p>
|
|
268
|
-
</li>
|
|
269
|
-
);
|
|
270
|
-
};
|