@chai-ui/react 0.6.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/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 +3 -58
- package/dist/Composer.d.ts.map +1 -1
- package/dist/Composer.js +2 -5
- 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.map +1 -1
- package/dist/MediaAnalyzer.d.ts +2 -13
- package/dist/MediaAnalyzer.d.ts.map +1 -1
- package/dist/MediaAnalyzer.js.map +1 -1
- 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 +11 -64
- package/src/EditCard.tsx +3 -17
- package/src/MediaAnalyzer.tsx +3 -15
- package/src/useComposer.ts +33 -411
- package/src/useMediaAnalyzer.ts +15 -146
package/src/useComposer.ts
CHANGED
|
@@ -1,423 +1,45 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
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
|
-
|
|
35
|
-
export const DEFAULT_MODEL_ID = "default";
|
|
6
|
+
export { DEFAULT_MODEL_ID } from "@chai-ui/core";
|
|
36
7
|
|
|
37
|
-
|
|
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
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
}
|
package/src/useMediaAnalyzer.ts
CHANGED
|
@@ -1,157 +1,26 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
DEFAULT_MEDIA_ANALYSIS_PROMPT_LENGTH,
|
|
5
|
-
isAbortError,
|
|
6
|
-
MEDIA_ANALYSIS_PROMPTS,
|
|
7
|
-
normalizeGenerationError,
|
|
8
|
-
routeModel,
|
|
9
|
-
type GenerationEngine,
|
|
10
|
-
type ModelRoutingInput,
|
|
11
|
-
type ModelRoutingResult,
|
|
12
|
-
} from "@chai-ui/core";
|
|
13
|
-
import type { MediaAnalyzerSubmitPayload } from "./MediaAnalyzer.js";
|
|
14
|
-
import { reasoningModelFor, useReasoning } from "./ChaiProvider.js";
|
|
1
|
+
import { useState, useSyncExternalStore } from "react";
|
|
2
|
+
import { createMediaAnalyzerStore, type MediaAnalyzerOptions } from "@chai-ui/core";
|
|
3
|
+
import { useReasoning } from "./ChaiProvider.js";
|
|
15
4
|
import { useLatest } from "./use-latest.js";
|
|
16
5
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
export interface UseMediaAnalyzerOptions {
|
|
20
|
-
/**
|
|
21
|
-
* Overrides the nearest `ChaiProvider`'s reasoning engine. With neither,
|
|
22
|
-
* a mock text engine answers, so a first render works with no key.
|
|
23
|
-
*/
|
|
24
|
-
engine?: GenerationEngine;
|
|
25
|
-
/** The model when the person hasn't picked one. Defaults to the provider's reasoning model for the media's kind. */
|
|
26
|
-
model?: string;
|
|
27
|
-
onResult?: (prompt: string) => void;
|
|
28
|
-
onError?: (message: string) => void;
|
|
29
|
-
/** Auto-select: fires with the routed model so the trigger shows it. Wire it to `MediaAnalyzer`'s `onModelChange` state. */
|
|
30
|
-
onModelChange?: (modelId: string) => void;
|
|
31
|
-
/** Replaces the routing instruction (`buildModelRoutingPrompt` in `@chai-ui/core`). */
|
|
32
|
-
routingPrompt?: (input: ModelRoutingInput) => string;
|
|
33
|
-
/** Fires when auto-select fell back to the first listed model (no reasoning model, a failed call, or an unlisted reply). Analysis still runs. */
|
|
34
|
-
onRoutingFallback?: (result: Extract<ModelRoutingResult, { status: "fallback" }>) => void;
|
|
35
|
-
}
|
|
6
|
+
export type UseMediaAnalyzerOptions = MediaAnalyzerOptions;
|
|
36
7
|
|
|
37
8
|
/**
|
|
38
|
-
* The binding
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* generated prompt. `prompt` fills in as the text streams.
|
|
9
|
+
* The React binding for `createMediaAnalyzerStore` in `@chai-ui/core`, behind
|
|
10
|
+
* `MediaAnalyzer`'s `onSubmit`: returns the generated prompt, which fills in
|
|
11
|
+
* as the text streams.
|
|
42
12
|
*/
|
|
43
13
|
export function useMediaAnalyzer(options: UseMediaAnalyzerOptions = {}) {
|
|
44
|
-
const [analyzing, setAnalyzing] = useState(false);
|
|
45
|
-
const [error, setError] = useState<string | null>(null);
|
|
46
|
-
const [prompt, setPrompt] = useState<string | null>(null);
|
|
47
|
-
|
|
48
14
|
const provided = useReasoning();
|
|
49
15
|
const latest = useLatest({ options, provided });
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
controller.current?.abort();
|
|
59
|
-
const { signal } = (controller.current = new AbortController());
|
|
60
|
-
setError(null);
|
|
61
|
-
setPrompt(null);
|
|
62
|
-
|
|
63
|
-
const kind = payload.attachments[0]?.kind;
|
|
64
|
-
if (kind !== "image" && kind !== "video" && kind !== "audio") {
|
|
65
|
-
const message = "Attach an image, video, or audio file to analyze.";
|
|
66
|
-
setError(message);
|
|
67
|
-
opts.onError?.(message);
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const engine = opts.engine ?? reasoning?.engine ?? mockTextEngine;
|
|
72
|
-
const defaultModel = opts.model ?? (reasoning ? reasoningModelFor(reasoning, payload.attachments) : undefined);
|
|
73
|
-
const instruction = MEDIA_ANALYSIS_PROMPTS[kind][payload.promptLength ?? DEFAULT_MEDIA_ANALYSIS_PROMPT_LENGTH];
|
|
74
|
-
const current = () => id === callId.current;
|
|
75
|
-
const autoSelect = payload.autoSelectModel && payload.models.length > 0;
|
|
76
|
-
|
|
77
|
-
const analyze = (modelId: string | undefined) =>
|
|
78
|
-
engine
|
|
79
|
-
.generate({
|
|
80
|
-
modelId,
|
|
81
|
-
prompt: instruction,
|
|
82
|
-
attachments: payload.attachments,
|
|
83
|
-
onText: (text) => current() && setPrompt(text),
|
|
84
|
-
signal,
|
|
85
|
-
})
|
|
86
|
-
.then(
|
|
87
|
-
({ src }) => {
|
|
88
|
-
if (!current()) return;
|
|
89
|
-
setPrompt(src);
|
|
90
|
-
latest.current.options.onResult?.(src);
|
|
91
|
-
},
|
|
92
|
-
(err) => {
|
|
93
|
-
if (!current() || isAbortError(err)) return;
|
|
94
|
-
const { message } = normalizeGenerationError(err);
|
|
95
|
-
setError(message);
|
|
96
|
-
latest.current.options.onError?.(message);
|
|
97
|
-
}
|
|
98
|
-
)
|
|
99
|
-
.finally(() => current() && setAnalyzing(false));
|
|
100
|
-
|
|
101
|
-
setAnalyzing(true);
|
|
102
|
-
if (!autoSelect) {
|
|
103
|
-
void analyze(payload.modelId ?? defaultModel);
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
// Auto-select: the reasoning model looks at the media and picks the
|
|
107
|
-
// analysis model first. A routing problem falls back, never blocks.
|
|
108
|
-
void routeModel({
|
|
109
|
-
prompt: "",
|
|
110
|
-
outputKind: "text",
|
|
111
|
-
attachmentKinds: payload.attachments.map((a) => a.kind),
|
|
112
|
-
attachments: payload.attachments,
|
|
113
|
-
models: payload.models,
|
|
114
|
-
engine: opts.engine ?? reasoning?.engine,
|
|
115
|
-
model: defaultModel,
|
|
116
|
-
routingPrompt: opts.routingPrompt,
|
|
117
|
-
signal,
|
|
118
|
-
}).then((routed) => {
|
|
119
|
-
if (!current() || signal.aborted) return;
|
|
120
|
-
const latestOpts = latest.current.options;
|
|
121
|
-
if (routed.status === "fallback") {
|
|
122
|
-
if (routed.reason === "no-reasoning") warnNoReasoningOnce();
|
|
123
|
-
latestOpts.onRoutingFallback?.(routed);
|
|
124
|
-
}
|
|
125
|
-
latestOpts.onModelChange?.(routed.modelId);
|
|
126
|
-
return analyze(routed.modelId);
|
|
127
|
-
});
|
|
128
|
-
}, [latest]);
|
|
129
|
-
|
|
130
|
-
/** Stops the analysis in flight (cancelling at the provider where it can), keeping any text that already streamed in. */
|
|
131
|
-
const cancel = useCallback(() => {
|
|
132
|
-
callId.current += 1;
|
|
133
|
-
controller.current?.abort();
|
|
134
|
-
controller.current = null;
|
|
135
|
-
setAnalyzing(false);
|
|
136
|
-
}, []);
|
|
137
|
-
|
|
138
|
-
/** Stops anything in flight and clears the prompt and error, e.g. when the person changes their media. */
|
|
139
|
-
const reset = useCallback(() => {
|
|
140
|
-
cancel();
|
|
141
|
-
setError(null);
|
|
142
|
-
setPrompt(null);
|
|
143
|
-
}, [cancel]);
|
|
144
|
-
|
|
145
|
-
return { submit, analyzing, error, prompt, cancel, reset };
|
|
16
|
+
const [store] = useState(() =>
|
|
17
|
+
createMediaAnalyzerStore(
|
|
18
|
+
() => latest.current.options,
|
|
19
|
+
() => latest.current.provided
|
|
20
|
+
)
|
|
21
|
+
);
|
|
22
|
+
const state = useSyncExternalStore(store.subscribe, store.getState, store.getState);
|
|
23
|
+
return { ...state, submit: store.submit, cancel: store.cancel, reset: store.reset };
|
|
146
24
|
}
|
|
147
25
|
|
|
148
26
|
export type UseMediaAnalyzerReturn = ReturnType<typeof useMediaAnalyzer>;
|
|
149
|
-
|
|
150
|
-
let warnedNoReasoning = false;
|
|
151
|
-
function warnNoReasoningOnce() {
|
|
152
|
-
if (warnedNoReasoning) return;
|
|
153
|
-
warnedNoReasoning = true;
|
|
154
|
-
console.warn(
|
|
155
|
-
"[chai-ui] Auto-select model is on, but there's no reasoning model, so analysis runs on the first listed model. Wrap your app in <ChaiProvider> to route with one."
|
|
156
|
-
);
|
|
157
|
-
}
|