@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.
Files changed (54) hide show
  1. package/DESIGN.md +6 -1
  2. package/README.md +79 -17
  3. package/dist/ChaiProvider.d.ts +3 -42
  4. package/dist/ChaiProvider.d.ts.map +1 -1
  5. package/dist/ChaiProvider.js +5 -43
  6. package/dist/ChaiProvider.js.map +1 -1
  7. package/dist/Composer.d.ts +10 -60
  8. package/dist/Composer.d.ts.map +1 -1
  9. package/dist/Composer.js +26 -24
  10. package/dist/Composer.js.map +1 -1
  11. package/dist/EditCard.d.ts +2 -16
  12. package/dist/EditCard.d.ts.map +1 -1
  13. package/dist/EditCard.js +7 -2
  14. package/dist/EditCard.js.map +1 -1
  15. package/dist/MediaAnalyzer.d.ts +14 -18
  16. package/dist/MediaAnalyzer.d.ts.map +1 -1
  17. package/dist/MediaAnalyzer.js +26 -8
  18. package/dist/MediaAnalyzer.js.map +1 -1
  19. package/dist/ResultCard.d.ts.map +1 -1
  20. package/dist/ResultCard.js +10 -6
  21. package/dist/ResultCard.js.map +1 -1
  22. package/dist/icons.d.ts +1 -0
  23. package/dist/icons.d.ts.map +1 -1
  24. package/dist/icons.js +9 -0
  25. package/dist/icons.js.map +1 -1
  26. package/dist/index.d.ts +2 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/media-actions.d.ts +5 -2
  31. package/dist/media-actions.d.ts.map +1 -1
  32. package/dist/media-actions.js +12 -3
  33. package/dist/media-actions.js.map +1 -1
  34. package/dist/style.css +15 -0
  35. package/dist/useComposer.d.ts +11 -86
  36. package/dist/useComposer.d.ts.map +1 -1
  37. package/dist/useComposer.js +26 -291
  38. package/dist/useComposer.js.map +1 -1
  39. package/dist/useMediaAnalyzer.d.ts +8 -28
  40. package/dist/useMediaAnalyzer.d.ts.map +1 -1
  41. package/dist/useMediaAnalyzer.js +9 -106
  42. package/dist/useMediaAnalyzer.js.map +1 -1
  43. package/package.json +3 -3
  44. package/src/ChaiProvider.tsx +12 -70
  45. package/src/Composer.tsx +42 -90
  46. package/src/EditCard.tsx +18 -20
  47. package/src/MediaAnalyzer.tsx +51 -30
  48. package/src/ResultCard.tsx +12 -4
  49. package/src/icons.tsx +14 -0
  50. package/src/index.ts +10 -0
  51. package/src/media-actions.ts +13 -4
  52. package/src/style.css +15 -0
  53. package/src/useComposer.ts +33 -411
  54. package/src/useMediaAnalyzer.ts +15 -146
@@ -1,88 +1,30 @@
1
1
  import { createContext, useContext, useMemo, type ReactNode } from "react";
2
- import { createOpenRouterEngine, type DroppedMedia, type GenerationEngine, type MediaKind } from "@chai-ui/core";
2
+ import { createReasoning, reasoningEngineFor, type Reasoning, type ReasoningConfig } from "@chai-ui/core";
3
3
 
4
- /**
5
- * The suggested reasoning model for text and images: prompt enhancement,
6
- * auto-select routing and image analysis. A default, not a requirement —
7
- * pass any model id `reasoningEngine` can run.
8
- */
9
- export const DEFAULT_REASONING_MODEL = "anthropic/claude-opus-5";
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 ?? createOpenRouterEngine(), [reasoningEngine]);
52
- const model = reasoningModel ?? DEFAULT_REASONING_MODEL;
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
- engine,
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
- /** Models valid for the current use case. The Model menu only renders when this is non-empty. */
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
- ? activeUseCases
317
- .map((u) => ({ label: u.label, kind: u.kind, options: modelsByKind?.[u.kind] ?? [] }))
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
- const pickedModelIds =
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: multiSelectUseCases ? (modelsByKind?.[u.kind] ?? []) : (models ?? []),
323
+ models: rosterFor(u).options,
358
324
  modelIds: multiSelectUseCases
359
- ? pickedModelIds.filter((id) => modelsByKind?.[u.kind]?.some((m) => m.id === 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 RegionBox, type ResultStatus } from "@chai-ui/core";
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
- await shareMedia(version.src);
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 type="button" className="chai-card-top-btn" aria-label="Share" title="Share" disabled={disabled || !ready} onClick={handleShare}>
513
- <ShareIcon />
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 />
@@ -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
- /** Flat fallback roster, shown (ungrouped) before any media's kind is known. */
117
- models: ModelOption[];
118
- /** Per-kind rosters — once a kind is known, only that kind's models show. Before that, all three show as separate sections (reference: Figma "06 Composer from Media Analysis"). */
119
- modelsByKind: Partial<Record<MediaKind, ModelOption[]>>;
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
- onAttachmentsChange(
225
- resolved.reduce((acc, media) => nextMediaAnalyzerAttachments(acc, media, maxAttachmentsByKind), attachments)
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 (!isSubmitDisabled && !submitting) onSubmit({
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">
@@ -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
- if (result.output) await shareMedia(result.output.src);
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";