@chai-ui/react 0.7.1 → 0.9.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/DESIGN.md +2 -0
- package/dist/Composer.d.ts +31 -8
- package/dist/Composer.d.ts.map +1 -1
- package/dist/Composer.js +134 -55
- package/dist/Composer.js.map +1 -1
- package/dist/MediaAnalyzer.js +1 -4
- package/dist/MediaAnalyzer.js.map +1 -1
- package/dist/ResultCard.d.ts.map +1 -1
- package/dist/ResultCard.js +1 -5
- package/dist/ResultCard.js.map +1 -1
- package/dist/icons.d.ts +4 -0
- package/dist/icons.d.ts.map +1 -1
- package/dist/icons.js +8 -0
- package/dist/icons.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/primitives/SearchMenu.d.ts.map +1 -1
- package/dist/primitives/SearchMenu.js +1 -4
- package/dist/primitives/SearchMenu.js.map +1 -1
- package/dist/style.css +166 -1
- package/dist/useComposer.d.ts +1 -1
- package/package.json +3 -3
- package/src/Composer.tsx +408 -149
- package/src/MediaAnalyzer.tsx +1 -9
- package/src/ResultCard.tsx +1 -13
- package/src/icons.tsx +21 -0
- package/src/index.ts +5 -0
- package/src/primitives/SearchMenu.tsx +1 -12
- package/src/style.css +166 -1
package/src/Composer.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { StopIcon } from "./icons.js";
|
|
2
|
-
import { useEffect, useRef, useState, type ChangeEvent } from "react";
|
|
1
|
+
import { AddIcon, ArrowForwardIcon, CloseSymbolIcon, StopIcon } from "./icons.js";
|
|
2
|
+
import { useEffect, useId, useRef, useState, useSyncExternalStore, type ChangeEvent } from "react";
|
|
3
3
|
import { createComponent } from "@lit/react";
|
|
4
4
|
import * as React from "react";
|
|
5
5
|
import { MdFab } from "@material/web/fab/fab.js";
|
|
@@ -7,13 +7,19 @@ import {
|
|
|
7
7
|
composerAttachmentsFromFiles,
|
|
8
8
|
composerAttachMenuItems,
|
|
9
9
|
composerView,
|
|
10
|
+
createVoiceInput,
|
|
10
11
|
enhanceRevealDurationMs,
|
|
12
|
+
formatVoiceElapsed,
|
|
13
|
+
VOICE_LEVEL_COUNT,
|
|
11
14
|
MAX_ANIMATE_ENHANCE_LENGTH,
|
|
12
15
|
regionColor,
|
|
13
16
|
TEXT_USE_CASE,
|
|
14
17
|
type ComposerAttachMenuAction,
|
|
18
|
+
type ComposerPromptField,
|
|
19
|
+
type VoiceInputConfig,
|
|
15
20
|
} from "@chai-ui/core";
|
|
16
|
-
import
|
|
21
|
+
import { useLatest } from "./use-latest.js";
|
|
22
|
+
import type { EditRegion, GenerationUseCase, ModelOption, ParameterOption, MediaKind, OutputKind } from "@chai-ui/core";
|
|
17
23
|
import { SearchMenu } from "./primitives/SearchMenu.js";
|
|
18
24
|
import type { ComposerAttachment, ComposerSubmitPayload, ComposerUseCase } from "@chai-ui/core";
|
|
19
25
|
|
|
@@ -22,6 +28,7 @@ export {
|
|
|
22
28
|
TEXT_USE_CASE,
|
|
23
29
|
type ComposerAttachMenuAction,
|
|
24
30
|
type ComposerAttachment,
|
|
31
|
+
type ComposerPromptField,
|
|
25
32
|
type ComposerSelection,
|
|
26
33
|
type ComposerSubmitPayload,
|
|
27
34
|
type ComposerUseCase,
|
|
@@ -61,7 +68,7 @@ export interface ComposerProps {
|
|
|
61
68
|
useCases?: ComposerUseCase[];
|
|
62
69
|
onUseCasesChange?: (next: ComposerUseCase[]) => void;
|
|
63
70
|
/** Models per use-case kind, for `multiSelectUseCases`. A kind left out gets Chai's suggested models (see `models`). */
|
|
64
|
-
modelsByKind?: Partial<Record<
|
|
71
|
+
modelsByKind?: Partial<Record<OutputKind, ModelOption[]>>;
|
|
65
72
|
/**
|
|
66
73
|
* Builder opt-in, off by default: the end user can pick several models,
|
|
67
74
|
* each producing its own result (ResultCard pages through them). Uses
|
|
@@ -118,11 +125,26 @@ export interface ComposerProps {
|
|
|
118
125
|
* runs the rewrite is still an open question at the design-doc level,
|
|
119
126
|
* not just an implementation gap — see the design open question 5
|
|
120
127
|
* ("Enhance model"). Receives the current prompt text so the handler is
|
|
121
|
-
* self-contained — doesn't need its own closure over `value`.
|
|
128
|
+
* self-contained — doesn't need its own closure over `value`. With a
|
|
129
|
+
* world use case each field has its own button: `field` is "environment"
|
|
130
|
+
* or "character" (otherwise "prompt"), so the rewrite can fit the field,
|
|
131
|
+
* and the handler calls `onChange` or `onCharacterChange` to match.
|
|
132
|
+
* `mode` is the use case on screen, e.g. "text-to-text" with none picked:
|
|
133
|
+
* rewrite a text request as one, not as a media prompt.
|
|
122
134
|
*/
|
|
123
|
-
onEnhance?: (value: string) => void;
|
|
124
|
-
/** True while the builder's own `onEnhance` handler is running its LLM call — shows the button
|
|
135
|
+
onEnhance?: (value: string, field: ComposerPromptField, mode?: GenerationUseCase) => void;
|
|
136
|
+
/** True while the builder's own `onEnhance` handler is running its LLM call — shows the active state on the button that was clicked. */
|
|
125
137
|
enhancing?: boolean;
|
|
138
|
+
/**
|
|
139
|
+
* A world's character field (Create world), shown beside the environment
|
|
140
|
+
* while the use case is a world. `value` is the environment. Without
|
|
141
|
+
* `onCharacterChange`, only the environment shows.
|
|
142
|
+
*/
|
|
143
|
+
character?: string;
|
|
144
|
+
onCharacterChange?: (value: string) => void;
|
|
145
|
+
/** A world's frame rate menu, from the selected models' `ModelOption.frameRates`. */
|
|
146
|
+
frameRate?: number | null;
|
|
147
|
+
onFrameRateChange?: (value: number) => void;
|
|
126
148
|
/** Fires with everything Composer tracks — see `ComposerSubmitPayload`. Also what retry calls, with the same payload, when `submitError` is set. */
|
|
127
149
|
onSubmit: (payload: ComposerSubmitPayload) => void;
|
|
128
150
|
/**
|
|
@@ -164,6 +186,14 @@ export interface ComposerProps {
|
|
|
164
186
|
*/
|
|
165
187
|
regions?: EditRegion[];
|
|
166
188
|
onRegionsChange?: (next: EditRegion[]) => void;
|
|
189
|
+
/**
|
|
190
|
+
* Turns on the mic, left of submit: the person speaks, and the transcript
|
|
191
|
+
* is added to the prompt (never submitted). Name a Fal speech-to-text
|
|
192
|
+
* model, e.g. `{ model: "fal-ai/wizper" }`, and the clip goes through your
|
|
193
|
+
* `/api/chai/fal` route; or pass `{ transcribe }` for any other provider.
|
|
194
|
+
* Off by default, and there's no default model.
|
|
195
|
+
*/
|
|
196
|
+
voiceInput?: VoiceInputConfig;
|
|
167
197
|
disabled?: boolean;
|
|
168
198
|
}
|
|
169
199
|
|
|
@@ -206,6 +236,10 @@ export function Composer({
|
|
|
206
236
|
onAspectRatioChange,
|
|
207
237
|
onEnhance,
|
|
208
238
|
enhancing = false,
|
|
239
|
+
character = "",
|
|
240
|
+
onCharacterChange,
|
|
241
|
+
frameRate = null,
|
|
242
|
+
onFrameRateChange,
|
|
209
243
|
onSubmit,
|
|
210
244
|
submitError = null,
|
|
211
245
|
submitDisabled,
|
|
@@ -215,6 +249,7 @@ export function Composer({
|
|
|
215
249
|
attachMenuActions,
|
|
216
250
|
regions = [],
|
|
217
251
|
onRegionsChange,
|
|
252
|
+
voiceInput,
|
|
218
253
|
disabled = false,
|
|
219
254
|
}: ComposerProps) {
|
|
220
255
|
const view = composerView({
|
|
@@ -235,6 +270,8 @@ export function Composer({
|
|
|
235
270
|
autoSelectModel,
|
|
236
271
|
aspectRatios,
|
|
237
272
|
aspectRatio,
|
|
273
|
+
character,
|
|
274
|
+
frameRate,
|
|
238
275
|
submitError,
|
|
239
276
|
submitDisabled,
|
|
240
277
|
submitting,
|
|
@@ -271,79 +308,21 @@ export function Composer({
|
|
|
271
308
|
wasAutoSelect.current = autoSelectModel;
|
|
272
309
|
}, [autoSelectModel, multiModel, onModelChange, onModelIdsChange]);
|
|
273
310
|
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (!el) return;
|
|
283
|
-
el.style.height = "auto";
|
|
284
|
-
el.style.height = `${el.scrollHeight}px`;
|
|
285
|
-
}, [value]);
|
|
286
|
-
|
|
287
|
-
// The optimize/undo cycle (§ its own button below). `value` is already
|
|
288
|
-
// the final rewritten text the instant `enhancing` drops back to false —
|
|
289
|
-
// the reveal below is a purely cosmetic overlay on top of it, not a
|
|
290
|
-
// delay, so nothing here ever gates what the real `<textarea>` holds.
|
|
291
|
-
const [originalValue, setOriginalValue] = useState<string | null>(null);
|
|
292
|
-
const [enhancedValue, setEnhancedValue] = useState<string | null>(null);
|
|
293
|
-
const [prevEnhancing, setPrevEnhancing] = useState(enhancing);
|
|
294
|
-
const [animatingText, setAnimatingText] = useState<string | null>(null);
|
|
295
|
-
const [revealedLength, setRevealedLength] = useState(0);
|
|
296
|
-
|
|
297
|
-
// React to `enhancing` dropping back to false while rendering, not in an
|
|
298
|
-
// effect (the rules of React): that's the moment the rewrite has landed.
|
|
299
|
-
if (prevEnhancing !== enhancing) {
|
|
300
|
-
setPrevEnhancing(enhancing);
|
|
301
|
-
if (prevEnhancing && !enhancing && originalValue != null && value !== originalValue) {
|
|
302
|
-
setEnhancedValue(value);
|
|
303
|
-
// Bails on length (see the constant's own comment) or on a real
|
|
304
|
-
// `prefers-reduced-motion` request — the reveal is itself motion, not
|
|
305
|
-
// just the shimmer the CSS `@media` query above separately turns off.
|
|
306
|
-
const reducedMotion =
|
|
307
|
-
typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
308
|
-
if (value.length <= MAX_ANIMATE_ENHANCE_LENGTH && !reducedMotion) {
|
|
309
|
-
setRevealedLength(0);
|
|
310
|
-
setAnimatingText(value);
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
useEffect(() => {
|
|
316
|
-
if (animatingText == null) return;
|
|
317
|
-
const total = animatingText.length;
|
|
318
|
-
const duration = enhanceRevealDurationMs(total);
|
|
319
|
-
const start = performance.now();
|
|
320
|
-
let raf: number;
|
|
321
|
-
const tick = (now: number) => {
|
|
322
|
-
const elapsed = now - start;
|
|
323
|
-
const next = Math.min(total, Math.floor((elapsed / duration) * total));
|
|
324
|
-
setRevealedLength((prev) => (prev === next ? prev : next));
|
|
325
|
-
if (next < total) raf = requestAnimationFrame(tick);
|
|
326
|
-
else setAnimatingText(null);
|
|
327
|
-
};
|
|
328
|
-
raf = requestAnimationFrame(tick);
|
|
329
|
-
return () => cancelAnimationFrame(raf);
|
|
330
|
-
}, [animatingText]);
|
|
331
|
-
|
|
332
|
-
const animating = animatingText != null;
|
|
333
|
-
const isEnhanced = !animating && enhancedValue != null && value === enhancedValue;
|
|
334
|
-
const enhanceBusy = enhancing || animating;
|
|
311
|
+
// Which field's Enhance is running: `enhancing` is one flag for the bar.
|
|
312
|
+
const [enhancingField, setEnhancingField] = useState<ComposerPromptField>("prompt");
|
|
313
|
+
const enhanceField = (field: ComposerPromptField) =>
|
|
314
|
+
onEnhance &&
|
|
315
|
+
((current: string) => {
|
|
316
|
+
setEnhancingField(field);
|
|
317
|
+
onEnhance(current, field, view.enhanceMode);
|
|
318
|
+
});
|
|
335
319
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
return;
|
|
343
|
-
}
|
|
344
|
-
setOriginalValue(value); // snapshot now — before the rewrite lands, for undo
|
|
345
|
-
onEnhance?.(value);
|
|
346
|
-
}
|
|
320
|
+
// Voice input: a transcript is added to the prompt, never submitted.
|
|
321
|
+
const latestPrompt = useLatest({ value, onChange });
|
|
322
|
+
const voice = useVoice(voiceInput, (text) => {
|
|
323
|
+
const { value: current, onChange: change } = latestPrompt.current;
|
|
324
|
+
change(current.trim() ? `${current.trimEnd()} ${text}` : text);
|
|
325
|
+
});
|
|
347
326
|
|
|
348
327
|
async function handleFilesSelected(fileList: FileList | null) {
|
|
349
328
|
if (!fileList || fileList.length === 0 || !onAttachmentsChange) return;
|
|
@@ -391,49 +370,45 @@ export function Composer({
|
|
|
391
370
|
</ul>
|
|
392
371
|
)}
|
|
393
372
|
|
|
394
|
-
|
|
395
|
-
<
|
|
396
|
-
|
|
397
|
-
|
|
373
|
+
{voiceInput && voice.state.status === "recording" ? (
|
|
374
|
+
<VoiceWaveform levels={voice.state.levels} elapsedMs={voice.state.elapsedMs} />
|
|
375
|
+
) : view.world ? (
|
|
376
|
+
<div className="chai-composer__world-fields">
|
|
377
|
+
<PromptField
|
|
378
|
+
value={value}
|
|
379
|
+
onChange={onChange}
|
|
380
|
+
placeholder={view.placeholder}
|
|
381
|
+
heading="Environment"
|
|
382
|
+
note="(required)"
|
|
383
|
+
hint={view.environmentHint}
|
|
384
|
+
onEnhance={enhanceField("environment")}
|
|
385
|
+
enhancing={enhancing && enhancingField === "environment"}
|
|
386
|
+
disabled={disabled}
|
|
387
|
+
/>
|
|
388
|
+
{onCharacterChange && (
|
|
389
|
+
<PromptField
|
|
390
|
+
value={character}
|
|
391
|
+
onChange={onCharacterChange}
|
|
392
|
+
placeholder={view.characterPlaceholder}
|
|
393
|
+
heading="Character"
|
|
394
|
+
note="(optional)"
|
|
395
|
+
onEnhance={enhanceField("character")}
|
|
396
|
+
enhancing={enhancing && enhancingField === "character"}
|
|
397
|
+
disabled={disabled}
|
|
398
|
+
/>
|
|
399
|
+
)}
|
|
400
|
+
</div>
|
|
401
|
+
) : (
|
|
402
|
+
<PromptField
|
|
398
403
|
value={value}
|
|
399
|
-
onChange={
|
|
404
|
+
onChange={onChange}
|
|
400
405
|
placeholder={view.placeholder}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
406
|
+
label={promptLabel}
|
|
407
|
+
onEnhance={enhanceField("prompt")}
|
|
408
|
+
enhancing={enhancing}
|
|
409
|
+
disabled={disabled}
|
|
404
410
|
/>
|
|
405
|
-
|
|
406
|
-
textarea above already holds the final text throughout (just
|
|
407
|
-
visually suppressed via `--revealing`); this div paints the
|
|
408
|
-
same text back in progressively with a gradient, then unmounts.
|
|
409
|
-
`aria-hidden` so a screen reader isn't told the text twice —
|
|
410
|
-
the textarea's own (already-final) value is what it reads. */}
|
|
411
|
-
{animating && (
|
|
412
|
-
<div className="chai-composer__reveal chai-composer__type" aria-hidden="true">
|
|
413
|
-
<span className="chai-composer__reveal-text">{animatingText!.slice(0, revealedLength)}</span>
|
|
414
|
-
</div>
|
|
415
|
-
)}
|
|
416
|
-
{/* "Optimize prompt" / "Revert to original prompt" — see
|
|
417
|
-
`onEnhance`'s own doc comment on `ComposerProps` for what the
|
|
418
|
-
rewrite itself actually does and why Composer doesn't run it.
|
|
419
|
-
Once a rewrite lands, this becomes an undo affordance instead —
|
|
420
|
-
an already-optimized prompt can't be re-optimized — and reverts
|
|
421
|
-
to a plain optimize button again the moment `value` no longer
|
|
422
|
-
matches that rewrite exactly, i.e. the person has since edited
|
|
423
|
-
it by hand. */}
|
|
424
|
-
{onEnhance && (
|
|
425
|
-
<button
|
|
426
|
-
type="button"
|
|
427
|
-
className={`chai-composer__optimize${enhanceBusy ? " chai-composer__optimize--active" : ""}`}
|
|
428
|
-
onClick={handleOptimizeClick}
|
|
429
|
-
disabled={disabled || (isEnhanced ? false : !value.trim() || enhanceBusy)}
|
|
430
|
-
aria-label={isEnhanced ? "Revert to original prompt" : "Optimize prompt"}
|
|
431
|
-
title={isEnhanced ? "Revert to original prompt" : "Optimize prompt"}
|
|
432
|
-
>
|
|
433
|
-
{isEnhanced ? <UndoIcon /> : <OptimizeIcon />}
|
|
434
|
-
</button>
|
|
435
|
-
)}
|
|
436
|
-
</div>
|
|
411
|
+
)}
|
|
437
412
|
|
|
438
413
|
{submitError && (
|
|
439
414
|
<p className="chai-composer__error" role="alert">
|
|
@@ -442,6 +417,13 @@ export function Composer({
|
|
|
442
417
|
</p>
|
|
443
418
|
)}
|
|
444
419
|
|
|
420
|
+
{voiceInput && voice.state.status === "error" && (
|
|
421
|
+
<p className="chai-composer__error" role="alert">
|
|
422
|
+
<ErrorIcon />
|
|
423
|
+
{voice.state.error}
|
|
424
|
+
</p>
|
|
425
|
+
)}
|
|
426
|
+
|
|
445
427
|
<div className="chai-composer__controls">
|
|
446
428
|
<div className="chai-composer__controls-start">
|
|
447
429
|
{menuItems.length > 0 && (
|
|
@@ -511,6 +493,19 @@ export function Composer({
|
|
|
511
493
|
disabled={disabled}
|
|
512
494
|
/>
|
|
513
495
|
)}
|
|
496
|
+
|
|
497
|
+
{view.frameRateOptions.length > 0 && onFrameRateChange && (
|
|
498
|
+
<SearchMenu
|
|
499
|
+
options={view.frameRateOptions.map((r) => ({ id: String(r), label: `${r}fps` }))}
|
|
500
|
+
value={view.offeredFrameRate == null ? null : String(view.offeredFrameRate)}
|
|
501
|
+
triggerLabel={view.offeredFrameRate == null ? "Frame rate" : `${view.offeredFrameRate}fps`}
|
|
502
|
+
menuLabel="Frame rate"
|
|
503
|
+
onSelect={(opt) => onFrameRateChange(Number(opt.id))}
|
|
504
|
+
searchable={false}
|
|
505
|
+
triggerVariant="text"
|
|
506
|
+
disabled={disabled}
|
|
507
|
+
/>
|
|
508
|
+
)}
|
|
514
509
|
</div>
|
|
515
510
|
|
|
516
511
|
{/* MD3's `<md-fab>` has no `disabled` property of its own (FABs are
|
|
@@ -549,6 +544,11 @@ export function Composer({
|
|
|
549
544
|
(same filled look — reads as "in progress," not "empty/error"),
|
|
550
545
|
clickable regardless of `isSubmitDisabled` since a request
|
|
551
546
|
already in flight isn't gated on the field having content. */}
|
|
547
|
+
<div className="chai-composer__controls-end">
|
|
548
|
+
{voiceInput && <VoiceButton voice={voice} disabled={disabled} />}
|
|
549
|
+
{/* No submit while a recording is being made or transcribed: the
|
|
550
|
+
person sends the transcript once it's in the prompt to check. */}
|
|
551
|
+
{!(voiceInput && (voice.state.status === "recording" || voice.state.status === "transcribing")) && (
|
|
552
552
|
<MdFabElement
|
|
553
553
|
className="chai-composer__submit"
|
|
554
554
|
size="small"
|
|
@@ -575,7 +575,267 @@ export function Composer({
|
|
|
575
575
|
{showStop ? <StopIcon /> : submitError ? <RefreshIcon /> : <ArrowForwardIcon />}
|
|
576
576
|
</span>
|
|
577
577
|
</MdFabElement>
|
|
578
|
+
)}
|
|
579
|
+
</div>
|
|
580
|
+
</div>
|
|
581
|
+
</div>
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** The core recorder, created once, with the latest config and transcript handler. */
|
|
586
|
+
function useVoice(config: VoiceInputConfig | undefined, onTranscript: (text: string) => void) {
|
|
587
|
+
const latest = useLatest({ config, onTranscript });
|
|
588
|
+
const [voice] = useState(() =>
|
|
589
|
+
createVoiceInput(
|
|
590
|
+
() => latest.current.config ?? { transcribe: async () => "" },
|
|
591
|
+
(text) => latest.current.onTranscript(text)
|
|
592
|
+
)
|
|
593
|
+
);
|
|
594
|
+
const state = useSyncExternalStore(voice.subscribe, voice.getState, voice.getState);
|
|
595
|
+
useEffect(() => () => voice.dispose(), [voice]);
|
|
596
|
+
return { ...voice, state };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* What replaces the prompt while recording: the mic's recent loudness as
|
|
601
|
+
* bars, newest on the right, and how long it's run. Decorative: the mic
|
|
602
|
+
* button's label and the status line say it's recording.
|
|
603
|
+
*/
|
|
604
|
+
function VoiceWaveform({ levels, elapsedMs }: { levels: number[]; elapsedMs: number }) {
|
|
605
|
+
const bars = [...Array<number>(Math.max(0, VOICE_LEVEL_COUNT - levels.length)).fill(0), ...levels];
|
|
606
|
+
return (
|
|
607
|
+
<div className="chai-composer__waveform" aria-hidden="true">
|
|
608
|
+
<div className="chai-composer__waveform-bars">
|
|
609
|
+
{bars.map((level, i) => (
|
|
610
|
+
<span key={i} className="chai-composer__waveform-bar" style={{ height: `${Math.max(20, level * 100)}%` }} />
|
|
611
|
+
))}
|
|
612
|
+
</div>
|
|
613
|
+
<span className="chai-composer__waveform-time">{formatVoiceElapsed(elapsedMs)}</span>
|
|
614
|
+
</div>
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* The mic: click to record; while recording it's a stop button that ends
|
|
620
|
+
* the recording and transcribes it; while transcribing, a spinner, and a
|
|
621
|
+
* click cancels.
|
|
622
|
+
*/
|
|
623
|
+
function VoiceButton({ voice, disabled }: { voice: ReturnType<typeof useVoice>; disabled: boolean }) {
|
|
624
|
+
const { status } = voice.state;
|
|
625
|
+
const label =
|
|
626
|
+
status === "recording" ? "Stop recording and transcribe" : status === "transcribing" ? "Cancel transcription" : "Start voice input";
|
|
627
|
+
function handleClick() {
|
|
628
|
+
if (status === "recording") voice.stop();
|
|
629
|
+
else if (status === "transcribing") voice.cancel();
|
|
630
|
+
else {
|
|
631
|
+
voice.dismiss();
|
|
632
|
+
void voice.start();
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return (
|
|
636
|
+
<div className="chai-composer__voice" data-status={status}>
|
|
637
|
+
<button
|
|
638
|
+
type="button"
|
|
639
|
+
className="chai-composer__voice-button"
|
|
640
|
+
onClick={handleClick}
|
|
641
|
+
onKeyDown={(e) => {
|
|
642
|
+
if (e.key === "Escape" && status === "recording") voice.cancel();
|
|
643
|
+
}}
|
|
644
|
+
disabled={disabled}
|
|
645
|
+
aria-label={label}
|
|
646
|
+
title={label}
|
|
647
|
+
>
|
|
648
|
+
{status === "transcribing" ? (
|
|
649
|
+
<span className="chai-composer__voice-spinner" aria-hidden="true" />
|
|
650
|
+
) : status === "recording" ? (
|
|
651
|
+
<StopIcon />
|
|
652
|
+
) : (
|
|
653
|
+
<MicIcon />
|
|
654
|
+
)}
|
|
655
|
+
</button>
|
|
656
|
+
{/* Announces each change of state once, not every tick of the timer. */}
|
|
657
|
+
<span className="chai-visually-hidden" role="status">
|
|
658
|
+
{status === "recording" ? "Recording" : status === "transcribing" ? "Transcribing" : ""}
|
|
659
|
+
</span>
|
|
660
|
+
</div>
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* One prompt textarea with its Enhance button: grows with wrapped text,
|
|
666
|
+
* reveals a rewrite as it lands, and offers undo until the text is edited
|
|
667
|
+
* by hand. A `heading` gives it a visible label above a filled box (a
|
|
668
|
+
* world's Environment and Character); without one it's the bar's plain
|
|
669
|
+
* prompt.
|
|
670
|
+
*/
|
|
671
|
+
function PromptField({
|
|
672
|
+
value,
|
|
673
|
+
onChange,
|
|
674
|
+
placeholder,
|
|
675
|
+
label,
|
|
676
|
+
heading,
|
|
677
|
+
note,
|
|
678
|
+
hint,
|
|
679
|
+
onEnhance,
|
|
680
|
+
enhancing,
|
|
681
|
+
disabled,
|
|
682
|
+
}: {
|
|
683
|
+
value: string;
|
|
684
|
+
onChange: (value: string) => void;
|
|
685
|
+
placeholder: string;
|
|
686
|
+
/** Accessible name for the plain prompt. Defaults to `placeholder`. */
|
|
687
|
+
label?: string;
|
|
688
|
+
heading?: string;
|
|
689
|
+
note?: string;
|
|
690
|
+
/** Shown under a headed field, e.g. why submit can't go yet. */
|
|
691
|
+
hint?: string | null;
|
|
692
|
+
onEnhance?: (value: string) => void;
|
|
693
|
+
enhancing: boolean;
|
|
694
|
+
disabled: boolean;
|
|
695
|
+
}) {
|
|
696
|
+
const id = useId();
|
|
697
|
+
const hintId = `${id}-hint`;
|
|
698
|
+
|
|
699
|
+
// Auto-grows with wrapped content instead of a fixed row count — a
|
|
700
|
+
// static `rows` either hides wrapped lines behind a scrollbar (too
|
|
701
|
+
// short) or wastes space while empty (too tall). Starts at one line
|
|
702
|
+
// (matching the mock's compact minimal state) and grows only as typed
|
|
703
|
+
// text actually wraps.
|
|
704
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
705
|
+
useEffect(() => {
|
|
706
|
+
const el = textareaRef.current;
|
|
707
|
+
if (!el) return;
|
|
708
|
+
el.style.height = "auto";
|
|
709
|
+
el.style.height = `${el.scrollHeight}px`;
|
|
710
|
+
}, [value]);
|
|
711
|
+
|
|
712
|
+
// The optimize/undo cycle (§ its own button below). `value` is already
|
|
713
|
+
// the final rewritten text the instant `enhancing` drops back to false —
|
|
714
|
+
// the reveal below is a purely cosmetic overlay on top of it, not a
|
|
715
|
+
// delay, so nothing here ever gates what the real `<textarea>` holds.
|
|
716
|
+
const [originalValue, setOriginalValue] = useState<string | null>(null);
|
|
717
|
+
const [enhancedValue, setEnhancedValue] = useState<string | null>(null);
|
|
718
|
+
const [prevEnhancing, setPrevEnhancing] = useState(enhancing);
|
|
719
|
+
const [animatingText, setAnimatingText] = useState<string | null>(null);
|
|
720
|
+
const [revealedLength, setRevealedLength] = useState(0);
|
|
721
|
+
|
|
722
|
+
// React to `enhancing` dropping back to false while rendering, not in an
|
|
723
|
+
// effect (the rules of React): that's the moment the rewrite has landed.
|
|
724
|
+
if (prevEnhancing !== enhancing) {
|
|
725
|
+
setPrevEnhancing(enhancing);
|
|
726
|
+
if (prevEnhancing && !enhancing && originalValue != null && value !== originalValue) {
|
|
727
|
+
setEnhancedValue(value);
|
|
728
|
+
// Bails on length (see the constant's own comment) or on a real
|
|
729
|
+
// `prefers-reduced-motion` request — the reveal is itself motion, not
|
|
730
|
+
// just the shimmer the CSS `@media` query above separately turns off.
|
|
731
|
+
const reducedMotion =
|
|
732
|
+
typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
733
|
+
if (value.length <= MAX_ANIMATE_ENHANCE_LENGTH && !reducedMotion) {
|
|
734
|
+
setRevealedLength(0);
|
|
735
|
+
setAnimatingText(value);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
useEffect(() => {
|
|
741
|
+
if (animatingText == null) return;
|
|
742
|
+
const total = animatingText.length;
|
|
743
|
+
const duration = enhanceRevealDurationMs(total);
|
|
744
|
+
const start = performance.now();
|
|
745
|
+
let raf: number;
|
|
746
|
+
const tick = (now: number) => {
|
|
747
|
+
const elapsed = now - start;
|
|
748
|
+
const next = Math.min(total, Math.floor((elapsed / duration) * total));
|
|
749
|
+
setRevealedLength((prev) => (prev === next ? prev : next));
|
|
750
|
+
if (next < total) raf = requestAnimationFrame(tick);
|
|
751
|
+
else setAnimatingText(null);
|
|
752
|
+
};
|
|
753
|
+
raf = requestAnimationFrame(tick);
|
|
754
|
+
return () => cancelAnimationFrame(raf);
|
|
755
|
+
}, [animatingText]);
|
|
756
|
+
|
|
757
|
+
const animating = animatingText != null;
|
|
758
|
+
const isEnhanced = !animating && enhancedValue != null && value === enhancedValue;
|
|
759
|
+
const enhanceBusy = enhancing || animating;
|
|
760
|
+
|
|
761
|
+
function handleOptimizeClick() {
|
|
762
|
+
if (isEnhanced) {
|
|
763
|
+
const original = originalValue;
|
|
764
|
+
setEnhancedValue(null);
|
|
765
|
+
setOriginalValue(null);
|
|
766
|
+
if (original != null) onChange(original);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
setOriginalValue(value); // snapshot now — before the rewrite lands, for undo
|
|
770
|
+
onEnhance?.(value);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const optimizeLabel = isEnhanced ? "Revert to original" : "Optimize";
|
|
774
|
+
const optimizeName = `${optimizeLabel} ${heading ? heading.toLowerCase() : "prompt"}`;
|
|
775
|
+
/* "Optimize prompt" / "Revert to original prompt" — see `onEnhance`'s own
|
|
776
|
+
doc comment on `ComposerProps` for what the rewrite itself actually
|
|
777
|
+
does and why Composer doesn't run it. Once a rewrite lands, this
|
|
778
|
+
becomes an undo affordance instead — an already-optimized prompt can't
|
|
779
|
+
be re-optimized — and reverts to a plain optimize button again the
|
|
780
|
+
moment `value` no longer matches that rewrite exactly, i.e. the person
|
|
781
|
+
has since edited it by hand. */
|
|
782
|
+
const optimize = onEnhance && (
|
|
783
|
+
<button
|
|
784
|
+
type="button"
|
|
785
|
+
className={`chai-composer__optimize${enhanceBusy ? " chai-composer__optimize--active" : ""}`}
|
|
786
|
+
onClick={handleOptimizeClick}
|
|
787
|
+
disabled={disabled || (isEnhanced ? false : !value.trim() || enhanceBusy)}
|
|
788
|
+
aria-label={optimizeName}
|
|
789
|
+
title={optimizeName}
|
|
790
|
+
>
|
|
791
|
+
{isEnhanced ? <UndoIcon /> : <OptimizeIcon />}
|
|
792
|
+
</button>
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
const field = (
|
|
796
|
+
<div className={`chai-composer__field${heading ? " chai-composer__field--boxed" : ""}`}>
|
|
797
|
+
<textarea
|
|
798
|
+
ref={textareaRef}
|
|
799
|
+
id={id}
|
|
800
|
+
className={`chai-composer__input chai-composer__type${animating ? " chai-composer__input--revealing" : ""}`}
|
|
801
|
+
value={value}
|
|
802
|
+
onChange={(e) => onChange(e.target.value)}
|
|
803
|
+
placeholder={placeholder}
|
|
804
|
+
aria-label={heading ? undefined : (label ?? placeholder)}
|
|
805
|
+
aria-describedby={hint ? hintId : undefined}
|
|
806
|
+
rows={1}
|
|
807
|
+
disabled={disabled || animating}
|
|
808
|
+
/>
|
|
809
|
+
{/* The optimize/undo reveal overlay — decorative only. The real
|
|
810
|
+
textarea above already holds the final text throughout (just
|
|
811
|
+
visually suppressed via `--revealing`); this div paints the
|
|
812
|
+
same text back in progressively with a gradient, then unmounts.
|
|
813
|
+
`aria-hidden` so a screen reader isn't told the text twice —
|
|
814
|
+
the textarea's own (already-final) value is what it reads. */}
|
|
815
|
+
{animating && (
|
|
816
|
+
<div className="chai-composer__reveal chai-composer__type" aria-hidden="true">
|
|
817
|
+
<span className="chai-composer__reveal-text">{animatingText!.slice(0, revealedLength)}</span>
|
|
818
|
+
</div>
|
|
819
|
+
)}
|
|
820
|
+
{!heading && optimize}
|
|
821
|
+
</div>
|
|
822
|
+
);
|
|
823
|
+
|
|
824
|
+
if (!heading) return field;
|
|
825
|
+
return (
|
|
826
|
+
<div className="chai-composer__world-field">
|
|
827
|
+
<div className="chai-composer__world-field-head">
|
|
828
|
+
<label htmlFor={id} className="chai-composer__world-field-label">
|
|
829
|
+
<strong>{heading}</strong> {note}
|
|
830
|
+
</label>
|
|
831
|
+
{optimize}
|
|
578
832
|
</div>
|
|
833
|
+
{field}
|
|
834
|
+
{hint && (
|
|
835
|
+
<p id={hintId} className="chai-composer__field-hint">
|
|
836
|
+
{hint}
|
|
837
|
+
</p>
|
|
838
|
+
)}
|
|
579
839
|
</div>
|
|
580
840
|
);
|
|
581
841
|
}
|
|
@@ -584,6 +844,7 @@ const ATTACH_MENU_ICONS: Record<ComposerAttachMenuAction, React.ReactNode> = {
|
|
|
584
844
|
"add-media": <AttachFileIcon />,
|
|
585
845
|
"create-image": <ImageIcon />,
|
|
586
846
|
"create-video": <VideocamIcon />,
|
|
847
|
+
"create-world": <GlobeIcon />,
|
|
587
848
|
"edit-media": <ContentCutIcon />,
|
|
588
849
|
};
|
|
589
850
|
|
|
@@ -645,7 +906,7 @@ function ComposerAttachMenu({
|
|
|
645
906
|
aria-haspopup="menu"
|
|
646
907
|
aria-label={open ? "Close attach menu" : "Add media"}
|
|
647
908
|
>
|
|
648
|
-
{open ? <
|
|
909
|
+
{open ? <CloseSymbolIcon /> : <AddIcon />}
|
|
649
910
|
</button>
|
|
650
911
|
|
|
651
912
|
{open && (
|
|
@@ -693,7 +954,7 @@ function ComposerThumbnail({ attachment, onRemove }: { attachment: ComposerAttac
|
|
|
693
954
|
)}
|
|
694
955
|
{onRemove && (
|
|
695
956
|
<button type="button" className="chai-composer__thumb-remove" onClick={onRemove} aria-label="Remove attachment">
|
|
696
|
-
<
|
|
957
|
+
<CloseSymbolIcon />
|
|
697
958
|
</button>
|
|
698
959
|
)}
|
|
699
960
|
</div>
|
|
@@ -732,7 +993,7 @@ function UseCaseChip({
|
|
|
732
993
|
disabled={disabled}
|
|
733
994
|
aria-label={`Remove ${useCase.label}`}
|
|
734
995
|
>
|
|
735
|
-
<
|
|
996
|
+
<CloseSymbolIcon />
|
|
736
997
|
</button>
|
|
737
998
|
)}
|
|
738
999
|
</span>
|
|
@@ -771,7 +1032,7 @@ function RegionChip({
|
|
|
771
1032
|
disabled={disabled}
|
|
772
1033
|
aria-label={`Remove ${name}`}
|
|
773
1034
|
>
|
|
774
|
-
<
|
|
1035
|
+
<CloseSymbolIcon />
|
|
775
1036
|
</button>
|
|
776
1037
|
)}
|
|
777
1038
|
</span>
|
|
@@ -813,32 +1074,6 @@ function kindIcon(kind: MediaKind) {
|
|
|
813
1074
|
// themed via the active/inactive CSS class instead of two hardcoded-color
|
|
814
1075
|
// SVGs. See its button's own doc comment above for what it actually does.
|
|
815
1076
|
|
|
816
|
-
function PlusIcon() {
|
|
817
|
-
return (
|
|
818
|
-
<svg viewBox="0 -960 960 960" aria-hidden="true">
|
|
819
|
-
<path d="M450-450H200v-60h250v-250h60v250h250v60H510v250h-60v-250Z" fill="currentColor" />
|
|
820
|
-
</svg>
|
|
821
|
-
);
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
// Material Symbols "close" — reproduced here since icons aren't shared
|
|
825
|
-
// across files yet in this codebase.
|
|
826
|
-
function CloseIcon() {
|
|
827
|
-
return (
|
|
828
|
-
<svg viewBox="0 -960 960 960" aria-hidden="true">
|
|
829
|
-
<path d="m249-207-42-42 231-231-231-231 42-42 231 231 231-231 42 42-231 231 231 231-42 42-231-231-231 231Z" fill="currentColor" />
|
|
830
|
-
</svg>
|
|
831
|
-
);
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
function ArrowForwardIcon() {
|
|
835
|
-
return (
|
|
836
|
-
<svg viewBox="0 -960 960 960" aria-hidden="true">
|
|
837
|
-
<path d="M686-450H160v-60h526L438-758l42-42 320 320-320 320-42-42 248-248Z" fill="currentColor" />
|
|
838
|
-
</svg>
|
|
839
|
-
);
|
|
840
|
-
}
|
|
841
|
-
|
|
842
1077
|
// Material Symbols "refresh" — the submit FAB's retry state.
|
|
843
1078
|
function RefreshIcon() {
|
|
844
1079
|
return (
|
|
@@ -902,6 +1137,30 @@ function AttachFileIcon() {
|
|
|
902
1137
|
);
|
|
903
1138
|
}
|
|
904
1139
|
|
|
1140
|
+
// Material Symbols "mic" — voice input.
|
|
1141
|
+
function MicIcon() {
|
|
1142
|
+
return (
|
|
1143
|
+
<svg viewBox="0 -960 960 960" aria-hidden="true">
|
|
1144
|
+
<path
|
|
1145
|
+
d="M408-453.92q-29-30.91-29-75.08v-251q0-41.67 29.44-70.83Q437.88-880 479.94-880t71.56 29.17Q581-821.67 581-780v251q0 44.17-29 75.08Q523-423 480-423t-72-30.92ZM480-651Zm-30 531v-136q-106-11-178-89t-72-184h60q0 91 64.29 153t155.5 62q91.21 0 155.71-62Q700-438 700-529h60q0 106-72 184t-178 89v136h-60Zm59.5-376.5Q521-510 521-529v-251q0-17-11.79-28.5T480-820q-17.42 0-29.21 11.5T439-780v251q0 19 11.5 32.5T480-483q18 0 29.5-13.5Z"
|
|
1146
|
+
fill="currentColor"
|
|
1147
|
+
/>
|
|
1148
|
+
</svg>
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// Material Symbols "globe" — the attach menu's Create world.
|
|
1153
|
+
function GlobeIcon() {
|
|
1154
|
+
return (
|
|
1155
|
+
<svg viewBox="0 -960 960 960" aria-hidden="true">
|
|
1156
|
+
<path
|
|
1157
|
+
d="M480.27-80q-82.74 0-155.5-31.5Q252-143 197.5-197.5t-86-127.34Q80-397.68 80-480.5t31.5-155.66Q143-709 197.5-763t127.34-85.5Q397.68-880 480.5-880t155.66 31.5Q709-817 763-763t85.5 127Q880-563 880-480.27q0 82.74-31.5 155.5Q817-252 763-197.68q-54 54.31-127 86Q563-80 480.27-80Zm-.27-60q142.38 0 241.19-99.5T820-480v-13q-6 26-27.41 43.5Q771.19-432 742-432h-80q-33 0-56.5-23.5T582-512v-40H422v-80q0-33 23.5-56.5T502-712h40v-22q0-16 13.5-40t30.5-29q-25-8-51.36-12.5Q508.29-820 480-820q-141 0-240.5 98.81T140-480h150q66 0 113 47t47 113v40H330v105q34 17 71.7 26t78.3 9Z"
|
|
1158
|
+
fill="currentColor"
|
|
1159
|
+
/>
|
|
1160
|
+
</svg>
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
905
1164
|
function ImageIcon() {
|
|
906
1165
|
return (
|
|
907
1166
|
<svg viewBox="0 -960 960 960" aria-hidden="true">
|