@chai-ui/react 0.6.0 → 0.7.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 +19 -3
- package/dist/ChaiProvider.d.ts +3 -42
- package/dist/ChaiProvider.d.ts.map +1 -1
- package/dist/ChaiProvider.js +5 -43
- package/dist/ChaiProvider.js.map +1 -1
- package/dist/Composer.d.ts +4 -59
- package/dist/Composer.d.ts.map +1 -1
- package/dist/Composer.js +50 -169
- package/dist/Composer.js.map +1 -1
- package/dist/EditCard.d.ts +2 -16
- package/dist/EditCard.d.ts.map +1 -1
- package/dist/EditCard.js +16 -115
- package/dist/EditCard.js.map +1 -1
- package/dist/MediaAnalyzer.d.ts +3 -49
- package/dist/MediaAnalyzer.d.ts.map +1 -1
- package/dist/MediaAnalyzer.js +13 -141
- package/dist/MediaAnalyzer.js.map +1 -1
- package/dist/ResultCard.d.ts +13 -0
- package/dist/ResultCard.d.ts.map +1 -1
- package/dist/ResultCard.js +2 -48
- package/dist/ResultCard.js.map +1 -1
- package/dist/useComposer.d.ts +11 -86
- package/dist/useComposer.d.ts.map +1 -1
- package/dist/useComposer.js +26 -291
- package/dist/useComposer.js.map +1 -1
- package/dist/useMediaAnalyzer.d.ts +8 -28
- package/dist/useMediaAnalyzer.d.ts.map +1 -1
- package/dist/useMediaAnalyzer.js +9 -106
- package/dist/useMediaAnalyzer.js.map +1 -1
- package/package.json +3 -3
- package/src/ChaiProvider.tsx +12 -70
- package/src/Composer.tsx +73 -258
- package/src/EditCard.tsx +46 -139
- package/src/MediaAnalyzer.tsx +31 -176
- package/src/ResultCard.tsx +13 -38
- package/src/useComposer.ts +33 -411
- package/src/useMediaAnalyzer.ts +15 -146
- package/dist/media-actions.d.ts +0 -18
- package/dist/media-actions.d.ts.map +0 -1
- package/dist/media-actions.js +0 -56
- package/dist/media-actions.js.map +0 -1
- package/dist/regions.d.ts +0 -3
- package/dist/regions.d.ts.map +0 -1
- package/dist/regions.js +0 -7
- package/dist/regions.js.map +0 -1
- package/src/media-actions.ts +0 -54
- package/src/regions.ts +0 -7
package/src/ChaiProvider.tsx
CHANGED
|
@@ -1,88 +1,30 @@
|
|
|
1
1
|
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { createReasoning, reasoningEngineFor, type Reasoning, type ReasoningConfig } from "@chai-ui/core";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Suggested per-kind overrides for media Claude can't read. Gemini takes
|
|
13
|
-
* video and audio directly. For a cheaper option, swap in
|
|
14
|
-
* `"qwen/qwen3.8-omni-flash"`, which takes both too.
|
|
15
|
-
*/
|
|
16
|
-
export const DEFAULT_REASONING_MODEL_BY_KIND: Partial<Record<MediaKind, string>> = {
|
|
17
|
-
video: "google/gemini-3.8-flash",
|
|
18
|
-
audio: "google/gemini-3.8-flash",
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
/** The reasoning engine and models every CHAI component shares. */
|
|
22
|
-
export interface Reasoning {
|
|
23
|
-
engine: GenerationEngine;
|
|
24
|
-
/** The model for text and any attachment kind without its own entry in `modelByKind`. */
|
|
25
|
-
model: string;
|
|
26
|
-
modelByKind: Partial<Record<MediaKind, string>>;
|
|
27
|
-
}
|
|
4
|
+
export {
|
|
5
|
+
DEFAULT_REASONING_MODEL,
|
|
6
|
+
DEFAULT_REASONING_MODEL_BY_KIND,
|
|
7
|
+
reasoningModelFor,
|
|
8
|
+
type Reasoning,
|
|
9
|
+
} from "@chai-ui/core";
|
|
28
10
|
|
|
29
11
|
const ReasoningContext = createContext<Reasoning | undefined>(undefined);
|
|
30
12
|
|
|
31
|
-
export interface ChaiProviderProps {
|
|
32
|
-
/** Text, image and routing model. Defaults to `DEFAULT_REASONING_MODEL`. */
|
|
33
|
-
reasoningModel?: string;
|
|
34
|
-
/**
|
|
35
|
-
* Per-attachment-kind models, merged over `DEFAULT_REASONING_MODEL_BY_KIND`
|
|
36
|
-
* one kind at a time, e.g. `{ video: "qwen/qwen3.8-omni-flash" }` keeps
|
|
37
|
-
* the default for audio. Map a kind to `reasoningModel`'s value to use
|
|
38
|
-
* one model for everything.
|
|
39
|
-
*/
|
|
40
|
-
reasoningModelByKind?: Partial<Record<MediaKind, string>>;
|
|
41
|
-
/**
|
|
42
|
-
* The engine the reasoning models run on. Defaults to OpenRouter,
|
|
43
|
-
* independent of the generation engine: Fal's own LLM access is an
|
|
44
|
-
* OpenRouter proxy, so defaulting to it would double-proxy.
|
|
45
|
-
*/
|
|
46
|
-
reasoningEngine?: GenerationEngine;
|
|
13
|
+
export interface ChaiProviderProps extends ReasoningConfig {
|
|
47
14
|
children?: ReactNode;
|
|
48
15
|
}
|
|
49
16
|
|
|
50
17
|
export function ChaiProvider({ reasoningModel, reasoningModelByKind, reasoningEngine, children }: ChaiProviderProps) {
|
|
51
|
-
const engine = useMemo(() => reasoningEngine
|
|
52
|
-
const
|
|
53
|
-
const video = reasoningModelByKind?.video;
|
|
54
|
-
const audio = reasoningModelByKind?.audio;
|
|
55
|
-
const image = reasoningModelByKind?.image;
|
|
56
|
-
const text = reasoningModelByKind?.text;
|
|
18
|
+
const engine = useMemo(() => reasoningEngineFor({ reasoningEngine }), [reasoningEngine]);
|
|
19
|
+
const { video, audio, image, text } = reasoningModelByKind ?? {};
|
|
57
20
|
const value = useMemo(
|
|
58
|
-
() => ({
|
|
59
|
-
|
|
60
|
-
model,
|
|
61
|
-
modelByKind: { ...DEFAULT_REASONING_MODEL_BY_KIND, ...dropUndefined({ video, audio, image, text }) },
|
|
62
|
-
}),
|
|
63
|
-
[engine, model, video, audio, image, text]
|
|
21
|
+
() => createReasoning({ reasoningModel, reasoningModelByKind: { video, audio, image, text } }, engine),
|
|
22
|
+
[engine, reasoningModel, video, audio, image, text]
|
|
64
23
|
);
|
|
65
24
|
return <ReasoningContext.Provider value={value}>{children}</ReasoningContext.Provider>;
|
|
66
25
|
}
|
|
67
26
|
|
|
68
|
-
function dropUndefined<T extends object>(obj: T): Partial<T> {
|
|
69
|
-
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as Partial<T>;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
27
|
/** The nearest `ChaiProvider`'s reasoning engine and models, or `undefined` outside one. */
|
|
73
28
|
export function useReasoning(): Reasoning | undefined {
|
|
74
29
|
return useContext(ReasoningContext);
|
|
75
30
|
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* The reasoning model for a call with these attachments: the first
|
|
79
|
-
* attachment whose kind has its own model wins (a clip plus a still goes
|
|
80
|
-
* to the model that can read the clip), else `reasoning.model`.
|
|
81
|
-
*/
|
|
82
|
-
export function reasoningModelFor(reasoning: Reasoning, attachments: DroppedMedia[] = []): string {
|
|
83
|
-
for (const { kind } of attachments) {
|
|
84
|
-
const model = reasoning.modelByKind[kind];
|
|
85
|
-
if (model) return model;
|
|
86
|
-
}
|
|
87
|
-
return reasoning.model;
|
|
88
|
-
}
|
package/src/Composer.tsx
CHANGED
|
@@ -3,10 +3,29 @@ import { useEffect, useRef, useState, 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";
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
composerAttachmentsFromFiles,
|
|
8
|
+
composerAttachMenuItems,
|
|
9
|
+
composerView,
|
|
10
|
+
enhanceRevealDurationMs,
|
|
11
|
+
MAX_ANIMATE_ENHANCE_LENGTH,
|
|
12
|
+
regionColor,
|
|
13
|
+
TEXT_USE_CASE,
|
|
14
|
+
type ComposerAttachMenuAction,
|
|
15
|
+
} from "@chai-ui/core";
|
|
16
|
+
import type { EditRegion, ModelOption, ParameterOption, MediaKind } from "@chai-ui/core";
|
|
8
17
|
import { SearchMenu } from "./primitives/SearchMenu.js";
|
|
9
|
-
import {
|
|
18
|
+
import type { ComposerAttachment, ComposerSubmitPayload, ComposerUseCase } from "@chai-ui/core";
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
EDIT_IMAGE_USE_CASE,
|
|
22
|
+
TEXT_USE_CASE,
|
|
23
|
+
type ComposerAttachMenuAction,
|
|
24
|
+
type ComposerAttachment,
|
|
25
|
+
type ComposerSelection,
|
|
26
|
+
type ComposerSubmitPayload,
|
|
27
|
+
type ComposerUseCase,
|
|
28
|
+
} from "@chai-ui/core";
|
|
10
29
|
|
|
11
30
|
/** See primitives/Toggle.tsx for why `createComponent` is used instead of raw JSX on custom-element tags. */
|
|
12
31
|
const MdFabElement = createComponent({
|
|
@@ -16,101 +35,6 @@ const MdFabElement = createComponent({
|
|
|
16
35
|
events: { onClick: "click" },
|
|
17
36
|
});
|
|
18
37
|
|
|
19
|
-
const ACCEPTED_KINDS: MediaKind[] = ["image", "video", "audio"];
|
|
20
|
-
|
|
21
|
-
function detectKind(file: File): MediaKind | undefined {
|
|
22
|
-
const prefix = file.type.split("/")[0];
|
|
23
|
-
return ACCEPTED_KINDS.find((k) => k === prefix);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// Past this length the rewritten prompt just appears instantly — a
|
|
27
|
-
// multi-hundred-character reveal reads as sluggish, not delightful, and a
|
|
28
|
-
// long prompt is exactly the case where a person wants the result now, not
|
|
29
|
-
// a show. Picked to comfortably cover a typical Enhance rewrite (usually a
|
|
30
|
-
// sentence or two of added detail) while bailing out before it doesn't.
|
|
31
|
-
const MAX_ANIMATE_ENHANCE_LENGTH = 260;
|
|
32
|
-
|
|
33
|
-
// Time-driven (see the `requestAnimationFrame` loop that uses this), not
|
|
34
|
-
// count-driven — a fixed ms-per-character reveal on a slow frame would
|
|
35
|
-
// drift or stall instead of just finishing a beat later. Capped on both
|
|
36
|
-
// ends: a two-word rewrite shouldn't take as long as a five-word one to
|
|
37
|
-
// feel intentional, and even a near-max-length one should still resolve
|
|
38
|
-
// under a second.
|
|
39
|
-
function enhanceRevealDurationMs(charCount: number): number {
|
|
40
|
-
return Math.min(900, Math.max(300, charCount * 6));
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function nextAttachmentId(): string {
|
|
44
|
-
return typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
45
|
-
? crypto.randomUUID()
|
|
46
|
-
: `attachment-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface ComposerAttachment extends DroppedMedia {
|
|
50
|
-
id: string;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export type ComposerAttachMenuAction = "add-media" | "create-image" | "create-video" | "edit-media";
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* The selected generation use case, shown as a removable chip (reference:
|
|
57
|
-
* the mock's "Video"/"Image" chip) — deliberately independent of
|
|
58
|
-
* `attachments`: a use case can be picked from the attach menu with nothing
|
|
59
|
-
* attached yet (e.g. "Create video"). Composer never derives this from
|
|
60
|
-
* `attachments` itself (that's app-level policy, not a Composer opinion —
|
|
61
|
-
* the design). With none picked, `defaultUseCase` applies.
|
|
62
|
-
*/
|
|
63
|
-
export interface ComposerUseCase {
|
|
64
|
-
kind: MediaKind;
|
|
65
|
-
label: string;
|
|
66
|
-
/**
|
|
67
|
-
* An edit of the attached image rather than a new one: no aspect ratio,
|
|
68
|
-
* region chips when `regions` is set, and a submit needs only an image and
|
|
69
|
-
* either a prompt or a region. Images only for now.
|
|
70
|
-
*/
|
|
71
|
-
edit?: boolean;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** A plain text request — what a submit means when no use case is picked, unless the builder sets `defaultUseCase`. */
|
|
75
|
-
export const TEXT_USE_CASE: ComposerUseCase = { kind: "text", label: "Text" };
|
|
76
|
-
|
|
77
|
-
/** Editing the attached image, the attach menu's "Edit image". Pair it with `useComposer`'s `editImage` and an `EditCard`. */
|
|
78
|
-
export const EDIT_IMAGE_USE_CASE: ComposerUseCase = { kind: "image", label: "Edit image", edit: true };
|
|
79
|
-
|
|
80
|
-
/** One use case to run and the models picked for it. Empty `modelIds` means the engine's default model. */
|
|
81
|
-
export interface ComposerSelection {
|
|
82
|
-
useCase: ComposerUseCase;
|
|
83
|
-
modelIds: string[];
|
|
84
|
-
/** The models offered for this use case: what auto-select routes among. */
|
|
85
|
-
models: ModelOption[];
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Everything Composer itself tracks, assembled into one object at submit
|
|
90
|
-
* time — not the the design `Request` shape (no `mode`/`params`/
|
|
91
|
-
* `presetId`; those belong to the run store this package doesn't have yet,
|
|
92
|
-
* see the package README), just an honest snapshot of this component's own
|
|
93
|
-
* state. Handed to `onSubmit` so a builder doesn't have to re-derive it
|
|
94
|
-
* from five separately-controlled props they already hold — mainly a
|
|
95
|
-
* correctness/ergonomics win for a coding agent wiring this up from the
|
|
96
|
-
* type signature alone.
|
|
97
|
-
*/
|
|
98
|
-
export interface ComposerSubmitPayload {
|
|
99
|
-
value: string;
|
|
100
|
-
attachments: ComposerAttachment[];
|
|
101
|
-
/** The use case to run — the picked one, or `defaultUseCase` when none is picked. The first one with `multiSelectUseCases`. */
|
|
102
|
-
useCase: ComposerUseCase;
|
|
103
|
-
/** The first picked model, or null for the engine's default. */
|
|
104
|
-
modelId: string | null;
|
|
105
|
-
aspectRatio: string | null;
|
|
106
|
-
/** Everything to run: one entry per use case with its picked models. A single entry unless `multiSelectUseCases` is on. */
|
|
107
|
-
selections: ComposerSelection[];
|
|
108
|
-
/** When true, the model is chosen at submit (`useComposer` routes among each selection's `models`), not by the picks. */
|
|
109
|
-
autoSelectModel: boolean;
|
|
110
|
-
/** An edit's marked regions, each with its own instruction. Empty unless the use case is an edit. */
|
|
111
|
-
regions?: EditRegion[];
|
|
112
|
-
}
|
|
113
|
-
|
|
114
38
|
export interface ComposerProps {
|
|
115
39
|
/** The prompt text. Controlled — Composer owns no text state of its own. */
|
|
116
40
|
value: string;
|
|
@@ -292,92 +216,36 @@ export function Composer({
|
|
|
292
216
|
onRegionsChange,
|
|
293
217
|
disabled = false,
|
|
294
218
|
}: ComposerProps) {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const
|
|
321
|
-
// The app's list for a use case, or Chai's suggested models when it gave none.
|
|
322
|
-
const rosterFor = (u: ComposerUseCase): { options: ModelOption[]; suggested: boolean } => {
|
|
323
|
-
const given = multiSelectUseCases ? modelsByKind?.[u.kind] : models;
|
|
324
|
-
if (given) return { options: given, suggested: false };
|
|
325
|
-
return { options: suggestedModels(u.edit ? "image-edit" : deriveGenerationUseCase(u, attachments)), suggested: true };
|
|
326
|
-
};
|
|
327
|
-
// One menu section per use case with multi-use-case select; otherwise one flat roster.
|
|
328
|
-
const modelSections = (multiSelectUseCases ? activeUseCases : activeUseCases.slice(0, 1))
|
|
329
|
-
.map((u) => ({ label: multiSelectUseCases ? u.label : "", kind: u.kind, ...rosterFor(u) }))
|
|
330
|
-
.filter((s) => s.options.length > 0);
|
|
331
|
-
const allModels = modelSections.flatMap((s) => s.options);
|
|
332
|
-
// A pick that isn't in the current list (e.g. the edit model after leaving
|
|
333
|
-
// edit mode) is shown as unpicked and never submitted. With no list at
|
|
334
|
-
// all, the app's pick is passed through as given. With nothing picked
|
|
335
|
-
// from a suggested list (and auto-select off), its first model is the pick.
|
|
336
|
-
const validPicks =
|
|
337
|
-
allModels.length > 0 ? rawPickedIds.filter((id) => allModels.some((m) => m.id === id)) : rawPickedIds;
|
|
338
|
-
const pickedModelIds =
|
|
339
|
-
validPicks.length > 0 || autoSelectModel
|
|
340
|
-
? validPicks
|
|
341
|
-
: modelSections.filter((s) => s.suggested).map((s) => s.options[0]!.id);
|
|
342
|
-
const pickedModels = allModels.filter((m) => pickedModelIds.includes(m.id));
|
|
343
|
-
// No menu that can't change anything: without its handler, the Model menu
|
|
344
|
-
// hides (the default pick still runs).
|
|
345
|
-
const canPickModel = multiModel ? Boolean(onModelIdsChange) : Boolean(onModelChange);
|
|
346
|
-
const modelTriggerLabel =
|
|
347
|
-
pickedModels.length > 1
|
|
348
|
-
? `${pickedModels.length} models`
|
|
349
|
-
: pickedModels[0]?.label ?? (autoSelectModel ? "Auto-select" : "Select models");
|
|
350
|
-
// An edit keeps the source image's shape.
|
|
351
|
-
const showAspectRatio = activeUseCases.some((u) => !u.edit && (u.kind === "image" || u.kind === "video"));
|
|
352
|
-
// Only ratios every model that might run takes: the picked ones, or with
|
|
353
|
-
// none picked (or auto-select on) the whole list, so whichever runs can
|
|
354
|
-
// honor it. A model with no `aspectRatios` sets its own shape: no menu.
|
|
355
|
-
const ratioModels = pickedModels.length > 0 && !autoSelectModel ? pickedModels : allModels;
|
|
356
|
-
const sharedRatios =
|
|
357
|
-
ratioModels.reduce<string[] | null>(
|
|
358
|
-
(shared, m) => (shared ?? m.aspectRatios ?? []).filter((r) => m.aspectRatios?.includes(r)),
|
|
359
|
-
null
|
|
360
|
-
) ?? [];
|
|
361
|
-
const aspectOptions = aspectRatios
|
|
362
|
-
? aspectRatios.filter((o) => sharedRatios.includes(o.value))
|
|
363
|
-
: sharedRatios.map((r) => ({ value: r, label: r }));
|
|
364
|
-
// A pick the current models don't take is shown as unpicked and never sent.
|
|
365
|
-
const offeredAspect = showAspectRatio && aspectOptions.some((o) => o.value === aspectRatio) ? aspectRatio : null;
|
|
366
|
-
const selectedAspect = aspectOptions.find((o) => o.value === offeredAspect) ?? null;
|
|
219
|
+
const view = composerView({
|
|
220
|
+
value,
|
|
221
|
+
placeholder,
|
|
222
|
+
attachments,
|
|
223
|
+
useCase,
|
|
224
|
+
defaultUseCase,
|
|
225
|
+
multiSelectUseCases,
|
|
226
|
+
useCases,
|
|
227
|
+
modelsByKind,
|
|
228
|
+
multiSelectModels,
|
|
229
|
+
modelIds,
|
|
230
|
+
models,
|
|
231
|
+
modelId,
|
|
232
|
+
onModelChange,
|
|
233
|
+
onModelIdsChange,
|
|
234
|
+
autoSelectModel,
|
|
235
|
+
aspectRatios,
|
|
236
|
+
aspectRatio,
|
|
237
|
+
submitError,
|
|
238
|
+
submitDisabled,
|
|
239
|
+
submitting,
|
|
240
|
+
onAbort,
|
|
241
|
+
regions,
|
|
242
|
+
disabled,
|
|
243
|
+
});
|
|
244
|
+
const { editing, isSubmitDisabled, showStop, multiModel, modelSections, pickedModelIds, aspectOptions, offeredAspect } = view;
|
|
367
245
|
|
|
368
246
|
function toggleModel(id: string) {
|
|
369
247
|
if (!multiModel) return onModelChange?.(id);
|
|
370
|
-
onModelIdsChange?.(
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
function buildSelections(): ComposerSelection[] {
|
|
374
|
-
return activeUseCases.map((u) => ({
|
|
375
|
-
useCase: u,
|
|
376
|
-
models: rosterFor(u).options,
|
|
377
|
-
modelIds: multiSelectUseCases
|
|
378
|
-
? pickedModelIds.filter((id) => rosterFor(u).options.some((m) => m.id === id))
|
|
379
|
-
: pickedModelIds,
|
|
380
|
-
}));
|
|
248
|
+
onModelIdsChange?.(view.toggledModelIds(id));
|
|
381
249
|
}
|
|
382
250
|
|
|
383
251
|
// Auto-select chooses at submit, from the prompt (`useComposer` routes
|
|
@@ -466,54 +334,19 @@ export function Composer({
|
|
|
466
334
|
onEnhance?.(value);
|
|
467
335
|
}
|
|
468
336
|
|
|
469
|
-
function handleFilesSelected(fileList: FileList | null) {
|
|
337
|
+
async function handleFilesSelected(fileList: FileList | null) {
|
|
470
338
|
if (!fileList || fileList.length === 0 || !onAttachmentsChange) return;
|
|
471
339
|
// Edit mode works on one image: a new one replaces it, and the edit starts over.
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
Array.from(fileList).forEach((f) => onUnsupportedFile?.(f.name));
|
|
476
|
-
return;
|
|
477
|
-
}
|
|
478
|
-
const reader = new FileReader();
|
|
479
|
-
reader.onload = () =>
|
|
480
|
-
onAttachmentsChange([{ id: nextAttachmentId(), src: String(reader.result), kind: "image", name: image.name, size: image.size }]);
|
|
481
|
-
reader.onerror = () => onUnsupportedFile?.(image.name);
|
|
482
|
-
reader.readAsDataURL(image);
|
|
483
|
-
return;
|
|
484
|
-
}
|
|
485
|
-
const next = [...attachments];
|
|
486
|
-
let pending = fileList.length;
|
|
487
|
-
const settle = () => {
|
|
488
|
-
pending -= 1;
|
|
489
|
-
if (pending === 0) onAttachmentsChange(next);
|
|
490
|
-
};
|
|
491
|
-
Array.from(fileList).forEach((file) => {
|
|
492
|
-
const kind = detectKind(file);
|
|
493
|
-
if (!kind) {
|
|
494
|
-
onUnsupportedFile?.(file.name);
|
|
495
|
-
settle();
|
|
496
|
-
return;
|
|
497
|
-
}
|
|
498
|
-
const reader = new FileReader();
|
|
499
|
-
reader.onload = () => {
|
|
500
|
-
next.push({ id: nextAttachmentId(), src: String(reader.result), kind, name: file.name, size: file.size });
|
|
501
|
-
settle();
|
|
502
|
-
};
|
|
503
|
-
reader.onerror = () => {
|
|
504
|
-
onUnsupportedFile?.(file.name);
|
|
505
|
-
settle();
|
|
506
|
-
};
|
|
507
|
-
reader.readAsDataURL(file);
|
|
508
|
-
});
|
|
340
|
+
const { attachments: next, unsupported } = await composerAttachmentsFromFiles(fileList, attachments, editing);
|
|
341
|
+
unsupported.forEach((name) => onUnsupportedFile?.(name));
|
|
342
|
+
if (next) onAttachmentsChange(next);
|
|
509
343
|
}
|
|
510
344
|
|
|
511
345
|
// Only items that can work: no menu item that silently does nothing.
|
|
512
|
-
const menuItems =
|
|
513
|
-
(
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
);
|
|
346
|
+
const menuItems = composerAttachMenuItems(attachMenuActions, {
|
|
347
|
+
canAddMedia: Boolean(onAttachmentsChange),
|
|
348
|
+
canSelect: Boolean(onAttachMenuSelect),
|
|
349
|
+
});
|
|
517
350
|
|
|
518
351
|
function handleRemoveAttachment(id: string) {
|
|
519
352
|
onAttachmentsChange?.(attachments.filter((a) => a.id !== id));
|
|
@@ -553,8 +386,8 @@ export function Composer({
|
|
|
553
386
|
className={`chai-composer__input chai-composer__type${animating ? " chai-composer__input--revealing" : ""}`}
|
|
554
387
|
value={value}
|
|
555
388
|
onChange={(e) => onChange(e.target.value)}
|
|
556
|
-
placeholder={
|
|
557
|
-
aria-label={promptLabel ??
|
|
389
|
+
placeholder={view.placeholder}
|
|
390
|
+
aria-label={promptLabel ?? view.placeholder}
|
|
558
391
|
rows={1}
|
|
559
392
|
disabled={disabled || animating}
|
|
560
393
|
/>
|
|
@@ -610,7 +443,7 @@ export function Composer({
|
|
|
610
443
|
/>
|
|
611
444
|
)}
|
|
612
445
|
|
|
613
|
-
{pickedUseCases.map((u) => (
|
|
446
|
+
{view.pickedUseCases.map((u) => (
|
|
614
447
|
<UseCaseChip
|
|
615
448
|
key={u.kind}
|
|
616
449
|
useCase={u}
|
|
@@ -629,7 +462,7 @@ export function Composer({
|
|
|
629
462
|
trigger stays enabled whenever the switch is shown — it's the
|
|
630
463
|
only way back to the switch. Rows are disabled by `SearchMenu`
|
|
631
464
|
itself while auto-select is on. */}
|
|
632
|
-
{modelSections.length > 0 && canPickModel && (
|
|
465
|
+
{modelSections.length > 0 && view.canPickModel && (
|
|
633
466
|
<SearchMenu
|
|
634
467
|
{...(multiSelectUseCases
|
|
635
468
|
? { sections: modelSections.map((s) => ({ label: s.label, options: s.options })) }
|
|
@@ -645,7 +478,7 @@ export function Composer({
|
|
|
645
478
|
}
|
|
646
479
|
: undefined
|
|
647
480
|
}
|
|
648
|
-
triggerLabel={modelTriggerLabel}
|
|
481
|
+
triggerLabel={view.modelTriggerLabel}
|
|
649
482
|
menuLabel={multiModel ? "Select models" : "Select model"}
|
|
650
483
|
onSelect={(opt) => toggleModel(opt.id)}
|
|
651
484
|
searchable={false}
|
|
@@ -655,11 +488,11 @@ export function Composer({
|
|
|
655
488
|
/>
|
|
656
489
|
)}
|
|
657
490
|
|
|
658
|
-
{showAspectRatio && aspectOptions.length > 0 && onAspectRatioChange && (
|
|
491
|
+
{view.showAspectRatio && aspectOptions.length > 0 && onAspectRatioChange && (
|
|
659
492
|
<SearchMenu
|
|
660
493
|
options={aspectOptions.map((o) => ({ id: o.value, label: o.label }))}
|
|
661
494
|
value={offeredAspect}
|
|
662
|
-
triggerLabel={selectedAspect?.label ?? "Aspect ratio"}
|
|
495
|
+
triggerLabel={view.selectedAspect?.label ?? "Aspect ratio"}
|
|
663
496
|
menuLabel="Aspect ratio"
|
|
664
497
|
onSelect={(opt) => onAspectRatioChange?.(opt.id)}
|
|
665
498
|
searchable={false}
|
|
@@ -724,19 +557,7 @@ export function Composer({
|
|
|
724
557
|
} as Record<string, unknown>)}
|
|
725
558
|
onClick={() => {
|
|
726
559
|
if (showStop) onAbort?.();
|
|
727
|
-
else if (!isSubmitDisabled && !submitting)
|
|
728
|
-
const selections = buildSelections();
|
|
729
|
-
onSubmit({
|
|
730
|
-
value,
|
|
731
|
-
attachments,
|
|
732
|
-
useCase: activeUseCases[0]!,
|
|
733
|
-
modelId: pickedModelIds[0] ?? null,
|
|
734
|
-
aspectRatio: offeredAspect,
|
|
735
|
-
selections,
|
|
736
|
-
autoSelectModel,
|
|
737
|
-
regions: editing ? regions : [],
|
|
738
|
-
});
|
|
739
|
-
}
|
|
560
|
+
else if (!isSubmitDisabled && !submitting) onSubmit(view.payload());
|
|
740
561
|
}}
|
|
741
562
|
>
|
|
742
563
|
<span slot="icon">
|
|
@@ -748,18 +569,12 @@ export function Composer({
|
|
|
748
569
|
);
|
|
749
570
|
}
|
|
750
571
|
|
|
751
|
-
const
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
}[] = [
|
|
758
|
-
{ action: "add-media", label: "Add media", icon: <AttachFileIcon />, divider: true },
|
|
759
|
-
{ action: "create-image", label: "Create image", icon: <ImageIcon /> },
|
|
760
|
-
{ action: "create-video", label: "Create video", icon: <VideocamIcon /> },
|
|
761
|
-
{ action: "edit-media", label: "Edit image", icon: <ContentCutIcon /> },
|
|
762
|
-
];
|
|
572
|
+
const ATTACH_MENU_ICONS: Record<ComposerAttachMenuAction, React.ReactNode> = {
|
|
573
|
+
"add-media": <AttachFileIcon />,
|
|
574
|
+
"create-image": <ImageIcon />,
|
|
575
|
+
"create-video": <VideocamIcon />,
|
|
576
|
+
"edit-media": <ContentCutIcon />,
|
|
577
|
+
};
|
|
763
578
|
|
|
764
579
|
/**
|
|
765
580
|
* The "+" / "×" attach control (reference: Composer_01 "Menu open" state,
|
|
@@ -775,7 +590,7 @@ function ComposerAttachMenu({
|
|
|
775
590
|
imagesOnly,
|
|
776
591
|
disabled,
|
|
777
592
|
}: {
|
|
778
|
-
items: typeof
|
|
593
|
+
items: ReturnType<typeof composerAttachMenuItems>;
|
|
779
594
|
onSelect?: (action: ComposerAttachMenuAction) => void;
|
|
780
595
|
onAddMediaFiles: (files: FileList | null) => void;
|
|
781
596
|
/** Edit mode: one image file at a time. */
|
|
@@ -832,7 +647,7 @@ function ComposerAttachMenu({
|
|
|
832
647
|
className="chai-composer__attach-menu-item"
|
|
833
648
|
onClick={() => handleItemClick(item.action)}
|
|
834
649
|
>
|
|
835
|
-
<span className="chai-composer__attach-menu-icon">{item.
|
|
650
|
+
<span className="chai-composer__attach-menu-icon">{ATTACH_MENU_ICONS[item.action]}</span>
|
|
836
651
|
{item.label}
|
|
837
652
|
</button>
|
|
838
653
|
{item.divider && i < items.length - 1 && <hr className="chai-composer__attach-menu-divider" role="separator" />}
|