@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
@@ -24,16 +24,25 @@ export async function downloadMedia(src: string, filename: string): Promise<void
24
24
  }
25
25
  }
26
26
 
27
- /** The native share sheet where there is one, otherwise the media's URL copied to the clipboard. */
28
- export async function shareMedia(src: string): Promise<void> {
27
+ /**
28
+ * The native share sheet where there is one, otherwise the media's URL
29
+ * copied to the clipboard. Says which, so the button can confirm a copy.
30
+ */
31
+ export async function shareMedia(src: string): Promise<"shared" | "copied" | "none"> {
29
32
  if (navigator.share) {
30
33
  try {
31
34
  await navigator.share({ url: src });
35
+ return "shared";
32
36
  } catch {
33
37
  // Dismissed, or not shareable: same as closing a native share sheet.
38
+ return "none";
34
39
  }
35
- } else {
36
- navigator.clipboard?.writeText(src).catch(() => {});
40
+ }
41
+ try {
42
+ await navigator.clipboard.writeText(src);
43
+ return "copied";
44
+ } catch {
45
+ return "none";
37
46
  }
38
47
  }
39
48
 
package/src/style.css CHANGED
@@ -1178,11 +1178,26 @@
1178
1178
  --md-fab-secondary-icon-color: var(--chai-media-analyzer-text);
1179
1179
  }
1180
1180
 
1181
+ /* While analyzing, with `onAbort`: submit becomes stop, same as the Composer's.
1182
+ Before `[data-error]`, so an error still wins. */
1183
+ .chai-media-analyzer__submit[data-stop] {
1184
+ --md-fab-secondary-container-color: var(--chai-color-semantic-warning-subtle);
1185
+ --md-fab-secondary-icon-color: var(--chai-color-semantic-warning);
1186
+ }
1187
+
1181
1188
  .chai-media-analyzer__submit[data-error] {
1182
1189
  --md-fab-secondary-container-color: var(--chai-color-semantic-danger-subtle);
1183
1190
  --md-fab-secondary-icon-color: var(--chai-color-semantic-danger);
1184
1191
  }
1185
1192
 
1193
+ /* What the last attach replaced (one kind per analysis). */
1194
+ .chai-media-analyzer__note {
1195
+ margin: 0;
1196
+ padding: 2px 2px 0;
1197
+ color: var(--chai-media-analyzer-text);
1198
+ font-size: var(--chai-type-size-label-large);
1199
+ }
1200
+
1186
1201
  .chai-media-analyzer__error {
1187
1202
  margin: 0;
1188
1203
  padding: 2px 2px 0;
@@ -1,423 +1,45 @@
1
- import { useCallback, useMemo, useRef, useState } from "react";
2
- import {
3
- buildRegionEditPrompt,
4
- createMockEngine,
5
- createRun,
6
- deriveGenerationUseCase,
7
- cancelResult,
8
- failResult,
9
- isAbortError,
10
- mockEngine,
11
- normalizeGenerationError,
12
- PRECISE_EDIT_MODELS,
13
- resolveResult,
14
- routeModel,
15
- ROUTING_MODEL_ID,
16
- startResult,
17
- streamResult,
18
- type DroppedMedia,
19
- type EditRegion,
20
- type GenerationEngine,
21
- type GenerationUseCase,
22
- type MediaKind,
23
- type ModelOption,
24
- type ModelRoutingInput,
25
- type ModelRoutingResult,
26
- type Request,
27
- type Run,
28
- } from "@chai-ui/core";
29
- import type { ComposerSelection, ComposerSubmitPayload } from "./Composer.js";
30
- import type { EditVersion } from "./EditCard.js";
31
- import { reasoningModelFor, useReasoning, type Reasoning } from "./ChaiProvider.js";
1
+ import { useState, useSyncExternalStore } from "react";
2
+ import { createComposerStore, pickReasoning, type ComposerOptions } from "@chai-ui/core";
3
+ import { useReasoning } from "./ChaiProvider.js";
32
4
  import { useLatest } from "./use-latest.js";
33
5
 
34
- /** A result's `modelId` when nothing was picked: the engine runs its own default model. */
35
- export const DEFAULT_MODEL_ID = "default";
6
+ export { DEFAULT_MODEL_ID } from "@chai-ui/core";
36
7
 
37
- const mockTextEngine = createMockEngine({ outputKind: "text" });
38
-
39
- function defaultEnhancePrompt(value: string, mode?: GenerationUseCase): string {
40
- const subject = mode?.includes("video")
41
- ? "a video-generation"
42
- : mode === "text-to-text" || mode === "vision"
43
- ? "an AI assistant"
44
- : "an image-generation";
45
- return `Rewrite the following ${subject} prompt to be more detailed and specific, keeping the same intent. Reply with only the rewritten prompt, nothing else.\n\n${value}`;
46
- }
47
-
48
- export interface UseComposerOptions {
49
- /** The generation engine a submitted run actually calls. Defaults to `mockEngine`. */
50
- engine?: GenerationEngine;
51
- /**
52
- * Engines per use-case kind, overriding `engine` for that kind — needed
53
- * for multi-use-case select, since one Fal engine outputs one kind, e.g.
54
- * `{ image: createFalEngine({ outputKind: "image" }), video: createFalEngine({ outputKind: "video" }) }`.
55
- */
56
- engineByKind?: Partial<Record<MediaKind, GenerationEngine>>;
57
- /**
58
- * The engine for text requests (a Composer with no use case picked, or a
59
- * text use case). Defaults to `reasoningEngine`, and to a mock text engine
60
- * when neither is set, so a text prompt always gets a reply.
61
- */
62
- textEngine?: GenerationEngine;
63
- /** The model text requests use when none is picked. Defaults to the reasoning model for the attachments' kinds, then the text engine's own default. */
64
- textModel?: string;
65
- /**
66
- * The reasoning engine Enhance calls — deliberately separate from
67
- * `engine`: Fal's own text/vision access is itself an
68
- * OpenRouter proxy, so defaulting Enhance to a Fal `engine` would
69
- * silently double-proxy. Must return text. Set together with
70
- * `reasoningModel` to override the nearest `ChaiProvider`'s pair; with
71
- * neither set here nor a provider, `enhance` stays `undefined`, same
72
- * shape as `Composer`'s own "omit `onEnhance` to hide the button" convention.
73
- */
74
- reasoningEngine?: GenerationEngine;
75
- /** Set with `reasoningEngine` to override the provider's pair; used for every kind (§5.5). */
76
- reasoningModel?: string;
77
- /** Overrides the instruction wrapping a raw prompt for Enhance. Defaults to a generic still/video-aware rewrite instruction. */
78
- enhancePrompt?: (value: string, mode?: GenerationUseCase) => string;
79
- /**
80
- * Auto-select: with `autoSelectModel` on in the payload, the reasoning
81
- * model picks one model per use case at submit, then these fire with the
82
- * pick so the Composer's trigger shows it. Wire them to the same state as
83
- * Composer's `onModelChange` / `onModelIdsChange`.
84
- */
85
- onModelChange?: (modelId: string) => void;
86
- onModelIdsChange?: (modelIds: string[]) => void;
87
- /** Replaces the routing instruction (`buildModelRoutingPrompt` in `@chai-ui/core`). */
88
- routingPrompt?: (input: ModelRoutingInput) => string;
89
- /**
90
- * Fires when auto-select couldn't route and fell back to the first listed
91
- * model: no reasoning model, a failed reasoning call (e.g. a bad key), or
92
- * a reply naming no listed model. The request still runs.
93
- */
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;
104
- onRun?: (run: Run) => void;
105
- onResult?: (run: Run, resultId: string) => void;
106
- onError?: (run: Run, resultId: string) => void;
107
- }
8
+ export type UseComposerOptions = ComposerOptions;
108
9
 
109
10
  /**
110
- * The headless binding the design promises and the package README
111
- * flagged as missing: turns a `Composer`'s `onSubmit` payload into a real
112
- * `Request` → `Run` → `Result` (§6), fanning out across `engine.generate`
113
- * calls and folding each one back into state as it settles — pure
114
- * transitions live in `@chai-ui/core`, this is the thin React binding.
11
+ * The React binding for `createComposerStore` in `@chai-ui/core`: turns a
12
+ * `Composer`'s `onSubmit` payload into a real `Request` → `Run` → `Result`
13
+ * (§6). The behavior lives in core; this keeps its state in React.
115
14
  */
116
15
  export function useComposer(options: UseComposerOptions = {}) {
117
- // One run per use case submitted: a single run unless the Composer has
118
- // `multiSelectUseCases` on. `run` stays as the first, for the common case.
119
- const [runs, setRuns] = useState<Run[]>([]);
120
- const [enhancing, setEnhancing] = useState(false);
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
- // Numbers each image, so its original version has its own id and EditCard
132
- // starts it clean (no half-drawn region from the last image).
133
- const [editImageCount, setEditImageCount] = useState(0);
134
- if ((editImage?.src ?? null) !== editSource) {
135
- setEditSource(editImage?.src ?? null);
136
- setEditImageCount((n) => n + 1);
137
- setEditRuns([]);
138
- setRegions([]);
139
- setActiveVersion(0);
140
- }
141
- const versions = useMemo<EditVersion[]>(
142
- () =>
143
- editImage
144
- ? [
145
- { id: `original-${editImageCount}`, src: editImage.src, status: "done", regions: [] },
146
- ...editRuns.flatMap((run) =>
147
- run.results.map((r) => ({
148
- id: r.id,
149
- src: r.output?.src,
150
- from: run.request.attachments[0]?.src,
151
- status: r.status,
152
- regions: (run.request.params.regions as EditRegion[] | undefined) ?? [],
153
- error: r.error?.message,
154
- }))
155
- ),
156
- ]
157
- : [],
158
- [editImage, editRuns, editImageCount]
159
- );
160
- const editRef = useLatest({ versions, activeVersion, editRuns });
161
- // Each submit gets an id, so routing that settles after a newer submit is dropped.
162
- const submitId = useRef(0);
163
- // Aborts the current submit's engine calls: on `cancel()`, and when a newer
164
- // submit replaces it (its results would be dropped anyway).
165
- const controller = useRef<AbortController | null>(null);
166
-
167
- // `submit` below reads through a ref so it can stay referentially stable (empty dep array — safe to
168
- // pass to a memoized child) while always seeing this render's latest
169
- // options, not the ones captured when `submit` was first created.
170
- const optionsRef = useLatest(options);
171
-
172
- // The hook's own pair wins, but only as a pair: half of one never mixes
173
- // with half of the provider's (e.g. a hook engine with the provider's model).
174
16
  const provided = useReasoning();
175
- const { reasoningEngine, reasoningModel } = options;
176
- const reasoning = useMemo<Reasoning | undefined>(
177
- () =>
178
- reasoningEngine || reasoningModel
179
- ? reasoningEngine && reasoningModel
180
- ? { engine: reasoningEngine, model: reasoningModel, modelByKind: {} }
181
- : undefined
182
- : provided,
183
- [reasoningEngine, reasoningModel, provided]
184
- );
185
- const reasoningRef = useLatest(reasoning);
186
-
187
- // One request per selection. An edit runs on the image version showing
188
- // in the EditCard (the original until an edit lands) and carries its regions.
189
- const buildRequest = useCallback(
190
- (payload: ComposerSubmitPayload, selection: ComposerSelection): { request: Request; engine: GenerationEngine; isEdit: boolean } => {
191
- const opts = optionsRef.current;
192
- const reasoning = reasoningRef.current;
193
- const isText = selection.useCase.kind === "text";
194
- const isEdit = Boolean(selection.useCase.edit && opts.editImage?.kind === "image");
195
- const { versions, activeVersion } = editRef.current;
196
- const shown = versions[activeVersion];
197
- const editFrom = shown?.status === "done" && shown.src ? shown.src : opts.editImage?.src;
198
- const attachments: DroppedMedia[] = isEdit && editFrom ? [{ src: editFrom, kind: "image" }] : payload.attachments;
199
- // Nothing picked: text uses the builder's text model when there is
200
- // one; otherwise the engine runs its own default. Never a silent no-op.
201
- const fallback = isText ? (opts.textModel ?? (reasoning && reasoningModelFor(reasoning, attachments))) : undefined;
202
- const params: Record<string, unknown> = payload.aspectRatio && !isEdit ? { aspectRatio: payload.aspectRatio } : {};
203
- if (isEdit) params.regions = payload.regions ?? [];
204
- const request: Request = {
205
- mode: deriveGenerationUseCase(selection.useCase, attachments),
206
- prompt: payload.value,
207
- attachments,
208
- modelIds: selection.modelIds.length > 0 ? selection.modelIds : [fallback ?? DEFAULT_MODEL_ID],
209
- params,
210
- };
211
- const engine = isText
212
- ? (opts.textEngine ?? reasoning?.engine ?? mockTextEngine)
213
- : (opts.engineByKind?.[selection.useCase.kind] ?? opts.engine ?? mockEngine);
214
- return { request, engine, isEdit };
215
- },
216
- [optionsRef, reasoningRef, editRef]
217
- );
218
-
219
- // Edit placeholders from routing waits, dropped from the history when the
220
- // next real runs start (including ones a newer submit abandoned).
221
- const placeholderIds = useRef(new Set<string>());
222
-
223
- const start = useCallback((payload: ComposerSubmitPayload, signal: AbortSignal) => {
224
- const opts = optionsRef.current;
225
- const newRuns = payload.selections.map((selection) => {
226
- const { request, engine, isEdit } = buildRequest(payload, selection);
227
- return { run: createRun(request), engine, isEdit, models: selection.models ?? [] };
228
- });
229
-
230
- setRuns(newRuns.map((r) => r.run));
231
- const newEditRuns = newRuns.filter((r) => r.isEdit).map((r) => r.run);
232
- const placeholders = placeholderIds.current;
233
- const kept = editRef.current.editRuns.filter((r) => !placeholders.has(r.id));
234
- if (newEditRuns.length > 0) {
235
- // Show the first new version, and start the next edit with no regions:
236
- // these ones now belong to the versions they made.
237
- setEditRuns([...kept, ...newEditRuns]);
238
- setActiveVersion(1 + kept.reduce((n, r) => n + r.results.length, 0));
239
- setRegions([]);
240
- } else if (placeholders.size > 0) {
241
- setEditRuns(kept);
242
- }
243
- placeholderIds.current = new Set();
244
-
245
- // Results settle independently and out of order — each update only
246
- // ever touches its own run, and only if that run is still current. A
247
- // run from an earlier submit is no longer in state (and its calls were
248
- // aborted), so a late result can't clobber the newer ones.
249
- const update = (runId: string, fn: (run: Run) => Run) => {
250
- const apply = (prev: Run[]) => (prev.some((r) => r.id === runId) ? prev.map((r) => (r.id === runId ? fn(r) : r)) : prev);
251
- setRuns(apply);
252
- setEditRuns(apply);
253
- };
254
-
255
- for (const { run: newRun, engine, isEdit, models } of newRuns) {
256
- opts.onRun?.(newRun);
257
- for (const result of newRun.results) {
258
- update(newRun.id, (r) => startResult(r, result.id));
259
- // An edit's prompt depends on the model: one with box support gets
260
- // boxes, any other gets the regions described in words.
261
- const regions = (newRun.request.params.regions as EditRegion[] | undefined) ?? [];
262
- const model =
263
- models.find((m) => m.id === result.modelId) ?? PRECISE_EDIT_MODELS.find((m) => m.id === result.modelId);
264
- const prompt = isEdit
265
- ? (opts.editPrompt ?? defaultEditPrompt)({ prompt: newRun.request.prompt, regions, model })
266
- : newRun.request.prompt;
267
- void engine
268
- .generate({
269
- modelId: result.modelId === DEFAULT_MODEL_ID ? undefined : result.modelId,
270
- prompt,
271
- attachments: newRun.request.attachments,
272
- aspectRatio: newRun.request.params.aspectRatio as string | undefined,
273
- // Text engines that stream fill `output` in while still "running".
274
- onText: (text) => update(newRun.id, (r) => streamResult(r, result.id, text)),
275
- signal,
276
- })
277
- .then(
278
- ({ src, kind, usage, evaluations }) => {
279
- update(newRun.id, (r) => resolveResult(r, result.id, { src, kind }, usage, evaluations));
280
- optionsRef.current.onResult?.(newRun, result.id);
281
- },
282
- (err) => {
283
- if (isAbortError(err)) return update(newRun.id, (r) => cancelResult(r, result.id));
284
- update(newRun.id, (r) => failResult(r, result.id, normalizeGenerationError(err)));
285
- optionsRef.current.onError?.(newRun, result.id);
286
- }
287
- );
288
- }
289
- }
290
- }, [optionsRef, buildRequest, editRef]);
291
-
292
- const submit = useCallback(
293
- (payload: ComposerSubmitPayload) => {
294
- const id = ++submitId.current;
295
- controller.current?.abort();
296
- const { signal } = (controller.current = new AbortController());
297
- const toRoute = payload.autoSelectModel && payload.selections.some((sel) => sel.models.length > 0);
298
- if (!toRoute) {
299
- setRouting(false);
300
- start(payload, signal);
301
- return;
302
- }
303
-
304
- const opts = optionsRef.current;
305
- const reasoning = reasoningRef.current;
306
- if (!reasoning) warnNoReasoningOnce();
307
- setRouting(true);
308
- // Runs exist from the moment of submit, so a result card shows progress
309
- // while the reasoning model chooses. Their results read "Choosing model".
310
- const placeholders = payload.selections.map((sel) => {
311
- const { request, isEdit } = buildRequest(payload, sel);
312
- const modelIds = sel.models.length > 0 ? [ROUTING_MODEL_ID] : request.modelIds;
313
- return { run: createRun({ ...request, modelIds }), isEdit };
314
- });
315
- setRuns(placeholders.map((p) => p.run));
316
- const editPlaceholders = placeholders.filter((p) => p.isEdit).map((p) => p.run);
317
- if (editPlaceholders.length > 0) {
318
- const editRuns = editRef.current.editRuns.filter((r) => !placeholderIds.current.has(r.id));
319
- for (const r of editPlaceholders) placeholderIds.current.add(r.id);
320
- setEditRuns([...editRuns, ...editPlaceholders]);
321
- setActiveVersion(1 + editRuns.reduce((n, r) => n + r.results.length, 0));
322
- }
323
- void Promise.all(
324
- payload.selections.map((sel) =>
325
- sel.models.length === 0
326
- ? Promise.resolve(null)
327
- : routeModel({
328
- prompt: payload.value,
329
- outputKind: sel.useCase.kind,
330
- attachmentKinds: payload.attachments.map((a) => a.kind),
331
- attachments: payload.attachments,
332
- models: sel.models,
333
- engine: reasoning?.engine,
334
- model: reasoning && reasoningModelFor(reasoning, payload.attachments),
335
- routingPrompt: opts.routingPrompt,
336
- signal,
337
- })
338
- )
339
- ).then((results) => {
340
- if (id !== submitId.current || signal.aborted) return;
341
- setRouting(false);
342
- const latest = optionsRef.current;
343
- for (const r of results) if (r?.status === "fallback") latest.onRoutingFallback?.(r);
344
- const routedIds = results.flatMap((r) => (r ? [r.modelId] : []));
345
- if (latest.onModelIdsChange) latest.onModelIdsChange(routedIds);
346
- else if (routedIds[0]) latest.onModelChange?.(routedIds[0]);
347
- start({
348
- ...payload,
349
- modelId: routedIds[0] ?? payload.modelId,
350
- selections: payload.selections.map((sel, i) => ({
351
- ...sel,
352
- modelIds: results[i] ? [results[i]!.modelId] : sel.modelIds,
353
- })),
354
- }, signal);
355
- });
356
- },
357
- [start, buildRequest, optionsRef, reasoningRef, editRef]
17
+ // `submit` and friends are stable but read the newest options when they run.
18
+ const latest = useLatest({ options, provided });
19
+ const [store] = useState(() =>
20
+ createComposerStore(
21
+ () => latest.current.options,
22
+ () => latest.current.provided
23
+ )
358
24
  );
359
-
360
- /**
361
- * Stops the current submit: wire it to Composer's `onAbort`. Its
362
- * unfinished results turn `"cancelled"` right away, and each engine
363
- * cancels at the provider where it can (best effort: some providers keep
364
- * running, and billing, once a request has started).
365
- */
366
- const cancel = useCallback(() => {
367
- submitId.current += 1;
368
- controller.current?.abort();
369
- controller.current = null;
370
- setRouting(false);
371
- const cancelAll = (prev: Run[]) => prev.map((run) => run.results.reduce((acc, r) => cancelResult(acc, r.id), run));
372
- setRuns(cancelAll);
373
- setEditRuns(cancelAll);
374
- }, []);
375
-
376
- const run = runs[0] ?? null;
377
-
378
- // `undefined` (not a no-op function) when unconfigured, so a consumer
379
- // can write `onEnhance={enhance}` directly — matching how `Composer`
380
- // itself treats an omitted `onEnhance` as "hide the button entirely",
381
- // rather than needing an `enhance ? enhance : undefined` at every call site.
382
- const enhance = useMemo(() => {
383
- if (!reasoning) return undefined;
384
- return async (value: string) => {
385
- setEnhancing(true);
386
- try {
387
- const prompt = (options.enhancePrompt ?? defaultEnhancePrompt)(value, run?.request.mode);
388
- const { src } = await reasoning.engine.generate({ modelId: reasoning.model, prompt, attachments: [] });
389
- return src;
390
- } finally {
391
- setEnhancing(false);
392
- }
393
- };
394
- }, [reasoning, options.enhancePrompt, run?.request.mode]);
395
-
396
- /** Props for an `EditCard` (spread them in), and `regions` for `Composer`. `null` when there's no `editImage`. */
397
- const edit = editImage
398
- ? {
399
- versions,
400
- activeVersion: Math.min(activeVersion, versions.length - 1),
401
- onActiveVersionChange: setActiveVersion,
402
- regions,
403
- onRegionsChange: setRegions,
404
- }
405
- : null;
406
-
407
- return { run, runs, submit, cancel, enhance, enhancing, routing, edit };
408
- }
409
-
410
- function defaultEditPrompt({ prompt, regions, model }: { prompt: string; regions: EditRegion[]; model?: ModelOption }): string {
411
- return buildRegionEditPrompt({ prompt, regions, format: model?.regionFormat });
25
+ const state = useSyncExternalStore(store.subscribe, store.getState, store.getState);
26
+
27
+ // `undefined` (not a no-op function) without reasoning, so a consumer can
28
+ // write `onEnhance={enhance}` directly — matching how `Composer` treats an
29
+ // omitted `onEnhance` as "hide the button".
30
+ const enhance = pickReasoning(options, provided) ? store.enhance : undefined;
31
+
32
+ return {
33
+ run: state.runs[0] ?? null,
34
+ runs: state.runs,
35
+ submit: store.submit,
36
+ cancel: store.cancel,
37
+ enhance,
38
+ enhancing: state.enhancing,
39
+ routing: state.routing,
40
+ /** Props for an `EditCard` (spread them in), and `regions` for `Composer`. `null` when there's no `editImage`. */
41
+ edit: store.edit(state, options.editImage),
42
+ };
412
43
  }
413
44
 
414
45
  export type UseComposerReturn = ReturnType<typeof useComposer>;
415
-
416
- let warnedNoReasoning = false;
417
- function warnNoReasoningOnce() {
418
- if (warnedNoReasoning) return;
419
- warnedNoReasoning = true;
420
- console.warn(
421
- "[chai-ui] Auto-select model is on, but there's no reasoning model, so each request runs on the first listed model. Wrap your app in <ChaiProvider> to route with one."
422
- );
423
- }