@chai-ui/core 0.10.1 → 0.11.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/src/composer.ts CHANGED
@@ -29,7 +29,7 @@ import {
29
29
  type Run,
30
30
  } from "./run.js";
31
31
  import type { ComposerPromptField } from "./composer-view.js";
32
- import type { DroppedMedia, GenerationUseCase, MediaKind, ModelOption, OutputKind } from "./types.js";
32
+ import type { AudioUseCase, DroppedMedia, GenerationUseCase, MediaKind, ModelOption, OutputKind } from "./types.js";
33
33
 
34
34
  export interface ComposerAttachment extends DroppedMedia {
35
35
  id: string;
@@ -52,6 +52,8 @@ export interface ComposerUseCase {
52
52
  * either a prompt or a region. Images only for now.
53
53
  */
54
54
  edit?: boolean;
55
+ /** For an `"audio"` use case, which audio it creates: spoken words or music. */
56
+ audio?: AudioUseCase;
55
57
  }
56
58
 
57
59
  /** A plain text request — what a submit means when no use case is picked, unless the builder sets `defaultUseCase`. */
@@ -60,6 +62,12 @@ export const TEXT_USE_CASE: ComposerUseCase = { kind: "text", label: "Text" };
60
62
  /** Editing the attached image, the attach menu's "Edit image". Pair it with `useComposer`'s `editImage` and an `EditCard`. */
61
63
  export const EDIT_IMAGE_USE_CASE: ComposerUseCase = { kind: "image", label: "Edit image", edit: true };
62
64
 
65
+ /** Spoken audio from text, the attach menu's "Create speech". */
66
+ export const CREATE_SPEECH_USE_CASE: ComposerUseCase = { kind: "audio", label: "Speech", audio: "speech" };
67
+
68
+ /** Music from a description, the attach menu's "Create music". */
69
+ export const CREATE_MUSIC_USE_CASE: ComposerUseCase = { kind: "audio", label: "Music", audio: "music" };
70
+
63
71
  /** One use case to run and the models picked for it. Empty `modelIds` means the engine's default model. */
64
72
  export interface ComposerSelection {
65
73
  useCase: ComposerUseCase;
@@ -92,6 +100,15 @@ export interface ComposerSubmitPayload {
92
100
  character?: string;
93
101
  /** The picked frame rate for a world, or null for the model's default. Undefined for other use cases. */
94
102
  frameRate?: number | null;
103
+ /** The picked voice for Create speech, or null for the model's default. Undefined for other use cases. */
104
+ speechVoice?: string | null;
105
+ /** The picked genres for Create music, written into the prompt at submit. Undefined for other use cases. */
106
+ genres?: string[];
107
+ }
108
+
109
+ /** A music prompt with the picked genres written in: music models take genre as part of the description. */
110
+ export function promptWithGenres(prompt: string, genres: string[]): string {
111
+ return genres.length > 0 ? `${prompt.trim()}\n\nGenre: ${genres.join(", ")}` : prompt;
95
112
  }
96
113
 
97
114
  /**
@@ -212,7 +229,13 @@ function defaultEnhancePrompt(value: string, mode?: GenerationUseCase, field: Co
212
229
  if (field === "character") {
213
230
  return `Rewrite the following description of the character a person plays in an interactive world to be more detailed and specific, keeping the same intent: who or what they are, how they look, and how they move. Don't describe the world around them. ${reply}\n\n${value}`;
214
231
  }
215
- const subject = mode?.includes("video")
232
+ // The prompt is the script itself: keep the words, only make them read aloud well.
233
+ if (mode === "text-to-speech") {
234
+ return `Edit the following text so it reads naturally when spoken aloud: fix punctuation, and spell out numbers, symbols and abbreviations. Keep the same words and meaning. Reply with only the edited text, nothing else.\n\n${value}`;
235
+ }
236
+ const subject = mode === "text-to-music"
237
+ ? "a music-generation"
238
+ : mode?.includes("video")
216
239
  ? "a video-generation"
217
240
  : mode === "text-to-text" || mode === "vision"
218
241
  ? "an AI assistant"
@@ -314,9 +337,10 @@ export function createComposerStore(getOptions: () => ComposerOptions, getProvid
314
337
  if (payload.character?.trim()) params.character = payload.character;
315
338
  if (payload.frameRate != null) params.frameRate = payload.frameRate;
316
339
  }
340
+ if (selection.useCase.audio === "speech" && payload.speechVoice) params.voice = payload.speechVoice;
317
341
  const request: Request = {
318
342
  mode: deriveGenerationUseCase(selection.useCase, attachments),
319
- prompt: payload.value,
343
+ prompt: selection.useCase.audio === "music" ? promptWithGenres(payload.value, payload.genres ?? []) : payload.value,
320
344
  attachments,
321
345
  modelIds: selection.modelIds.length > 0 ? selection.modelIds : [fallback ?? DEFAULT_MODEL_ID],
322
346
  params,
@@ -382,6 +406,7 @@ export function createComposerStore(getOptions: () => ComposerOptions, getProvid
382
406
  aspectRatio: newRun.request.params.aspectRatio as string | undefined,
383
407
  character: newRun.request.params.character as string | undefined,
384
408
  frameRate: newRun.request.params.frameRate as number | undefined,
409
+ voice: newRun.request.params.voice as string | undefined,
385
410
  // Text engines that stream fill `output` in while still "running".
386
411
  onText: (text) => update(newRun.id, (r) => streamResult(r, result.id, text)),
387
412
  signal,
package/src/engine.ts CHANGED
@@ -27,6 +27,8 @@ export interface GenerationEngine {
27
27
  character?: string;
28
28
  /** A world's frame rate, e.g. `16`. Only set for a model that lists it in `ModelOption.frameRates`. */
29
29
  frameRate?: number;
30
+ /** A text-to-speech voice, the model's own id for it. Only set for a model that lists it in `ModelOption.voices`. */
31
+ voice?: string;
30
32
  /**
31
33
  * Optional streaming hook for text output. An engine that can stream
32
34
  * calls this with the full text received so far (not a delta) each time
@@ -22,7 +22,7 @@
22
22
  */
23
23
 
24
24
  import { abortError, abortableSleep, isAbortError, readEvaluations, type GenerationEngine } from "../engine.js";
25
- import type { DroppedMedia, MediaKind } from "../types.js";
25
+ import type { DroppedMedia, FalInputFormat, MediaKind, ModelOption } from "../types.js";
26
26
  import { SUGGESTED_FAL_MODELS } from "../suggested-models.js";
27
27
  import type { Transcribe } from "../voice-input.js";
28
28
 
@@ -38,6 +38,12 @@ export interface FalEngineOptions {
38
38
  modelByKind?: Partial<Record<FalOutputKind, string>>;
39
39
  /** Which kind of media this engine instance produces. Defaults to "image". */
40
40
  outputKind?: FalOutputKind;
41
+ /**
42
+ * Your own models, so the engine sends each one's inputs in the fields its
43
+ * Fal schema names (`ModelOption.falInput`). Chai's suggested models are
44
+ * already known.
45
+ */
46
+ models?: ModelOption[];
41
47
  /** Poll interval while a queued job is in progress. Defaults to 1500ms. */
42
48
  pollIntervalMs?: number;
43
49
  /** Give up waiting after this long. Defaults to 120000ms (2 min) — video/audio jobs run longer than image ones. */
@@ -48,9 +54,9 @@ export interface FalEngineOptions {
48
54
 
49
55
  /**
50
56
  * Fal model ids verified live against fal.ai's own docs/catalog
51
- * 2026-09-24, not invented. No default for "audio": CHAI has no audio
52
- * `GenerationUseCase` yet, so guessing one
53
- * here would be ahead of any real use case that needs it.
57
+ * 2026-09-24, not invented. No default for "audio": Create speech and
58
+ * Create music are opt-in, so the builder names the model when they turn
59
+ * one on.
54
60
  */
55
61
  const DEFAULT_MODEL_BY_KIND: Partial<Record<FalOutputKind, string>> = {
56
62
  image: "fal-ai/flux/schnell",
@@ -132,9 +138,13 @@ export function buildFalRequestBody(args: {
132
138
  attachments?: DroppedMedia[];
133
139
  model?: string;
134
140
  aspectRatio?: string;
141
+ voice?: string;
142
+ /** The model's input format when the caller knows it (see `FalEngineOptions.models`); else a suggested model's, else guessed. */
143
+ input?: FalInputFormat;
135
144
  }): Record<string, unknown> {
136
- const body: Record<string, unknown> = { prompt: args.prompt };
137
- const known = args.model ? SUGGESTED_FAL_MODELS.get(args.model)?.falInput : undefined;
145
+ const known = args.input ?? (args.model ? SUGGESTED_FAL_MODELS.get(args.model)?.falInput : undefined);
146
+ const body: Record<string, unknown> = { [known?.prompt ?? "prompt"]: args.prompt };
147
+ if (args.voice) body.voice = args.voice;
138
148
  if (args.aspectRatio) {
139
149
  const sizes = args.model?.startsWith("fal-ai/gpt-image")
140
150
  ? GPT_IMAGE_1_SIZES
@@ -241,7 +251,7 @@ export function createFalEngine(options: FalEngineOptions = {}): GenerationEngin
241
251
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
242
252
 
243
253
  return {
244
- async generate({ modelId, prompt, attachments, aspectRatio, signal }) {
254
+ async generate({ modelId, prompt, attachments, aspectRatio, voice, signal }) {
245
255
  const model =
246
256
  modelId ?? options.modelByKind?.[outputKind] ?? options.model ?? DEFAULT_MODEL_BY_KIND[outputKind];
247
257
  if (!model) {
@@ -250,7 +260,8 @@ export function createFalEngine(options: FalEngineOptions = {}): GenerationEngin
250
260
  );
251
261
  }
252
262
 
253
- const body = buildFalRequestBody({ prompt, attachments, model, aspectRatio });
263
+ const input = options.models?.find((m) => m.id === model)?.falInput;
264
+ const body = buildFalRequestBody({ prompt, attachments, model, aspectRatio, voice, input });
254
265
  if (signal?.aborted) throw abortError();
255
266
 
256
267
  try {
package/src/index.ts CHANGED
@@ -17,3 +17,4 @@ export * from "./result-format.js";
17
17
  export * from "./composer-view.js";
18
18
  export * from "./edit-card.js";
19
19
  export * from "./voice-input.js";
20
+ export * from "./menu-placement.js";
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Which way a component's menus open from their trigger. `"above"` suits a
3
+ * bar docked to the bottom of the screen and `"below"` one docked to the
4
+ * top; `"auto"` measures at open time, for a bar that sits in the page.
5
+ */
6
+ export type MenuPlacement = "auto" | "above" | "below";
7
+
8
+ /** The gap between a trigger and its open menu, and the least room kept to the screen's edge. */
9
+ const GAP = 8;
10
+ const EDGE = 16;
11
+
12
+ /**
13
+ * Whether a menu opens above its trigger. `"auto"` opens below when the menu
14
+ * fits there, above when only that side fits, and otherwise toward the side
15
+ * with more room, so it's never cut off when it could have fit.
16
+ */
17
+ export function menuOpensAbove(
18
+ placement: MenuPlacement,
19
+ trigger: { top: number; bottom: number },
20
+ menuHeight: number,
21
+ viewportHeight: number
22
+ ): boolean {
23
+ if (placement !== "auto") return placement === "above";
24
+ const roomBelow = viewportHeight - EDGE - (trigger.bottom + GAP);
25
+ const roomAbove = trigger.top - GAP - EDGE;
26
+ if (menuHeight <= roomBelow) return false;
27
+ if (menuHeight <= roomAbove) return true;
28
+ return roomAbove > roomBelow;
29
+ }
package/src/run.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * (`@chai-ui/react`) is the thin React binding on top.
5
5
  */
6
6
 
7
- import type { DroppedMedia, Evaluation, GenerationUseCase, MediaKind, OutputKind, Usage } from "./types.js";
7
+ import type { AudioUseCase, DroppedMedia, Evaluation, GenerationUseCase, MediaKind, OutputKind, Usage } from "./types.js";
8
8
 
9
9
  export interface Request {
10
10
  /** The specific generation use case, when one could be derived — see `deriveGenerationUseCase`. Undefined when the use case (or its kind) has no `GenerationUseCase` match yet, e.g. audio. */
@@ -134,16 +134,20 @@ export function cancelResult(run: Run, resultId: string): Run {
134
134
  * Derives a `GenerationUseCase` from Composer's own `{ kind, label }` use
135
135
  * case plus whether anything is attached — this is real derivation logic,
136
136
  * not just plumbing: the same `kind: "video"` means `"text-to-video"` with
137
- * nothing attached but `"image-to-video"` once an image is. Returns
138
- * `undefined` for "audio" and "world" (no `GenerationUseCase` member exists
139
- * yet; audio generation is planned, world models await provider specs). `"text"` is a text request: a reply to
137
+ * nothing attached but `"image-to-video"` once an image is. An `"audio"`
138
+ * use case is `"text-to-speech"` or `"text-to-music"` by its `audio` field,
139
+ * and `undefined` without one. Returns `undefined` for "world" (world
140
+ * models await provider specs). `"text"` is a text request: a reply to
140
141
  * the prompt, or a description of the attached media ("vision").
141
142
  */
142
143
  export function deriveGenerationUseCase(
143
- useCase: { kind: OutputKind } | null | undefined,
144
+ useCase: { kind: OutputKind; audio?: AudioUseCase } | null | undefined,
144
145
  attachments: DroppedMedia[]
145
146
  ): GenerationUseCase | undefined {
146
147
  if (!useCase) return undefined;
148
+ if (useCase.kind === "audio") {
149
+ return useCase.audio === "speech" ? "text-to-speech" : useCase.audio === "music" ? "text-to-music" : undefined;
150
+ }
147
151
  const hasMedia = attachments.length > 0;
148
152
  if (useCase.kind === "video") return hasMedia ? "image-to-video" : "text-to-video";
149
153
  if (useCase.kind === "image") return hasMedia ? "image-edit" : "text-to-image";
package/src/types.ts CHANGED
@@ -12,6 +12,13 @@ export type MediaKind = "image" | "video" | "audio" | "text";
12
12
  */
13
13
  export type OutputKind = MediaKind | "world";
14
14
 
15
+ /**
16
+ * Which audio an `"audio"` use case creates: spoken words (Create speech) or
17
+ * music (Create music). Each needs its own models and result surface, so a
18
+ * new kind of audio (e.g. sound effects) gets its own value, not a reuse.
19
+ */
20
+ export type AudioUseCase = "speech" | "music";
21
+
15
22
  /**
16
23
  * A piece of media a person attached (or an engine received) — just enough
17
24
  * to know its kind and where it lives.
@@ -119,12 +126,33 @@ export interface ModelOption {
119
126
  * a model with one fixed rate it doesn't take as an input: no menu shows.
120
127
  */
121
128
  frameRates?: number[];
129
+ /**
130
+ * The voices a text-to-speech model speaks in, from its schema. Composer's
131
+ * Voice menu offers them once this is the one model picked, and the
132
+ * engine sends the pick as the model's `voice` input. Leave it out for a
133
+ * model with no voice to choose.
134
+ */
135
+ voices?: VoiceOption[];
136
+ /**
137
+ * The genres offered with a music model. Composer's Genre menu offers the
138
+ * ones every picked model lists, several at once. Music models take genre
139
+ * as part of the description, so the picks are written into the prompt
140
+ * (see `promptWithGenres`).
141
+ */
142
+ genres?: ParameterOption<string>[];
143
+ }
144
+
145
+ /** One voice in `ModelOption.voices`: the model's own id for it, its name, and an optional menu section (e.g. "Female"). */
146
+ export interface VoiceOption extends ParameterOption<string> {
147
+ group?: string;
122
148
  }
123
149
 
124
150
  /** See `ModelOption.falInput`. */
125
151
  export interface FalInputFormat {
126
152
  image?: "image_url" | "image_urls" | "start_image_url";
127
153
  aspectRatio?: "aspect_ratio" | "image_size";
154
+ /** Which field the prompt goes in. Most models take `prompt`; some text-to-speech models take the words as `text`. */
155
+ prompt?: "prompt" | "text";
128
156
  }
129
157
 
130
158
  /** How an edit's regions reach the model — see `ModelOption.regionFormat`. */
@@ -135,8 +163,8 @@ export type RegionFormat = "flux-3-boxes" | "text";
135
163
  * leaderboard categories at github.com/oolong-tea-2026/arena-ai-leaderboards,
136
164
  * so a future model-ranking lookup can join on it directly, plus
137
165
  * "text-to-text" for a plain text request (a Composer with no use case
138
- * picked). That repo has no audio-generation category at all, a real gap
139
- * for any future audio use case.
166
+ * picked). That repo has no audio-generation category, so "text-to-speech"
167
+ * and "text-to-music" have nothing to join on yet.
140
168
  */
141
169
  export type GenerationUseCase =
142
170
  | "text-to-image"
@@ -145,4 +173,6 @@ export type GenerationUseCase =
145
173
  | "image-to-video"
146
174
  | "video-edit"
147
175
  | "vision"
148
- | "text-to-text";
176
+ | "text-to-text"
177
+ | "text-to-speech"
178
+ | "text-to-music";