@chai-ui/react 0.1.0 → 0.2.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 (55) hide show
  1. package/DESIGN.md +6 -1
  2. package/README.md +69 -6
  3. package/dist/Composer.d.ts +19 -2
  4. package/dist/Composer.d.ts.map +1 -1
  5. package/dist/Composer.js +36 -10
  6. package/dist/Composer.js.map +1 -1
  7. package/dist/EditCard.d.ts +52 -0
  8. package/dist/EditCard.d.ts.map +1 -0
  9. package/dist/EditCard.js +360 -0
  10. package/dist/EditCard.js.map +1 -0
  11. package/dist/ResultCard.d.ts +2 -1
  12. package/dist/ResultCard.d.ts.map +1 -1
  13. package/dist/ResultCard.js +19 -63
  14. package/dist/ResultCard.js.map +1 -1
  15. package/dist/icons.d.ts +27 -0
  16. package/dist/icons.d.ts.map +1 -0
  17. package/dist/icons.js +50 -0
  18. package/dist/icons.js.map +1 -0
  19. package/dist/index.d.ts +3 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +2 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/media-actions.d.ts +15 -0
  24. package/dist/media-actions.d.ts.map +1 -0
  25. package/dist/media-actions.js +47 -0
  26. package/dist/media-actions.js.map +1 -0
  27. package/dist/page-slide.d.ts +32 -0
  28. package/dist/page-slide.d.ts.map +1 -0
  29. package/dist/page-slide.js +43 -0
  30. package/dist/page-slide.js.map +1 -0
  31. package/dist/primitives/Pagination.d.ts +3 -1
  32. package/dist/primitives/Pagination.d.ts.map +1 -1
  33. package/dist/primitives/Pagination.js +2 -2
  34. package/dist/primitives/Pagination.js.map +1 -1
  35. package/dist/regions.d.ts +3 -0
  36. package/dist/regions.d.ts.map +1 -0
  37. package/dist/regions.js +7 -0
  38. package/dist/regions.js.map +1 -0
  39. package/dist/style.css +507 -82
  40. package/dist/useComposer.d.ts +22 -1
  41. package/dist/useComposer.d.ts.map +1 -1
  42. package/dist/useComposer.js +126 -24
  43. package/dist/useComposer.js.map +1 -1
  44. package/package.json +3 -3
  45. package/src/Composer.tsx +101 -13
  46. package/src/EditCard.tsx +688 -0
  47. package/src/ResultCard.tsx +60 -125
  48. package/src/icons.tsx +115 -0
  49. package/src/index.ts +8 -0
  50. package/src/media-actions.ts +45 -0
  51. package/src/page-slide.tsx +82 -0
  52. package/src/primitives/Pagination.tsx +4 -2
  53. package/src/regions.ts +7 -0
  54. package/src/style.css +507 -82
  55. package/src/useComposer.ts +147 -18
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useMemo, useRef, useState } from "react";
2
2
  import {
3
+ buildRegionEditPrompt,
3
4
  createMockEngine,
4
5
  createRun,
5
6
  deriveGenerationUseCase,
@@ -8,19 +9,25 @@ import {
8
9
  isAbortError,
9
10
  mockEngine,
10
11
  normalizeGenerationError,
12
+ PRECISE_EDIT_MODELS,
11
13
  resolveResult,
12
14
  routeModel,
15
+ ROUTING_MODEL_ID,
13
16
  startResult,
14
17
  streamResult,
18
+ type DroppedMedia,
19
+ type EditRegion,
15
20
  type GenerationEngine,
16
21
  type GenerationUseCase,
17
22
  type MediaKind,
23
+ type ModelOption,
18
24
  type ModelRoutingInput,
19
25
  type ModelRoutingResult,
20
26
  type Request,
21
27
  type Run,
22
28
  } from "@chai-ui/core";
23
- import type { ComposerSubmitPayload } from "./Composer.js";
29
+ import type { ComposerSelection, ComposerSubmitPayload } from "./Composer.js";
30
+ import type { EditVersion } from "./EditCard.js";
24
31
  import { reasoningModelFor, useReasoning, type Reasoning } from "./ChaiProvider.js";
25
32
  import { useLatest } from "./use-latest.js";
26
33
 
@@ -85,6 +92,15 @@ export interface UseComposerOptions {
85
92
  * a reply naming no listed model. The request still runs.
86
93
  */
87
94
  onRoutingFallback?: (result: Extract<ModelRoutingResult, { status: "fallback" }>) => void;
95
+ /**
96
+ * The image to edit, when the Composer's use case is an edit (e.g.
97
+ * `EDIT_IMAGE_USE_CASE`) and an image is attached. While it's set, the hook
98
+ * returns `edit` for an `EditCard`, and each edit's results become new
99
+ * versions of the image. Changing it to another image starts over.
100
+ */
101
+ editImage?: DroppedMedia | null;
102
+ /** Replaces how an edit's prompt and regions become the model's prompt. Defaults to `buildRegionEditPrompt` in `@chai-ui/core`, in the model's `regionFormat`. */
103
+ editPrompt?: (args: { prompt: string; regions: EditRegion[]; model?: ModelOption }) => string;
88
104
  onRun?: (run: Run) => void;
89
105
  onResult?: (run: Run, resultId: string) => void;
90
106
  onError?: (run: Run, resultId: string) => void;
@@ -103,6 +119,41 @@ export function useComposer(options: UseComposerOptions = {}) {
103
119
  const [runs, setRuns] = useState<Run[]>([]);
104
120
  const [enhancing, setEnhancing] = useState(false);
105
121
  const [routing, setRouting] = useState(false);
122
+
123
+ // Edit mode. Each edit's runs are kept (not replaced by the next submit),
124
+ // since every result is a version of the image the person can page back to.
125
+ const editImage = options.editImage?.kind === "image" ? options.editImage : null;
126
+ const [editRuns, setEditRuns] = useState<Run[]>([]);
127
+ const [regions, setRegions] = useState<EditRegion[]>([]);
128
+ const [activeVersion, setActiveVersion] = useState(0);
129
+ // Another image starts over. Reset while rendering, not in an effect (the rules of React).
130
+ const [editSource, setEditSource] = useState<string | null>(editImage?.src ?? null);
131
+ if ((editImage?.src ?? null) !== editSource) {
132
+ setEditSource(editImage?.src ?? null);
133
+ setEditRuns([]);
134
+ setRegions([]);
135
+ setActiveVersion(0);
136
+ }
137
+ const versions = useMemo<EditVersion[]>(
138
+ () =>
139
+ editImage
140
+ ? [
141
+ { id: "original", src: editImage.src, status: "done", regions: [] },
142
+ ...editRuns.flatMap((run) =>
143
+ run.results.map((r) => ({
144
+ id: r.id,
145
+ src: r.output?.src,
146
+ from: run.request.attachments[0]?.src,
147
+ status: r.status,
148
+ regions: (run.request.params.regions as EditRegion[] | undefined) ?? [],
149
+ error: r.error?.message,
150
+ }))
151
+ ),
152
+ ]
153
+ : [],
154
+ [editImage, editRuns]
155
+ );
156
+ const editRef = useLatest({ versions, activeVersion, editRuns });
106
157
  // Each submit gets an id, so routing that settles after a newer submit is dropped.
107
158
  const submitId = useRef(0);
108
159
  // Aborts the current submit's engine calls: on `cancel()`, and when a newer
@@ -129,44 +180,90 @@ export function useComposer(options: UseComposerOptions = {}) {
129
180
  );
130
181
  const reasoningRef = useLatest(reasoning);
131
182
 
132
- const start = useCallback((payload: ComposerSubmitPayload, signal: AbortSignal) => {
133
- const opts = optionsRef.current;
134
- const reasoning = reasoningRef.current;
135
- const newRuns = payload.selections.map((selection) => {
183
+ // One request per selection. An edit runs on the image version showing
184
+ // in the EditCard (the original until an edit lands) and carries its regions.
185
+ const buildRequest = useCallback(
186
+ (payload: ComposerSubmitPayload, selection: ComposerSelection): { request: Request; engine: GenerationEngine; isEdit: boolean } => {
187
+ const opts = optionsRef.current;
188
+ const reasoning = reasoningRef.current;
136
189
  const isText = selection.useCase.kind === "text";
190
+ const isEdit = Boolean(selection.useCase.edit && opts.editImage?.kind === "image");
191
+ const { versions, activeVersion } = editRef.current;
192
+ const shown = versions[activeVersion];
193
+ const editFrom = shown?.status === "done" && shown.src ? shown.src : opts.editImage?.src;
194
+ const attachments: DroppedMedia[] = isEdit && editFrom ? [{ src: editFrom, kind: "image" }] : payload.attachments;
137
195
  // Nothing picked: text uses the builder's text model when there is
138
196
  // one; otherwise the engine runs its own default. Never a silent no-op.
139
- const fallback = isText ? (opts.textModel ?? (reasoning && reasoningModelFor(reasoning, payload.attachments))) : undefined;
197
+ const fallback = isText ? (opts.textModel ?? (reasoning && reasoningModelFor(reasoning, attachments))) : undefined;
198
+ const params: Record<string, unknown> = payload.aspectRatio && !isEdit ? { aspectRatio: payload.aspectRatio } : {};
199
+ if (isEdit) params.regions = payload.regions ?? [];
140
200
  const request: Request = {
141
- mode: deriveGenerationUseCase(selection.useCase, payload.attachments),
201
+ mode: deriveGenerationUseCase(selection.useCase, attachments),
142
202
  prompt: payload.value,
143
- attachments: payload.attachments,
203
+ attachments,
144
204
  modelIds: selection.modelIds.length > 0 ? selection.modelIds : [fallback ?? DEFAULT_MODEL_ID],
145
- params: payload.aspectRatio ? { aspectRatio: payload.aspectRatio } : {},
205
+ params,
146
206
  };
147
207
  const engine = isText
148
208
  ? (opts.textEngine ?? reasoning?.engine ?? mockTextEngine)
149
209
  : (opts.engineByKind?.[selection.useCase.kind] ?? opts.engine ?? mockEngine);
150
- return { run: createRun(request), engine };
210
+ return { request, engine, isEdit };
211
+ },
212
+ [optionsRef, reasoningRef, editRef]
213
+ );
214
+
215
+ // Edit placeholders from routing waits, dropped from the history when the
216
+ // next real runs start (including ones a newer submit abandoned).
217
+ const placeholderIds = useRef(new Set<string>());
218
+
219
+ const start = useCallback((payload: ComposerSubmitPayload, signal: AbortSignal) => {
220
+ const opts = optionsRef.current;
221
+ const newRuns = payload.selections.map((selection) => {
222
+ const { request, engine, isEdit } = buildRequest(payload, selection);
223
+ return { run: createRun(request), engine, isEdit, models: selection.models ?? [] };
151
224
  });
152
225
 
153
226
  setRuns(newRuns.map((r) => r.run));
227
+ const newEditRuns = newRuns.filter((r) => r.isEdit).map((r) => r.run);
228
+ const placeholders = placeholderIds.current;
229
+ const kept = editRef.current.editRuns.filter((r) => !placeholders.has(r.id));
230
+ if (newEditRuns.length > 0) {
231
+ // Show the first new version, and start the next edit with no regions:
232
+ // these ones now belong to the versions they made.
233
+ setEditRuns([...kept, ...newEditRuns]);
234
+ setActiveVersion(1 + kept.reduce((n, r) => n + r.results.length, 0));
235
+ setRegions([]);
236
+ } else if (placeholders.size > 0) {
237
+ setEditRuns(kept);
238
+ }
239
+ placeholderIds.current = new Set();
154
240
 
155
241
  // Results settle independently and out of order — each update only
156
242
  // ever touches its own run, and only if that run is still current. A
157
243
  // run from an earlier submit is no longer in state (and its calls were
158
244
  // aborted), so a late result can't clobber the newer ones.
159
- const update = (runId: string, fn: (run: Run) => Run) =>
160
- setRuns((prev) => (prev.some((r) => r.id === runId) ? prev.map((r) => (r.id === runId ? fn(r) : r)) : prev));
245
+ const update = (runId: string, fn: (run: Run) => Run) => {
246
+ const apply = (prev: Run[]) => (prev.some((r) => r.id === runId) ? prev.map((r) => (r.id === runId ? fn(r) : r)) : prev);
247
+ setRuns(apply);
248
+ setEditRuns(apply);
249
+ };
161
250
 
162
- for (const { run: newRun, engine } of newRuns) {
251
+ for (const { run: newRun, engine, isEdit, models } of newRuns) {
163
252
  opts.onRun?.(newRun);
164
253
  for (const result of newRun.results) {
165
254
  update(newRun.id, (r) => startResult(r, result.id));
255
+ // An edit's prompt depends on the model: one with box support gets
256
+ // boxes, any other gets the regions described in words.
257
+ const regions = (newRun.request.params.regions as EditRegion[] | undefined) ?? [];
258
+ const model =
259
+ models.find((m) => m.id === result.modelId) ?? PRECISE_EDIT_MODELS.find((m) => m.id === result.modelId);
260
+ const prompt = isEdit
261
+ ? (opts.editPrompt ?? defaultEditPrompt)({ prompt: newRun.request.prompt, regions, model })
262
+ : newRun.request.prompt;
166
263
  void engine
167
264
  .generate({
168
265
  modelId: result.modelId === DEFAULT_MODEL_ID ? undefined : result.modelId,
169
- prompt: newRun.request.prompt,
266
+ prompt,
170
267
  attachments: newRun.request.attachments,
171
268
  // Text engines that stream fill `output` in while still "running".
172
269
  onText: (text) => update(newRun.id, (r) => streamResult(r, result.id, text)),
@@ -185,7 +282,7 @@ export function useComposer(options: UseComposerOptions = {}) {
185
282
  );
186
283
  }
187
284
  }
188
- }, [optionsRef, reasoningRef]);
285
+ }, [optionsRef, buildRequest, editRef]);
189
286
 
190
287
  const submit = useCallback(
191
288
  (payload: ComposerSubmitPayload) => {
@@ -203,6 +300,21 @@ export function useComposer(options: UseComposerOptions = {}) {
203
300
  const reasoning = reasoningRef.current;
204
301
  if (!reasoning) warnNoReasoningOnce();
205
302
  setRouting(true);
303
+ // Runs exist from the moment of submit, so a result card shows progress
304
+ // while the reasoning model chooses. Their results read "Choosing model".
305
+ const placeholders = payload.selections.map((sel) => {
306
+ const { request, isEdit } = buildRequest(payload, sel);
307
+ const modelIds = sel.models.length > 0 ? [ROUTING_MODEL_ID] : request.modelIds;
308
+ return { run: createRun({ ...request, modelIds }), isEdit };
309
+ });
310
+ setRuns(placeholders.map((p) => p.run));
311
+ const editPlaceholders = placeholders.filter((p) => p.isEdit).map((p) => p.run);
312
+ if (editPlaceholders.length > 0) {
313
+ const editRuns = editRef.current.editRuns.filter((r) => !placeholderIds.current.has(r.id));
314
+ for (const r of editPlaceholders) placeholderIds.current.add(r.id);
315
+ setEditRuns([...editRuns, ...editPlaceholders]);
316
+ setActiveVersion(1 + editRuns.reduce((n, r) => n + r.results.length, 0));
317
+ }
206
318
  void Promise.all(
207
319
  payload.selections.map((sel) =>
208
320
  sel.models.length === 0
@@ -237,7 +349,7 @@ export function useComposer(options: UseComposerOptions = {}) {
237
349
  }, signal);
238
350
  });
239
351
  },
240
- [start, optionsRef, reasoningRef]
352
+ [start, buildRequest, optionsRef, reasoningRef, editRef]
241
353
  );
242
354
 
243
355
  /**
@@ -251,7 +363,9 @@ export function useComposer(options: UseComposerOptions = {}) {
251
363
  controller.current?.abort();
252
364
  controller.current = null;
253
365
  setRouting(false);
254
- setRuns((prev) => prev.map((run) => run.results.reduce((acc, r) => cancelResult(acc, r.id), run)));
366
+ const cancelAll = (prev: Run[]) => prev.map((run) => run.results.reduce((acc, r) => cancelResult(acc, r.id), run));
367
+ setRuns(cancelAll);
368
+ setEditRuns(cancelAll);
255
369
  }, []);
256
370
 
257
371
  const run = runs[0] ?? null;
@@ -274,7 +388,22 @@ export function useComposer(options: UseComposerOptions = {}) {
274
388
  };
275
389
  }, [reasoning, options.enhancePrompt, run?.request.mode]);
276
390
 
277
- return { run, runs, submit, cancel, enhance, enhancing, routing };
391
+ /** Props for an `EditCard` (spread them in), and `regions` for `Composer`. `null` when there's no `editImage`. */
392
+ const edit = editImage
393
+ ? {
394
+ versions,
395
+ activeVersion: Math.min(activeVersion, versions.length - 1),
396
+ onActiveVersionChange: setActiveVersion,
397
+ regions,
398
+ onRegionsChange: setRegions,
399
+ }
400
+ : null;
401
+
402
+ return { run, runs, submit, cancel, enhance, enhancing, routing, edit };
403
+ }
404
+
405
+ function defaultEditPrompt({ prompt, regions, model }: { prompt: string; regions: EditRegion[]; model?: ModelOption }): string {
406
+ return buildRegionEditPrompt({ prompt, regions, format: model?.regionFormat });
278
407
  }
279
408
 
280
409
  export type UseComposerReturn = ReturnType<typeof useComposer>;