@chai-ui/react 0.5.0 → 0.6.1
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 +6 -1
- package/README.md +79 -17
- 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 +10 -60
- package/dist/Composer.d.ts.map +1 -1
- package/dist/Composer.js +26 -24
- 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 +7 -2
- package/dist/EditCard.js.map +1 -1
- package/dist/MediaAnalyzer.d.ts +14 -18
- package/dist/MediaAnalyzer.d.ts.map +1 -1
- package/dist/MediaAnalyzer.js +26 -8
- package/dist/MediaAnalyzer.js.map +1 -1
- package/dist/ResultCard.d.ts.map +1 -1
- package/dist/ResultCard.js +10 -6
- package/dist/ResultCard.js.map +1 -1
- package/dist/icons.d.ts +1 -0
- package/dist/icons.d.ts.map +1 -1
- package/dist/icons.js +9 -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/media-actions.d.ts +5 -2
- package/dist/media-actions.d.ts.map +1 -1
- package/dist/media-actions.js +12 -3
- package/dist/media-actions.js.map +1 -1
- package/dist/style.css +15 -0
- 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 +42 -90
- package/src/EditCard.tsx +18 -20
- package/src/MediaAnalyzer.tsx +51 -30
- package/src/ResultCard.tsx +12 -4
- package/src/icons.tsx +14 -0
- package/src/index.ts +10 -0
- package/src/media-actions.ts +13 -4
- package/src/style.css +15 -0
- package/src/useComposer.ts +33 -411
- package/src/useMediaAnalyzer.ts +15 -146
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
|
@@ -1,10 +1,22 @@
|
|
|
1
|
+
import { StopIcon } from "./icons.js";
|
|
1
2
|
import { useEffect, useRef, useState, type ChangeEvent } from "react";
|
|
2
3
|
import { createComponent } from "@lit/react";
|
|
3
4
|
import * as React from "react";
|
|
4
5
|
import { MdFab } from "@material/web/fab/fab.js";
|
|
6
|
+
import { deriveGenerationUseCase, suggestedModels, TEXT_USE_CASE } from "@chai-ui/core";
|
|
5
7
|
import type { DroppedMedia, EditRegion, ModelOption, ParameterOption, MediaKind } from "@chai-ui/core";
|
|
6
8
|
import { SearchMenu } from "./primitives/SearchMenu.js";
|
|
7
9
|
import { regionColor } from "./regions.js";
|
|
10
|
+
import type { ComposerAttachment, ComposerSelection, ComposerSubmitPayload, ComposerUseCase } from "@chai-ui/core";
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
EDIT_IMAGE_USE_CASE,
|
|
14
|
+
TEXT_USE_CASE,
|
|
15
|
+
type ComposerAttachment,
|
|
16
|
+
type ComposerSelection,
|
|
17
|
+
type ComposerSubmitPayload,
|
|
18
|
+
type ComposerUseCase,
|
|
19
|
+
} from "@chai-ui/core";
|
|
8
20
|
|
|
9
21
|
/** See primitives/Toggle.tsx for why `createComponent` is used instead of raw JSX on custom-element tags. */
|
|
10
22
|
const MdFabElement = createComponent({
|
|
@@ -44,71 +56,8 @@ function nextAttachmentId(): string {
|
|
|
44
56
|
: `attachment-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
45
57
|
}
|
|
46
58
|
|
|
47
|
-
export interface ComposerAttachment extends DroppedMedia {
|
|
48
|
-
id: string;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
59
|
export type ComposerAttachMenuAction = "add-media" | "create-image" | "create-video" | "edit-media";
|
|
52
60
|
|
|
53
|
-
/**
|
|
54
|
-
* The selected generation use case, shown as a removable chip (reference:
|
|
55
|
-
* the mock's "Video"/"Image" chip) — deliberately independent of
|
|
56
|
-
* `attachments`: a use case can be picked from the attach menu with nothing
|
|
57
|
-
* attached yet (e.g. "Create video"). Composer never derives this from
|
|
58
|
-
* `attachments` itself (that's app-level policy, not a Composer opinion —
|
|
59
|
-
* the design). With none picked, `defaultUseCase` applies.
|
|
60
|
-
*/
|
|
61
|
-
export interface ComposerUseCase {
|
|
62
|
-
kind: MediaKind;
|
|
63
|
-
label: string;
|
|
64
|
-
/**
|
|
65
|
-
* An edit of the attached image rather than a new one: no aspect ratio,
|
|
66
|
-
* region chips when `regions` is set, and a submit needs only an image and
|
|
67
|
-
* either a prompt or a region. Images only for now.
|
|
68
|
-
*/
|
|
69
|
-
edit?: boolean;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** A plain text request — what a submit means when no use case is picked, unless the builder sets `defaultUseCase`. */
|
|
73
|
-
export const TEXT_USE_CASE: ComposerUseCase = { kind: "text", label: "Text" };
|
|
74
|
-
|
|
75
|
-
/** Editing the attached image, the attach menu's "Edit image". Pair it with `useComposer`'s `editImage` and an `EditCard`. */
|
|
76
|
-
export const EDIT_IMAGE_USE_CASE: ComposerUseCase = { kind: "image", label: "Edit image", edit: true };
|
|
77
|
-
|
|
78
|
-
/** One use case to run and the models picked for it. Empty `modelIds` means the engine's default model. */
|
|
79
|
-
export interface ComposerSelection {
|
|
80
|
-
useCase: ComposerUseCase;
|
|
81
|
-
modelIds: string[];
|
|
82
|
-
/** The models offered for this use case: what auto-select routes among. */
|
|
83
|
-
models: ModelOption[];
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Everything Composer itself tracks, assembled into one object at submit
|
|
88
|
-
* time — not the the design `Request` shape (no `mode`/`params`/
|
|
89
|
-
* `presetId`; those belong to the run store this package doesn't have yet,
|
|
90
|
-
* see the package README), just an honest snapshot of this component's own
|
|
91
|
-
* state. Handed to `onSubmit` so a builder doesn't have to re-derive it
|
|
92
|
-
* from five separately-controlled props they already hold — mainly a
|
|
93
|
-
* correctness/ergonomics win for a coding agent wiring this up from the
|
|
94
|
-
* type signature alone.
|
|
95
|
-
*/
|
|
96
|
-
export interface ComposerSubmitPayload {
|
|
97
|
-
value: string;
|
|
98
|
-
attachments: ComposerAttachment[];
|
|
99
|
-
/** The use case to run — the picked one, or `defaultUseCase` when none is picked. The first one with `multiSelectUseCases`. */
|
|
100
|
-
useCase: ComposerUseCase;
|
|
101
|
-
/** The first picked model, or null for the engine's default. */
|
|
102
|
-
modelId: string | null;
|
|
103
|
-
aspectRatio: string | null;
|
|
104
|
-
/** Everything to run: one entry per use case with its picked models. A single entry unless `multiSelectUseCases` is on. */
|
|
105
|
-
selections: ComposerSelection[];
|
|
106
|
-
/** When true, the model is chosen at submit (`useComposer` routes among each selection's `models`), not by the picks. */
|
|
107
|
-
autoSelectModel: boolean;
|
|
108
|
-
/** An edit's marked regions, each with its own instruction. Empty unless the use case is an edit. */
|
|
109
|
-
regions?: EditRegion[];
|
|
110
|
-
}
|
|
111
|
-
|
|
112
61
|
export interface ComposerProps {
|
|
113
62
|
/** The prompt text. Controlled — Composer owns no text state of its own. */
|
|
114
63
|
value: string;
|
|
@@ -134,7 +83,7 @@ export interface ComposerProps {
|
|
|
134
83
|
multiSelectUseCases?: boolean;
|
|
135
84
|
useCases?: ComposerUseCase[];
|
|
136
85
|
onUseCasesChange?: (next: ComposerUseCase[]) => void;
|
|
137
|
-
/** Models per use-case kind, for `multiSelectUseCases`. */
|
|
86
|
+
/** Models per use-case kind, for `multiSelectUseCases`. A kind left out gets Chai's suggested models (see `models`). */
|
|
138
87
|
modelsByKind?: Partial<Record<MediaKind, ModelOption[]>>;
|
|
139
88
|
/**
|
|
140
89
|
* Builder opt-in, off by default: the end user can pick several models,
|
|
@@ -145,7 +94,12 @@ export interface ComposerProps {
|
|
|
145
94
|
multiSelectModels?: boolean;
|
|
146
95
|
modelIds?: string[];
|
|
147
96
|
onModelIdsChange?: (ids: string[]) => void;
|
|
148
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* Models for the current use case. Leave it out and Composer offers Chai's
|
|
99
|
+
* suggested Fal models for the use case (`suggestedModels`), with the
|
|
100
|
+
* first one picked until the person picks another. Pass a list to use
|
|
101
|
+
* your own; pass `[]` for no Model menu.
|
|
102
|
+
*/
|
|
149
103
|
models?: ModelOption[];
|
|
150
104
|
modelId?: string | null;
|
|
151
105
|
/** `null` clears the pick: Composer does this when auto-select turns on, until routing picks a model. */
|
|
@@ -311,19 +265,31 @@ export function Composer({
|
|
|
311
265
|
const showStop = submitting && Boolean(onAbort);
|
|
312
266
|
const multiModel = multiSelectModels || multiSelectUseCases;
|
|
313
267
|
const rawPickedIds = multiModel ? modelIds : modelId ? [modelId] : [];
|
|
268
|
+
// The app's list for a use case, or Chai's suggested models when it gave none.
|
|
269
|
+
const rosterFor = (u: ComposerUseCase): { options: ModelOption[]; suggested: boolean } => {
|
|
270
|
+
const given = multiSelectUseCases ? modelsByKind?.[u.kind] : models;
|
|
271
|
+
if (given) return { options: given, suggested: false };
|
|
272
|
+
return { options: suggestedModels(u.edit ? "image-edit" : deriveGenerationUseCase(u, attachments)), suggested: true };
|
|
273
|
+
};
|
|
314
274
|
// One menu section per use case with multi-use-case select; otherwise one flat roster.
|
|
315
|
-
const modelSections = multiSelectUseCases
|
|
316
|
-
?
|
|
317
|
-
|
|
318
|
-
.filter((s) => s.options.length > 0)
|
|
319
|
-
: [{ label: "", kind: activeUseCases[0]!.kind, options: models ?? [] }].filter((s) => s.options.length > 0);
|
|
275
|
+
const modelSections = (multiSelectUseCases ? activeUseCases : activeUseCases.slice(0, 1))
|
|
276
|
+
.map((u) => ({ label: multiSelectUseCases ? u.label : "", kind: u.kind, ...rosterFor(u) }))
|
|
277
|
+
.filter((s) => s.options.length > 0);
|
|
320
278
|
const allModels = modelSections.flatMap((s) => s.options);
|
|
321
279
|
// A pick that isn't in the current list (e.g. the edit model after leaving
|
|
322
280
|
// edit mode) is shown as unpicked and never submitted. With no list at
|
|
323
|
-
// all, the app's pick is passed through as given.
|
|
324
|
-
|
|
281
|
+
// all, the app's pick is passed through as given. With nothing picked
|
|
282
|
+
// from a suggested list (and auto-select off), its first model is the pick.
|
|
283
|
+
const validPicks =
|
|
325
284
|
allModels.length > 0 ? rawPickedIds.filter((id) => allModels.some((m) => m.id === id)) : rawPickedIds;
|
|
285
|
+
const pickedModelIds =
|
|
286
|
+
validPicks.length > 0 || autoSelectModel
|
|
287
|
+
? validPicks
|
|
288
|
+
: modelSections.filter((s) => s.suggested).map((s) => s.options[0]!.id);
|
|
326
289
|
const pickedModels = allModels.filter((m) => pickedModelIds.includes(m.id));
|
|
290
|
+
// No menu that can't change anything: without its handler, the Model menu
|
|
291
|
+
// hides (the default pick still runs).
|
|
292
|
+
const canPickModel = multiModel ? Boolean(onModelIdsChange) : Boolean(onModelChange);
|
|
327
293
|
const modelTriggerLabel =
|
|
328
294
|
pickedModels.length > 1
|
|
329
295
|
? `${pickedModels.length} models`
|
|
@@ -354,9 +320,9 @@ export function Composer({
|
|
|
354
320
|
function buildSelections(): ComposerSelection[] {
|
|
355
321
|
return activeUseCases.map((u) => ({
|
|
356
322
|
useCase: u,
|
|
357
|
-
models:
|
|
323
|
+
models: rosterFor(u).options,
|
|
358
324
|
modelIds: multiSelectUseCases
|
|
359
|
-
? pickedModelIds.filter((id) =>
|
|
325
|
+
? pickedModelIds.filter((id) => rosterFor(u).options.some((m) => m.id === id))
|
|
360
326
|
: pickedModelIds,
|
|
361
327
|
}));
|
|
362
328
|
}
|
|
@@ -610,7 +576,7 @@ export function Composer({
|
|
|
610
576
|
trigger stays enabled whenever the switch is shown — it's the
|
|
611
577
|
only way back to the switch. Rows are disabled by `SearchMenu`
|
|
612
578
|
itself while auto-select is on. */}
|
|
613
|
-
{modelSections.length > 0 && (
|
|
579
|
+
{modelSections.length > 0 && canPickModel && (
|
|
614
580
|
<SearchMenu
|
|
615
581
|
{...(multiSelectUseCases
|
|
616
582
|
? { sections: modelSections.map((s) => ({ label: s.label, options: s.options })) }
|
|
@@ -636,7 +602,7 @@ export function Composer({
|
|
|
636
602
|
/>
|
|
637
603
|
)}
|
|
638
604
|
|
|
639
|
-
{showAspectRatio && aspectOptions.length > 0 && (
|
|
605
|
+
{showAspectRatio && aspectOptions.length > 0 && onAspectRatioChange && (
|
|
640
606
|
<SearchMenu
|
|
641
607
|
options={aspectOptions.map((o) => ({ id: o.value, label: o.label }))}
|
|
642
608
|
value={offeredAspect}
|
|
@@ -1046,20 +1012,6 @@ function UndoIcon() {
|
|
|
1046
1012
|
);
|
|
1047
1013
|
}
|
|
1048
1014
|
|
|
1049
|
-
// Real Material Symbols "stop" glyph, the filled variant specifically
|
|
1050
|
-
// (`stop-fill`, not the base `stop` — that one traces a hollow square-
|
|
1051
|
-
// outline shape even filled with `currentColor`, which read as a thin
|
|
1052
|
-
// frame rather than a real stop icon at 24px; design review caught
|
|
1053
|
-
// this live). Viewbox `0 -960 960 960` — the submit FAB's in-flight state,
|
|
1054
|
-
// its own doc comment above where it's rendered.
|
|
1055
|
-
function StopIcon() {
|
|
1056
|
-
return (
|
|
1057
|
-
<svg viewBox="0 -960 960 960" aria-hidden="true">
|
|
1058
|
-
<path d="M240-240v-480h480v480H240Z" fill="currentColor" />
|
|
1059
|
-
</svg>
|
|
1060
|
-
);
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
1015
|
function AttachFileIcon() {
|
|
1064
1016
|
return (
|
|
1065
1017
|
<svg viewBox="0 -960 960 960" aria-hidden="true">
|
package/src/EditCard.tsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
|
|
2
|
-
import { DEFAULT_MAX_REGIONS, type EditRegion, type
|
|
2
|
+
import { DEFAULT_MAX_REGIONS, type EditRegion, type EditVersion, type RegionBox } from "@chai-ui/core";
|
|
3
|
+
|
|
4
|
+
export type { EditVersion } from "@chai-ui/core";
|
|
3
5
|
import { Pagination } from "./primitives/Pagination.js";
|
|
4
6
|
import {
|
|
5
7
|
AddIcon,
|
|
@@ -18,22 +20,6 @@ import { downloadFilename, downloadMedia, shareMedia } from "./media-actions.js"
|
|
|
18
20
|
import { PageSlide, usePageSlide } from "./page-slide.js";
|
|
19
21
|
import { regionColor } from "./regions.js";
|
|
20
22
|
|
|
21
|
-
/**
|
|
22
|
-
* One version of the image being edited: the original, or the result of an
|
|
23
|
-
* edit. `useComposer`'s `edit.versions` builds these from its runs.
|
|
24
|
-
*/
|
|
25
|
-
export interface EditVersion {
|
|
26
|
-
id: string;
|
|
27
|
-
/** The image, once there is one. */
|
|
28
|
-
src?: string;
|
|
29
|
-
/** The image this version was edited from, shown under the progress spinner until `src` arrives. */
|
|
30
|
-
from?: string;
|
|
31
|
-
status: ResultStatus;
|
|
32
|
-
/** The regions this version was made with (none for the original). Shown faintly, for reference. */
|
|
33
|
-
regions: EditRegion[];
|
|
34
|
-
error?: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
23
|
export interface EditCardProps {
|
|
38
24
|
/** The original image first, then each edit. Paged with the dots in the card's bottom row. */
|
|
39
25
|
versions: EditVersion[];
|
|
@@ -168,6 +154,7 @@ export function EditCard({
|
|
|
168
154
|
// until the next version's image loads, so the card doesn't jump between.
|
|
169
155
|
const [natural, setNatural] = useState<{ width: number; height: number } | null>(null);
|
|
170
156
|
const [expanded, setExpanded] = useState(false);
|
|
157
|
+
const [linkCopied, setLinkCopied] = useState(false);
|
|
171
158
|
const drag = useRef<Drag | null>(null);
|
|
172
159
|
const viewportRef = useRef<HTMLDivElement>(null);
|
|
173
160
|
const fieldRef = useRef<HTMLTextAreaElement>(null);
|
|
@@ -371,7 +358,11 @@ export function EditCard({
|
|
|
371
358
|
|
|
372
359
|
const handleShare = async () => {
|
|
373
360
|
if (!version?.src) return;
|
|
374
|
-
|
|
361
|
+
// With no share sheet the link is copied; say so, or the click looks like nothing happened.
|
|
362
|
+
if ((await shareMedia(version.src)) === "copied") {
|
|
363
|
+
setLinkCopied(true);
|
|
364
|
+
setTimeout(() => setLinkCopied(false), 1500);
|
|
365
|
+
}
|
|
375
366
|
onAction?.("share", version);
|
|
376
367
|
};
|
|
377
368
|
|
|
@@ -509,8 +500,15 @@ export function EditCard({
|
|
|
509
500
|
<button type="button" className="chai-card-top-btn" aria-label="Download" title="Download" disabled={disabled || !ready} onClick={handleDownload}>
|
|
510
501
|
<DownloadIcon />
|
|
511
502
|
</button>
|
|
512
|
-
<button
|
|
513
|
-
|
|
503
|
+
<button
|
|
504
|
+
type="button"
|
|
505
|
+
className="chai-card-top-btn"
|
|
506
|
+
aria-label={linkCopied ? "Link copied" : "Share"}
|
|
507
|
+
title={linkCopied ? "Link copied" : "Share"}
|
|
508
|
+
disabled={disabled || !ready}
|
|
509
|
+
onClick={handleShare}
|
|
510
|
+
>
|
|
511
|
+
{linkCopied ? <CheckSymbolIcon /> : <ShareIcon />}
|
|
514
512
|
</button>
|
|
515
513
|
<button type="button" className="chai-card-top-btn" aria-label="Expand" title="Expand" disabled={!ready} onClick={() => setExpanded(true)}>
|
|
516
514
|
<ExpandIcon />
|
package/src/MediaAnalyzer.tsx
CHANGED
|
@@ -2,9 +2,14 @@ import { createComponent } from "@lit/react";
|
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
import { useEffect, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
|
4
4
|
import { MdFab } from "@material/web/fab/fab.js";
|
|
5
|
+
import { SUGGESTED_ANALYSIS_MODELS } from "@chai-ui/core";
|
|
5
6
|
import type { DroppedMedia, ModelOption, MediaKind } from "@chai-ui/core";
|
|
6
7
|
import type { MediaAnalysisPromptLength } from "@chai-ui/core";
|
|
7
8
|
import { SearchMenu, type SearchMenuSection } from "./primitives/SearchMenu.js";
|
|
9
|
+
import { StopIcon } from "./icons.js";
|
|
10
|
+
import type { MediaAnalyzerAttachment, MediaAnalyzerSubmitPayload } from "@chai-ui/core";
|
|
11
|
+
|
|
12
|
+
export type { MediaAnalyzerAttachment, MediaAnalyzerSubmitPayload } from "@chai-ui/core";
|
|
8
13
|
|
|
9
14
|
/** See primitives/Toggle.tsx for why `createComponent` is used instead of raw JSX on custom-element tags. */
|
|
10
15
|
const MdFabElement = createComponent({
|
|
@@ -27,10 +32,6 @@ function nextAttachmentId(): string {
|
|
|
27
32
|
: `attachment-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
|
-
export interface MediaAnalyzerAttachment extends DroppedMedia {
|
|
31
|
-
id: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
35
|
/**
|
|
35
36
|
* How many attachments of each kind an analysis can hold at once. Image
|
|
36
37
|
* defaults to 5 (a sequence/series reads as one analysis); video and audio
|
|
@@ -97,26 +98,20 @@ export function isMediaAnalyzerAtCap(
|
|
|
97
98
|
return current.length >= (maxByKind.image ?? DEFAULT_MAX_ATTACHMENTS_BY_KIND.image);
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
export interface MediaAnalyzerSubmitPayload {
|
|
101
|
-
attachments: MediaAnalyzerAttachment[];
|
|
102
|
-
modelId: string | null;
|
|
103
|
-
/** When true, `useMediaAnalyzer` routes among `models` at submit instead of using `modelId`. */
|
|
104
|
-
autoSelectModel: boolean;
|
|
105
|
-
/** The models offered for the attached media's kind. */
|
|
106
|
-
models: ModelOption[];
|
|
107
|
-
/** `null` until a person actually picks one — see the `promptLength` prop's own doc comment for why this doesn't default to "concise". */
|
|
108
|
-
promptLength: MediaAnalysisPromptLength | null;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
101
|
export interface MediaAnalyzerProps {
|
|
112
102
|
attachments: MediaAnalyzerAttachment[];
|
|
113
103
|
onAttachmentsChange: (next: MediaAnalyzerAttachment[]) => void;
|
|
114
104
|
onUnsupportedFile?: (fileName: string) => void;
|
|
115
105
|
maxAttachmentsByKind?: Record<MediaKind, number>;
|
|
116
|
-
/**
|
|
117
|
-
models
|
|
118
|
-
/**
|
|
119
|
-
|
|
106
|
+
/** Fallback roster for a kind `modelsByKind` doesn't list. Optional. */
|
|
107
|
+
models?: ModelOption[];
|
|
108
|
+
/**
|
|
109
|
+
* Per-kind rosters — once a kind is known, only that kind's models show.
|
|
110
|
+
* Before that, all three show as separate sections (reference: Figma "06
|
|
111
|
+
* Composer from Media Analysis"). Defaults to Chai's suggested OpenRouter
|
|
112
|
+
* models (`SUGGESTED_ANALYSIS_MODELS`); pass your own to replace them.
|
|
113
|
+
*/
|
|
114
|
+
modelsByKind?: Partial<Record<MediaKind, ModelOption[]>>;
|
|
120
115
|
/**
|
|
121
116
|
* The picked model. While `autoSelectModel` is on it's `null` until a
|
|
122
117
|
* submit routes, then the routed model, shown by name on the trigger.
|
|
@@ -154,6 +149,8 @@ export interface MediaAnalyzerProps {
|
|
|
154
149
|
submitting?: boolean;
|
|
155
150
|
submitError?: string | null;
|
|
156
151
|
onSubmit: (payload: MediaAnalyzerSubmitPayload) => void;
|
|
152
|
+
/** Fires when the stop button is pressed while `submitting`. With it, submit turns into stop during an analysis (wire it to `useMediaAnalyzer`'s `cancel`); without it, submit is just disabled. */
|
|
153
|
+
onAbort?: () => void;
|
|
157
154
|
}
|
|
158
155
|
|
|
159
156
|
const KIND_LABEL: Record<MediaKind, string> = {
|
|
@@ -175,8 +172,8 @@ export function MediaAnalyzer({
|
|
|
175
172
|
onAttachmentsChange,
|
|
176
173
|
onUnsupportedFile,
|
|
177
174
|
maxAttachmentsByKind = DEFAULT_MAX_ATTACHMENTS_BY_KIND,
|
|
178
|
-
models,
|
|
179
|
-
modelsByKind,
|
|
175
|
+
models = [],
|
|
176
|
+
modelsByKind = SUGGESTED_ANALYSIS_MODELS,
|
|
180
177
|
modelId,
|
|
181
178
|
onModelChange,
|
|
182
179
|
autoSelectModel,
|
|
@@ -188,6 +185,7 @@ export function MediaAnalyzer({
|
|
|
188
185
|
submitting,
|
|
189
186
|
submitError,
|
|
190
187
|
onSubmit,
|
|
188
|
+
onAbort,
|
|
191
189
|
}: MediaAnalyzerProps) {
|
|
192
190
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
193
191
|
const kind = attachments[0]?.kind;
|
|
@@ -199,6 +197,9 @@ export function MediaAnalyzer({
|
|
|
199
197
|
// children. Active for as long as this is > 0; both drag handlers below
|
|
200
198
|
// clamp at 0 so a stray extra `dragleave` can't go negative.
|
|
201
199
|
const [dragDepth, setDragDepth] = useState(0);
|
|
200
|
+
// What the last attach replaced (one kind per analysis), shown until the
|
|
201
|
+
// attachments change some other way.
|
|
202
|
+
const [replaced, setReplaced] = useState<{ note: string; for: MediaAnalyzerAttachment[] } | null>(null);
|
|
202
203
|
const isDragActive = dragDepth > 0;
|
|
203
204
|
|
|
204
205
|
/**
|
|
@@ -221,9 +222,14 @@ export function MediaAnalyzer({
|
|
|
221
222
|
const settle = () => {
|
|
222
223
|
pending -= 1;
|
|
223
224
|
if (pending === 0 && resolved.length > 0) {
|
|
224
|
-
|
|
225
|
-
|
|
225
|
+
const next = resolved.reduce(
|
|
226
|
+
(acc, media) => nextMediaAnalyzerAttachments(acc, media, maxAttachmentsByKind),
|
|
227
|
+
attachments
|
|
226
228
|
);
|
|
229
|
+
const removed = attachments.filter((a) => !next.some((n) => n.id === a.id));
|
|
230
|
+
const added = next.filter((n) => !attachments.some((a) => a.id === n.id));
|
|
231
|
+
setReplaced(removed.length > 0 && added.length > 0 ? { note: replacedNote(removed, added), for: next } : null);
|
|
232
|
+
onAttachmentsChange(next);
|
|
227
233
|
}
|
|
228
234
|
};
|
|
229
235
|
files.forEach((file) => {
|
|
@@ -278,6 +284,7 @@ export function MediaAnalyzer({
|
|
|
278
284
|
const selectedLength = promptLengthOptions.find((o) => o.value === promptLength);
|
|
279
285
|
const hasContent = attachments.length > 0;
|
|
280
286
|
const isSubmitDisabled = disabled || !hasContent;
|
|
287
|
+
const showStop = Boolean(submitting && onAbort);
|
|
281
288
|
|
|
282
289
|
return (
|
|
283
290
|
<div className="chai-media-analyzer">
|
|
@@ -338,6 +345,12 @@ export function MediaAnalyzer({
|
|
|
338
345
|
</div>
|
|
339
346
|
)}
|
|
340
347
|
|
|
348
|
+
{replaced && replaced.for === attachments && (
|
|
349
|
+
<p className="chai-media-analyzer__note" role="status">
|
|
350
|
+
{replaced.note}
|
|
351
|
+
</p>
|
|
352
|
+
)}
|
|
353
|
+
|
|
341
354
|
{submitError && (
|
|
342
355
|
<p className="chai-media-analyzer__error" role="alert">
|
|
343
356
|
{submitError}
|
|
@@ -390,14 +403,16 @@ export function MediaAnalyzer({
|
|
|
390
403
|
size="small"
|
|
391
404
|
variant="secondary"
|
|
392
405
|
label=""
|
|
393
|
-
data-ready={!isSubmitDisabled || undefined}
|
|
406
|
+
data-ready={showStop || !isSubmitDisabled || undefined}
|
|
394
407
|
data-error={Boolean(submitError) || undefined}
|
|
408
|
+
data-stop={showStop || undefined}
|
|
395
409
|
{...({
|
|
396
|
-
ariaLabel: submitError ? "Retry" : "Analyze media",
|
|
397
|
-
ariaDisabled: submitting ? "true" : isSubmitDisabled ? "true" : "false",
|
|
410
|
+
ariaLabel: showStop ? "Stop" : submitError ? "Retry" : "Analyze media",
|
|
411
|
+
ariaDisabled: showStop ? "false" : submitting ? "true" : isSubmitDisabled ? "true" : "false",
|
|
398
412
|
} as Record<string, unknown>)}
|
|
399
413
|
onClick={() => {
|
|
400
|
-
if (
|
|
414
|
+
if (showStop) onAbort?.();
|
|
415
|
+
else if (!isSubmitDisabled && !submitting) onSubmit({
|
|
401
416
|
attachments,
|
|
402
417
|
modelId,
|
|
403
418
|
autoSelectModel,
|
|
@@ -406,9 +421,7 @@ export function MediaAnalyzer({
|
|
|
406
421
|
});
|
|
407
422
|
}}
|
|
408
423
|
>
|
|
409
|
-
<span slot="icon">
|
|
410
|
-
<ArrowForwardIcon />
|
|
411
|
-
</span>
|
|
424
|
+
<span slot="icon">{showStop ? <StopIcon /> : <ArrowForwardIcon />}</span>
|
|
412
425
|
</MdFabElement>
|
|
413
426
|
</div>
|
|
414
427
|
</div>
|
|
@@ -447,6 +460,14 @@ function MediaAnalyzerThumbnail({
|
|
|
447
460
|
// Same "trace and inline as a currentColor component" convention Composer.tsx
|
|
448
461
|
// documents — icons aren't shared across files yet in this codebase.
|
|
449
462
|
|
|
463
|
+
/** "Replaced 2 images with clip.wav." */
|
|
464
|
+
function replacedNote(removed: MediaAnalyzerAttachment[], added: MediaAnalyzerAttachment[]): string {
|
|
465
|
+
const name = (a: MediaAnalyzerAttachment) => a.name ?? KIND_LABEL[a.kind].toLowerCase();
|
|
466
|
+
const describe = (list: MediaAnalyzerAttachment[]) =>
|
|
467
|
+
list.length === 1 ? name(list[0]!) : `${list.length} ${KIND_LABEL[list[0]!.kind].toLowerCase()}s`;
|
|
468
|
+
return `Replaced ${describe(removed)} with ${describe(added)}.`;
|
|
469
|
+
}
|
|
470
|
+
|
|
450
471
|
function ArrowForwardIcon() {
|
|
451
472
|
return (
|
|
452
473
|
<svg viewBox="0 -960 960 960" aria-hidden="true">
|
package/src/ResultCard.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
2
2
|
import { ROUTING_MODEL_ID, type ModelOption, type Result } from "@chai-ui/core";
|
|
3
3
|
import { Pagination } from "./primitives/Pagination.js";
|
|
4
|
-
import { DownloadIcon, ExpandIcon, ShareIcon } from "./icons.js";
|
|
4
|
+
import { CheckSymbolIcon, DownloadIcon, ExpandIcon, ShareIcon } from "./icons.js";
|
|
5
5
|
import { downloadFilename, downloadMedia, shareMedia } from "./media-actions.js";
|
|
6
6
|
import { PageSlide, usePageSlide } from "./page-slide.js";
|
|
7
7
|
|
|
@@ -125,6 +125,7 @@ export function ResultCard({
|
|
|
125
125
|
const [flipped, setFlipped] = useState(false);
|
|
126
126
|
const [moreOpen, setMoreOpen] = useState(false);
|
|
127
127
|
const [copied, setCopied] = useState(false);
|
|
128
|
+
const [linkCopied, setLinkCopied] = useState(false);
|
|
128
129
|
const [mediaExpanded, setMediaExpanded] = useState(false);
|
|
129
130
|
// Not part of `Result` (no engine reports generated-media dimensions
|
|
130
131
|
// today) — measured client-side off the loaded `<img>`/`<video>` itself
|
|
@@ -238,7 +239,11 @@ export function ResultCard({
|
|
|
238
239
|
};
|
|
239
240
|
|
|
240
241
|
const handleShare = async () => {
|
|
241
|
-
|
|
242
|
+
// With no share sheet the link is copied; say so, or the click looks like nothing happened.
|
|
243
|
+
if (result.output && (await shareMedia(result.output.src)) === "copied") {
|
|
244
|
+
setLinkCopied(true);
|
|
245
|
+
setTimeout(() => setLinkCopied(false), 1500);
|
|
246
|
+
}
|
|
242
247
|
onAction("share", result);
|
|
243
248
|
};
|
|
244
249
|
|
|
@@ -274,6 +279,7 @@ export function ResultCard({
|
|
|
274
279
|
mediaExpanded={mediaExpanded}
|
|
275
280
|
onDownload={handleDownload}
|
|
276
281
|
onShare={handleShare}
|
|
282
|
+
linkCopied={linkCopied}
|
|
277
283
|
onExpand={() => setMediaExpanded(true)}
|
|
278
284
|
onDimensions={(w, h) => setMeasured({ id: result.id, width: w, height: h })}
|
|
279
285
|
videoTimeRef={videoTimeRef}
|
|
@@ -458,6 +464,7 @@ function ResultMedia({
|
|
|
458
464
|
mediaExpanded,
|
|
459
465
|
onDownload,
|
|
460
466
|
onShare,
|
|
467
|
+
linkCopied,
|
|
461
468
|
onExpand,
|
|
462
469
|
onDimensions,
|
|
463
470
|
videoTimeRef,
|
|
@@ -470,6 +477,7 @@ function ResultMedia({
|
|
|
470
477
|
mediaExpanded: boolean;
|
|
471
478
|
onDownload: () => void;
|
|
472
479
|
onShare: () => void;
|
|
480
|
+
linkCopied: boolean;
|
|
473
481
|
onExpand: () => void;
|
|
474
482
|
onDimensions: (width: number, height: number) => void;
|
|
475
483
|
videoTimeRef: React.MutableRefObject<number>;
|
|
@@ -555,8 +563,8 @@ function ResultMedia({
|
|
|
555
563
|
<ResultIconButton variant="top" label="Download" disabled={disabled} onClick={onDownload}>
|
|
556
564
|
<DownloadIcon />
|
|
557
565
|
</ResultIconButton>
|
|
558
|
-
<ResultIconButton variant="top" label="Share" disabled={disabled} onClick={onShare}>
|
|
559
|
-
<ShareIcon />
|
|
566
|
+
<ResultIconButton variant="top" label={linkCopied ? "Link copied" : "Share"} disabled={disabled} onClick={onShare}>
|
|
567
|
+
{linkCopied ? <CheckSymbolIcon /> : <ShareIcon />}
|
|
560
568
|
</ResultIconButton>
|
|
561
569
|
{(isVideo || isImage) && (
|
|
562
570
|
<ResultIconButton variant="top" label="Expand" disabled={disabled} onClick={onExpand}>
|
package/src/icons.tsx
CHANGED
|
@@ -113,3 +113,17 @@ export function CloseSymbolIcon() {
|
|
|
113
113
|
</svg>
|
|
114
114
|
);
|
|
115
115
|
}
|
|
116
|
+
|
|
117
|
+
// Real Material Symbols "stop" glyph, the filled variant specifically
|
|
118
|
+
// (`stop-fill`, not the base `stop` — that one traces a hollow square-
|
|
119
|
+
// outline shape even filled with `currentColor`, which read as a thin
|
|
120
|
+
// frame rather than a real stop icon at 24px; design review caught
|
|
121
|
+
// this live). Viewbox `0 -960 960 960` — the submit FAB's in-flight state,
|
|
122
|
+
// its own doc comment above where it's rendered.
|
|
123
|
+
export function StopIcon() {
|
|
124
|
+
return (
|
|
125
|
+
<svg viewBox="0 -960 960 960" aria-hidden="true">
|
|
126
|
+
<path d="M240-240v-480h480v480H240Z" fill="currentColor" />
|
|
127
|
+
</svg>
|
|
128
|
+
);
|
|
129
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,13 @@ export {
|
|
|
22
22
|
DEFAULT_MEDIA_ANALYSIS_PROMPT_LENGTH,
|
|
23
23
|
MEDIA_ANALYSIS_PROMPTS,
|
|
24
24
|
MEDIA_ANALYSIS_PROMPT_LENGTHS,
|
|
25
|
+
SUGGESTED_IMAGE_MODELS,
|
|
26
|
+
SUGGESTED_TEXT_TO_VIDEO_MODELS,
|
|
27
|
+
SUGGESTED_IMAGE_TO_VIDEO_MODELS,
|
|
28
|
+
SUGGESTED_EDIT_MODELS,
|
|
29
|
+
SUGGESTED_ANALYSIS_MODELS,
|
|
30
|
+
suggestedModels,
|
|
31
|
+
checkSuggestedModels,
|
|
25
32
|
} from "@chai-ui/core";
|
|
26
33
|
export type {
|
|
27
34
|
DroppedMedia,
|
|
@@ -40,5 +47,8 @@ export type {
|
|
|
40
47
|
Result,
|
|
41
48
|
ResultStatus,
|
|
42
49
|
Run,
|
|
50
|
+
SuggestedModelProblem,
|
|
51
|
+
SuggestedModelsCheck,
|
|
52
|
+
FalInputFormat,
|
|
43
53
|
Usage,
|
|
44
54
|
} from "@chai-ui/core";
|