@chai-ui/core 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.
Files changed (52) hide show
  1. package/README.md +10 -5
  2. package/dist/composer-view.d.ts +93 -0
  3. package/dist/composer-view.d.ts.map +1 -0
  4. package/dist/composer-view.js +142 -0
  5. package/dist/composer-view.js.map +1 -0
  6. package/dist/composer.d.ts +188 -0
  7. package/dist/composer.d.ts.map +1 -0
  8. package/dist/composer.js +326 -0
  9. package/dist/composer.js.map +1 -0
  10. package/dist/edit-card.d.ts +53 -0
  11. package/dist/edit-card.d.ts.map +1 -0
  12. package/dist/edit-card.js +119 -0
  13. package/dist/edit-card.js.map +1 -0
  14. package/dist/index.d.ts +7 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +7 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/media-actions.d.ts +19 -0
  19. package/dist/media-actions.d.ts.map +1 -0
  20. package/dist/media-actions.js +57 -0
  21. package/dist/media-actions.js.map +1 -0
  22. package/dist/media-analyzer.d.ts +101 -0
  23. package/dist/media-analyzer.d.ts.map +1 -0
  24. package/dist/media-analyzer.js +215 -0
  25. package/dist/media-analyzer.js.map +1 -0
  26. package/dist/reasoning.d.ts +59 -0
  27. package/dist/reasoning.d.ts.map +1 -0
  28. package/dist/reasoning.js +55 -0
  29. package/dist/reasoning.js.map +1 -0
  30. package/dist/region-edit.d.ts +2 -0
  31. package/dist/region-edit.d.ts.map +1 -1
  32. package/dist/region-edit.js +6 -0
  33. package/dist/region-edit.js.map +1 -1
  34. package/dist/result-format.d.ts +11 -0
  35. package/dist/result-format.d.ts.map +1 -0
  36. package/dist/result-format.js +30 -0
  37. package/dist/result-format.js.map +1 -0
  38. package/dist/unique-id.d.ts +3 -0
  39. package/dist/unique-id.d.ts.map +1 -0
  40. package/dist/unique-id.js +7 -0
  41. package/dist/unique-id.js.map +1 -0
  42. package/package.json +2 -1
  43. package/src/composer-view.ts +222 -0
  44. package/src/composer.ts +525 -0
  45. package/src/edit-card.ts +138 -0
  46. package/src/index.ts +7 -0
  47. package/src/media-actions.ts +55 -0
  48. package/src/media-analyzer.ts +293 -0
  49. package/src/reasoning.ts +90 -0
  50. package/src/region-edit.ts +8 -0
  51. package/src/result-format.ts +34 -0
  52. package/src/unique-id.ts +6 -0
@@ -0,0 +1,7 @@
1
+ /** A unique id for an attachment or region. Internal: not exported from the package. */
2
+ export function uniqueId() {
3
+ return typeof crypto !== "undefined" && "randomUUID" in crypto
4
+ ? crypto.randomUUID()
5
+ : `id-${Date.now()}-${Math.random().toString(36).slice(2)}`;
6
+ }
7
+ //# sourceMappingURL=unique-id.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unique-id.js","sourceRoot":"","sources":["../src/unique-id.ts"],"names":[],"mappings":"AAAA,wFAAwF;AACxF,MAAM,UAAU,QAAQ;IACtB,OAAO,OAAO,MAAM,KAAK,WAAW,IAAI,YAAY,IAAI,MAAM;QAC5D,CAAC,CAAC,MAAM,CAAC,UAAU,EAAE;QACrB,CAAC,CAAC,MAAM,IAAI,CAAC,GAAG,EAAE,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;AAChE,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chai-ui/core",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "CHAI UI headless core — the Request/Run/Result data model and pluggable generation engines (Fal.ai, OpenRouter, mock). No framework, no rendering.",
5
5
  "license": "MIT",
6
6
  "author": "kazarazat",
@@ -44,6 +44,7 @@
44
44
  ],
45
45
  "devDependencies": {
46
46
  "@vitest/coverage-v8": "3.2.7",
47
+ "jsdom": "^25.0.1",
47
48
  "rimraf": "^6.0.1",
48
49
  "typescript": "^5.6.3",
49
50
  "vitest": "^3.2.7"
@@ -0,0 +1,222 @@
1
+ /**
2
+ * What the Composer component shows and submits, worked out from its props:
3
+ * the same in every framework binding. Pure; nothing here renders.
4
+ */
5
+ import type { ComposerAttachment, ComposerSelection, ComposerSubmitPayload, ComposerUseCase } from "./composer.js";
6
+ import { uniqueId } from "./unique-id.js";
7
+ import { TEXT_USE_CASE } from "./composer.js";
8
+ import { readMediaFiles } from "./media-analyzer.js";
9
+ import type { EditRegion } from "./region-edit.js";
10
+ import { deriveGenerationUseCase } from "./run.js";
11
+ import { suggestedModels } from "./suggested-models.js";
12
+ import type { MediaKind, ModelOption, ParameterOption } from "./types.js";
13
+
14
+ export type ComposerAttachMenuAction = "add-media" | "create-image" | "create-video" | "edit-media";
15
+
16
+ /** The `+` menu, in order. "Add media" is the only item with a divider after it. */
17
+ export const COMPOSER_ATTACH_MENU_ITEMS: { action: ComposerAttachMenuAction; label: string; divider?: boolean }[] = [
18
+ { action: "add-media", label: "Add media", divider: true },
19
+ { action: "create-image", label: "Create image" },
20
+ { action: "create-video", label: "Create video" },
21
+ { action: "edit-media", label: "Edit image" },
22
+ ];
23
+
24
+ /**
25
+ * Past this length a rewritten prompt appears at once: a long reveal reads
26
+ * as sluggish, and a long prompt is when a person wants the result now.
27
+ */
28
+ export const MAX_ANIMATE_ENHANCE_LENGTH = 260;
29
+
30
+ /** How long the Enhance reveal takes: time-driven, between 300ms and 900ms. */
31
+ export function enhanceRevealDurationMs(charCount: number): number {
32
+ return Math.min(900, Math.max(300, charCount * 6));
33
+ }
34
+
35
+ /** The Composer props `composerView` reads. Callbacks only matter by being there. */
36
+ export interface ComposerViewInput {
37
+ value: string;
38
+ placeholder?: string;
39
+ attachments?: ComposerAttachment[];
40
+ useCase?: ComposerUseCase | null;
41
+ defaultUseCase?: ComposerUseCase;
42
+ multiSelectUseCases?: boolean;
43
+ useCases?: ComposerUseCase[];
44
+ modelsByKind?: Partial<Record<MediaKind, ModelOption[]>>;
45
+ multiSelectModels?: boolean;
46
+ modelIds?: string[];
47
+ models?: ModelOption[];
48
+ modelId?: string | null;
49
+ onModelChange?: unknown;
50
+ onModelIdsChange?: unknown;
51
+ autoSelectModel?: boolean;
52
+ aspectRatios?: ParameterOption<string>[];
53
+ aspectRatio?: string | null;
54
+ submitError?: string | null;
55
+ submitDisabled?: boolean;
56
+ submitting?: boolean;
57
+ onAbort?: unknown;
58
+ regions?: EditRegion[];
59
+ disabled?: boolean;
60
+ }
61
+
62
+ export function composerView(props: ComposerViewInput) {
63
+ const {
64
+ value,
65
+ attachments = [],
66
+ useCase = null,
67
+ defaultUseCase = TEXT_USE_CASE,
68
+ multiSelectUseCases = false,
69
+ useCases = [],
70
+ modelsByKind,
71
+ multiSelectModels = false,
72
+ modelIds = [],
73
+ models,
74
+ modelId = null,
75
+ autoSelectModel = false,
76
+ aspectRatios,
77
+ aspectRatio = null,
78
+ submitError = null,
79
+ submitting = false,
80
+ regions = [],
81
+ disabled = false,
82
+ } = props;
83
+
84
+ // What the person picked, and what actually runs: with nothing picked, the
85
+ // builder's default use case (a text request unless they chose otherwise).
86
+ const pickedUseCases = multiSelectUseCases ? useCases : useCase ? [useCase] : [];
87
+ const activeUseCases = pickedUseCases.length > 0 ? pickedUseCases : [defaultUseCase];
88
+ // An edit needs an image, then a change: a prompt for the whole image, a
89
+ // region with its own instruction, or both.
90
+ const editing = activeUseCases.some((u) => u.edit);
91
+ const hasImage = attachments.some((a) => a.kind === "image");
92
+ const hasContent = editing
93
+ ? hasImage && (value.trim().length > 0 || regions.length > 0)
94
+ : value.trim().length > 0 || attachments.length > 0;
95
+ const isSubmitDisabled = submitError ? disabled : (props.submitDisabled ?? (disabled || !hasContent));
96
+ const placeholder =
97
+ props.placeholder ??
98
+ (editing
99
+ ? hasImage
100
+ ? "Describe a change across the whole image, or leave this empty"
101
+ : "Attach an image to edit"
102
+ : "Describe media to create");
103
+ // The stop icon only appears with somewhere for its click to go.
104
+ const showStop = submitting && Boolean(props.onAbort);
105
+
106
+ const multiModel = multiSelectModels || multiSelectUseCases;
107
+ const rawPickedIds = multiModel ? modelIds : modelId ? [modelId] : [];
108
+ // The app's list for a use case, or Chai's suggested models when it gave none.
109
+ const rosterFor = (u: ComposerUseCase): { options: ModelOption[]; suggested: boolean } => {
110
+ const given = multiSelectUseCases ? modelsByKind?.[u.kind] : models;
111
+ if (given) return { options: given, suggested: false };
112
+ return { options: suggestedModels(u.edit ? "image-edit" : deriveGenerationUseCase(u, attachments)), suggested: true };
113
+ };
114
+ // One menu section per use case with multi-use-case select; otherwise one flat roster.
115
+ const modelSections = (multiSelectUseCases ? activeUseCases : activeUseCases.slice(0, 1))
116
+ .map((u) => ({ label: multiSelectUseCases ? u.label : "", kind: u.kind, ...rosterFor(u) }))
117
+ .filter((s) => s.options.length > 0);
118
+ const allModels = modelSections.flatMap((s) => s.options);
119
+ // A pick that isn't in the current list (e.g. the edit model after leaving
120
+ // edit mode) is shown as unpicked and never submitted. With no list at
121
+ // all, the app's pick is passed through as given. With nothing picked
122
+ // from a suggested list (and auto-select off), its first model is the pick.
123
+ const validPicks = allModels.length > 0 ? rawPickedIds.filter((id) => allModels.some((m) => m.id === id)) : rawPickedIds;
124
+ const pickedModelIds =
125
+ validPicks.length > 0 || autoSelectModel ? validPicks : modelSections.filter((s) => s.suggested).map((s) => s.options[0]!.id);
126
+ const pickedModels = allModels.filter((m) => pickedModelIds.includes(m.id));
127
+ // No menu that can't change anything: without its handler, the Model menu hides (the default pick still runs).
128
+ const canPickModel = Boolean(multiModel ? props.onModelIdsChange : props.onModelChange);
129
+ const modelTriggerLabel =
130
+ pickedModels.length > 1
131
+ ? `${pickedModels.length} models`
132
+ : (pickedModels[0]?.label ?? (autoSelectModel ? "Auto-select" : "Select models"));
133
+
134
+ // An edit keeps the source image's shape.
135
+ const showAspectRatio = activeUseCases.some((u) => !u.edit && (u.kind === "image" || u.kind === "video"));
136
+ // Only ratios every model that might run takes: the picked ones, or with
137
+ // none picked (or auto-select on) the whole list, so whichever runs can
138
+ // honor it. A model with no `aspectRatios` sets its own shape: no menu.
139
+ const ratioModels = pickedModels.length > 0 && !autoSelectModel ? pickedModels : allModels;
140
+ const sharedRatios =
141
+ ratioModels.reduce<string[] | null>((shared, m) => (shared ?? m.aspectRatios ?? []).filter((r) => m.aspectRatios?.includes(r)), null) ??
142
+ [];
143
+ const aspectOptions = aspectRatios
144
+ ? aspectRatios.filter((o) => sharedRatios.includes(o.value))
145
+ : sharedRatios.map((r) => ({ value: r, label: r }));
146
+ // A pick the current models don't take is shown as unpicked and never sent.
147
+ const offeredAspect = showAspectRatio && aspectOptions.some((o) => o.value === aspectRatio) ? aspectRatio : null;
148
+ const selectedAspect = aspectOptions.find((o) => o.value === offeredAspect) ?? null;
149
+
150
+ const selections = (): ComposerSelection[] =>
151
+ activeUseCases.map((u) => ({
152
+ useCase: u,
153
+ models: rosterFor(u).options,
154
+ modelIds: multiSelectUseCases ? pickedModelIds.filter((id) => rosterFor(u).options.some((m) => m.id === id)) : pickedModelIds,
155
+ }));
156
+
157
+ return {
158
+ pickedUseCases,
159
+ activeUseCases,
160
+ editing,
161
+ isSubmitDisabled,
162
+ placeholder,
163
+ showStop,
164
+ multiModel,
165
+ modelSections,
166
+ pickedModelIds,
167
+ canPickModel,
168
+ modelTriggerLabel,
169
+ showAspectRatio,
170
+ aspectOptions,
171
+ offeredAspect,
172
+ selectedAspect,
173
+ /** The model ids after a row click: the one picked, or with multi-select that one toggled. */
174
+ toggledModelIds: (id: string) => (modelIds.includes(id) ? modelIds.filter((m) => m !== id) : [...modelIds, id]),
175
+ /** Everything a submit sends. */
176
+ payload: (): ComposerSubmitPayload => ({
177
+ value,
178
+ attachments,
179
+ useCase: activeUseCases[0]!,
180
+ modelId: pickedModelIds[0] ?? null,
181
+ aspectRatio: offeredAspect,
182
+ selections: selections(),
183
+ autoSelectModel,
184
+ regions: editing ? regions : [],
185
+ }),
186
+ };
187
+ }
188
+
189
+ export type ComposerView = ReturnType<typeof composerView>;
190
+
191
+ /** The `+` menu items that can work: "Add media" needs somewhere to put files, the use-case items a handler. */
192
+ export function composerAttachMenuItems(
193
+ actions: ComposerAttachMenuAction[] | undefined,
194
+ { canAddMedia, canSelect }: { canAddMedia: boolean; canSelect: boolean }
195
+ ) {
196
+ return COMPOSER_ATTACH_MENU_ITEMS.filter(
197
+ (item) =>
198
+ (actions ?? COMPOSER_ATTACH_MENU_ITEMS.map((i) => i.action)).includes(item.action) &&
199
+ (item.action === "add-media" ? canAddMedia : canSelect)
200
+ );
201
+ }
202
+
203
+ /**
204
+ * The attachments after picking files: each image, video or audio file
205
+ * added in the order picked. Editing works on one image, so its first image
206
+ * replaces what's there (and with none, every file is unsupported).
207
+ */
208
+ export async function composerAttachmentsFromFiles(
209
+ files: Iterable<File>,
210
+ current: ComposerAttachment[],
211
+ editing: boolean
212
+ ): Promise<{ attachments: ComposerAttachment[] | null; unsupported: string[] }> {
213
+ const list = [...files];
214
+ if (editing) {
215
+ const image = list.find((f) => f.type.startsWith("image/"));
216
+ if (!image) return { attachments: null, unsupported: list.map((f) => f.name) };
217
+ const { media, unsupported } = await readMediaFiles([image]);
218
+ return { attachments: media.length > 0 ? media.map((m) => ({ ...m, id: uniqueId() })) : null, unsupported };
219
+ }
220
+ const { media, unsupported } = await readMediaFiles(list);
221
+ return { attachments: [...current, ...media.map((m) => ({ ...m, id: uniqueId() }))], unsupported };
222
+ }