@chai-ui/react 0.1.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.
- package/DESIGN.md +207 -0
- package/LICENSE +21 -0
- package/README.md +265 -0
- package/dist/ChaiProvider.d.ts +49 -0
- package/dist/ChaiProvider.d.ts.map +1 -0
- package/dist/ChaiProvider.js +54 -0
- package/dist/ChaiProvider.js.map +1 -0
- package/dist/Composer.d.ts +169 -0
- package/dist/Composer.d.ts.map +1 -0
- package/dist/Composer.js +389 -0
- package/dist/Composer.js.map +1 -0
- package/dist/MediaAnalyzer.d.ts +102 -0
- package/dist/MediaAnalyzer.d.ts.map +1 -0
- package/dist/MediaAnalyzer.js +250 -0
- package/dist/MediaAnalyzer.js.map +1 -0
- package/dist/ResultCard.d.ts +55 -0
- package/dist/ResultCard.d.ts.map +1 -0
- package/dist/ResultCard.js +409 -0
- package/dist/ResultCard.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/primitives/Pagination.d.ts +19 -0
- package/dist/primitives/Pagination.d.ts.map +1 -0
- package/dist/primitives/Pagination.js +13 -0
- package/dist/primitives/Pagination.js.map +1 -0
- package/dist/primitives/SearchMenu.d.ts +69 -0
- package/dist/primitives/SearchMenu.d.ts.map +1 -0
- package/dist/primitives/SearchMenu.js +88 -0
- package/dist/primitives/SearchMenu.js.map +1 -0
- package/dist/primitives/Toggle.d.ts +9 -0
- package/dist/primitives/Toggle.d.ts.map +1 -0
- package/dist/primitives/Toggle.js +28 -0
- package/dist/primitives/Toggle.js.map +1 -0
- package/dist/primitives/index.d.ts +4 -0
- package/dist/primitives/index.d.ts.map +1 -0
- package/dist/primitives/index.js +4 -0
- package/dist/primitives/index.js.map +1 -0
- package/dist/style.css +1860 -0
- package/dist/use-latest.d.ts +7 -0
- package/dist/use-latest.d.ts.map +1 -0
- package/dist/use-latest.js +17 -0
- package/dist/use-latest.js.map +1 -0
- package/dist/useComposer.d.ts +75 -0
- package/dist/useComposer.d.ts.map +1 -0
- package/dist/useComposer.js +191 -0
- package/dist/useComposer.js.map +1 -0
- package/dist/useMediaAnalyzer.d.ts +37 -0
- package/dist/useMediaAnalyzer.d.ts.map +1 -0
- package/dist/useMediaAnalyzer.js +114 -0
- package/dist/useMediaAnalyzer.js.map +1 -0
- package/package.json +88 -0
- package/src/ChaiProvider.tsx +88 -0
- package/src/Composer.tsx +961 -0
- package/src/MediaAnalyzer.tsx +527 -0
- package/src/ResultCard.tsx +980 -0
- package/src/index.ts +36 -0
- package/src/primitives/Pagination.tsx +41 -0
- package/src/primitives/SearchMenu.tsx +284 -0
- package/src/primitives/Toggle.tsx +43 -0
- package/src/primitives/index.ts +3 -0
- package/src/style.css +1860 -0
- package/src/use-latest.ts +18 -0
- package/src/useComposer.ts +289 -0
- package/src/useMediaAnalyzer.ts +157 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
// useLayoutEffect warns during server rendering on React 18; nothing runs
|
|
4
|
+
// there anyway.
|
|
5
|
+
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A ref that always holds the latest `value`, updated after each render
|
|
9
|
+
* (not during it, per the rules of React). For stable callbacks that read
|
|
10
|
+
* the newest options when they run, e.g. `submit` in an event handler.
|
|
11
|
+
*/
|
|
12
|
+
export function useLatest<T>(value: T) {
|
|
13
|
+
const ref = useRef(value);
|
|
14
|
+
useIsomorphicLayoutEffect(() => {
|
|
15
|
+
ref.current = value;
|
|
16
|
+
});
|
|
17
|
+
return ref;
|
|
18
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
createMockEngine,
|
|
4
|
+
createRun,
|
|
5
|
+
deriveGenerationUseCase,
|
|
6
|
+
cancelResult,
|
|
7
|
+
failResult,
|
|
8
|
+
isAbortError,
|
|
9
|
+
mockEngine,
|
|
10
|
+
normalizeGenerationError,
|
|
11
|
+
resolveResult,
|
|
12
|
+
routeModel,
|
|
13
|
+
startResult,
|
|
14
|
+
streamResult,
|
|
15
|
+
type GenerationEngine,
|
|
16
|
+
type GenerationUseCase,
|
|
17
|
+
type MediaKind,
|
|
18
|
+
type ModelRoutingInput,
|
|
19
|
+
type ModelRoutingResult,
|
|
20
|
+
type Request,
|
|
21
|
+
type Run,
|
|
22
|
+
} from "@chai-ui/core";
|
|
23
|
+
import type { ComposerSubmitPayload } from "./Composer.js";
|
|
24
|
+
import { reasoningModelFor, useReasoning, type Reasoning } from "./ChaiProvider.js";
|
|
25
|
+
import { useLatest } from "./use-latest.js";
|
|
26
|
+
|
|
27
|
+
/** A result's `modelId` when nothing was picked: the engine runs its own default model. */
|
|
28
|
+
export const DEFAULT_MODEL_ID = "default";
|
|
29
|
+
|
|
30
|
+
const mockTextEngine = createMockEngine({ outputKind: "text" });
|
|
31
|
+
|
|
32
|
+
function defaultEnhancePrompt(value: string, mode?: GenerationUseCase): string {
|
|
33
|
+
const subject = mode?.includes("video")
|
|
34
|
+
? "a video-generation"
|
|
35
|
+
: mode === "text-to-text" || mode === "vision"
|
|
36
|
+
? "an AI assistant"
|
|
37
|
+
: "an image-generation";
|
|
38
|
+
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}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface UseComposerOptions {
|
|
42
|
+
/** The generation engine a submitted run actually calls. Defaults to `mockEngine`. */
|
|
43
|
+
engine?: GenerationEngine;
|
|
44
|
+
/**
|
|
45
|
+
* Engines per use-case kind, overriding `engine` for that kind — needed
|
|
46
|
+
* for multi-use-case select, since one Fal engine outputs one kind, e.g.
|
|
47
|
+
* `{ image: createFalEngine({ outputKind: "image" }), video: createFalEngine({ outputKind: "video" }) }`.
|
|
48
|
+
*/
|
|
49
|
+
engineByKind?: Partial<Record<MediaKind, GenerationEngine>>;
|
|
50
|
+
/**
|
|
51
|
+
* The engine for text requests (a Composer with no use case picked, or a
|
|
52
|
+
* text use case). Defaults to `reasoningEngine`, and to a mock text engine
|
|
53
|
+
* when neither is set, so a text prompt always gets a reply.
|
|
54
|
+
*/
|
|
55
|
+
textEngine?: GenerationEngine;
|
|
56
|
+
/** 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. */
|
|
57
|
+
textModel?: string;
|
|
58
|
+
/**
|
|
59
|
+
* The reasoning engine Enhance calls — deliberately separate from
|
|
60
|
+
* `engine`: Fal's own text/vision access is itself an
|
|
61
|
+
* OpenRouter proxy, so defaulting Enhance to a Fal `engine` would
|
|
62
|
+
* silently double-proxy. Must return text. Set together with
|
|
63
|
+
* `reasoningModel` to override the nearest `ChaiProvider`'s pair; with
|
|
64
|
+
* neither set here nor a provider, `enhance` stays `undefined`, same
|
|
65
|
+
* shape as `Composer`'s own "omit `onEnhance` to hide the button" convention.
|
|
66
|
+
*/
|
|
67
|
+
reasoningEngine?: GenerationEngine;
|
|
68
|
+
/** Set with `reasoningEngine` to override the provider's pair; used for every kind (§5.5). */
|
|
69
|
+
reasoningModel?: string;
|
|
70
|
+
/** Overrides the instruction wrapping a raw prompt for Enhance. Defaults to a generic still/video-aware rewrite instruction. */
|
|
71
|
+
enhancePrompt?: (value: string, mode?: GenerationUseCase) => string;
|
|
72
|
+
/**
|
|
73
|
+
* Auto-select: with `autoSelectModel` on in the payload, the reasoning
|
|
74
|
+
* model picks one model per use case at submit, then these fire with the
|
|
75
|
+
* pick so the Composer's trigger shows it. Wire them to the same state as
|
|
76
|
+
* Composer's `onModelChange` / `onModelIdsChange`.
|
|
77
|
+
*/
|
|
78
|
+
onModelChange?: (modelId: string) => void;
|
|
79
|
+
onModelIdsChange?: (modelIds: string[]) => void;
|
|
80
|
+
/** Replaces the routing instruction (`buildModelRoutingPrompt` in `@chai-ui/core`). */
|
|
81
|
+
routingPrompt?: (input: ModelRoutingInput) => string;
|
|
82
|
+
/**
|
|
83
|
+
* Fires when auto-select couldn't route and fell back to the first listed
|
|
84
|
+
* model: no reasoning model, a failed reasoning call (e.g. a bad key), or
|
|
85
|
+
* a reply naming no listed model. The request still runs.
|
|
86
|
+
*/
|
|
87
|
+
onRoutingFallback?: (result: Extract<ModelRoutingResult, { status: "fallback" }>) => void;
|
|
88
|
+
onRun?: (run: Run) => void;
|
|
89
|
+
onResult?: (run: Run, resultId: string) => void;
|
|
90
|
+
onError?: (run: Run, resultId: string) => void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The headless binding the design promises and the package README
|
|
95
|
+
* flagged as missing: turns a `Composer`'s `onSubmit` payload into a real
|
|
96
|
+
* `Request` → `Run` → `Result` (§6), fanning out across `engine.generate`
|
|
97
|
+
* calls and folding each one back into state as it settles — pure
|
|
98
|
+
* transitions live in `@chai-ui/core`, this is the thin React binding.
|
|
99
|
+
*/
|
|
100
|
+
export function useComposer(options: UseComposerOptions = {}) {
|
|
101
|
+
// One run per use case submitted: a single run unless the Composer has
|
|
102
|
+
// `multiSelectUseCases` on. `run` stays as the first, for the common case.
|
|
103
|
+
const [runs, setRuns] = useState<Run[]>([]);
|
|
104
|
+
const [enhancing, setEnhancing] = useState(false);
|
|
105
|
+
const [routing, setRouting] = useState(false);
|
|
106
|
+
// Each submit gets an id, so routing that settles after a newer submit is dropped.
|
|
107
|
+
const submitId = useRef(0);
|
|
108
|
+
// Aborts the current submit's engine calls: on `cancel()`, and when a newer
|
|
109
|
+
// submit replaces it (its results would be dropped anyway).
|
|
110
|
+
const controller = useRef<AbortController | null>(null);
|
|
111
|
+
|
|
112
|
+
// `submit` below reads through a ref so it can stay referentially stable (empty dep array — safe to
|
|
113
|
+
// pass to a memoized child) while always seeing this render's latest
|
|
114
|
+
// options, not the ones captured when `submit` was first created.
|
|
115
|
+
const optionsRef = useLatest(options);
|
|
116
|
+
|
|
117
|
+
// The hook's own pair wins, but only as a pair: half of one never mixes
|
|
118
|
+
// with half of the provider's (e.g. a hook engine with the provider's model).
|
|
119
|
+
const provided = useReasoning();
|
|
120
|
+
const { reasoningEngine, reasoningModel } = options;
|
|
121
|
+
const reasoning = useMemo<Reasoning | undefined>(
|
|
122
|
+
() =>
|
|
123
|
+
reasoningEngine || reasoningModel
|
|
124
|
+
? reasoningEngine && reasoningModel
|
|
125
|
+
? { engine: reasoningEngine, model: reasoningModel, modelByKind: {} }
|
|
126
|
+
: undefined
|
|
127
|
+
: provided,
|
|
128
|
+
[reasoningEngine, reasoningModel, provided]
|
|
129
|
+
);
|
|
130
|
+
const reasoningRef = useLatest(reasoning);
|
|
131
|
+
|
|
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) => {
|
|
136
|
+
const isText = selection.useCase.kind === "text";
|
|
137
|
+
// Nothing picked: text uses the builder's text model when there is
|
|
138
|
+
// 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;
|
|
140
|
+
const request: Request = {
|
|
141
|
+
mode: deriveGenerationUseCase(selection.useCase, payload.attachments),
|
|
142
|
+
prompt: payload.value,
|
|
143
|
+
attachments: payload.attachments,
|
|
144
|
+
modelIds: selection.modelIds.length > 0 ? selection.modelIds : [fallback ?? DEFAULT_MODEL_ID],
|
|
145
|
+
params: payload.aspectRatio ? { aspectRatio: payload.aspectRatio } : {},
|
|
146
|
+
};
|
|
147
|
+
const engine = isText
|
|
148
|
+
? (opts.textEngine ?? reasoning?.engine ?? mockTextEngine)
|
|
149
|
+
: (opts.engineByKind?.[selection.useCase.kind] ?? opts.engine ?? mockEngine);
|
|
150
|
+
return { run: createRun(request), engine };
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
setRuns(newRuns.map((r) => r.run));
|
|
154
|
+
|
|
155
|
+
// Results settle independently and out of order — each update only
|
|
156
|
+
// ever touches its own run, and only if that run is still current. A
|
|
157
|
+
// run from an earlier submit is no longer in state (and its calls were
|
|
158
|
+
// 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));
|
|
161
|
+
|
|
162
|
+
for (const { run: newRun, engine } of newRuns) {
|
|
163
|
+
opts.onRun?.(newRun);
|
|
164
|
+
for (const result of newRun.results) {
|
|
165
|
+
update(newRun.id, (r) => startResult(r, result.id));
|
|
166
|
+
void engine
|
|
167
|
+
.generate({
|
|
168
|
+
modelId: result.modelId === DEFAULT_MODEL_ID ? undefined : result.modelId,
|
|
169
|
+
prompt: newRun.request.prompt,
|
|
170
|
+
attachments: newRun.request.attachments,
|
|
171
|
+
// Text engines that stream fill `output` in while still "running".
|
|
172
|
+
onText: (text) => update(newRun.id, (r) => streamResult(r, result.id, text)),
|
|
173
|
+
signal,
|
|
174
|
+
})
|
|
175
|
+
.then(
|
|
176
|
+
({ src, kind, usage, evaluations }) => {
|
|
177
|
+
update(newRun.id, (r) => resolveResult(r, result.id, { src, kind }, usage, evaluations));
|
|
178
|
+
optionsRef.current.onResult?.(newRun, result.id);
|
|
179
|
+
},
|
|
180
|
+
(err) => {
|
|
181
|
+
if (isAbortError(err)) return update(newRun.id, (r) => cancelResult(r, result.id));
|
|
182
|
+
update(newRun.id, (r) => failResult(r, result.id, normalizeGenerationError(err)));
|
|
183
|
+
optionsRef.current.onError?.(newRun, result.id);
|
|
184
|
+
}
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}, [optionsRef, reasoningRef]);
|
|
189
|
+
|
|
190
|
+
const submit = useCallback(
|
|
191
|
+
(payload: ComposerSubmitPayload) => {
|
|
192
|
+
const id = ++submitId.current;
|
|
193
|
+
controller.current?.abort();
|
|
194
|
+
const { signal } = (controller.current = new AbortController());
|
|
195
|
+
const toRoute = payload.autoSelectModel && payload.selections.some((sel) => sel.models.length > 0);
|
|
196
|
+
if (!toRoute) {
|
|
197
|
+
setRouting(false);
|
|
198
|
+
start(payload, signal);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const opts = optionsRef.current;
|
|
203
|
+
const reasoning = reasoningRef.current;
|
|
204
|
+
if (!reasoning) warnNoReasoningOnce();
|
|
205
|
+
setRouting(true);
|
|
206
|
+
void Promise.all(
|
|
207
|
+
payload.selections.map((sel) =>
|
|
208
|
+
sel.models.length === 0
|
|
209
|
+
? Promise.resolve(null)
|
|
210
|
+
: routeModel({
|
|
211
|
+
prompt: payload.value,
|
|
212
|
+
outputKind: sel.useCase.kind,
|
|
213
|
+
attachmentKinds: payload.attachments.map((a) => a.kind),
|
|
214
|
+
attachments: payload.attachments,
|
|
215
|
+
models: sel.models,
|
|
216
|
+
engine: reasoning?.engine,
|
|
217
|
+
model: reasoning && reasoningModelFor(reasoning, payload.attachments),
|
|
218
|
+
routingPrompt: opts.routingPrompt,
|
|
219
|
+
signal,
|
|
220
|
+
})
|
|
221
|
+
)
|
|
222
|
+
).then((results) => {
|
|
223
|
+
if (id !== submitId.current || signal.aborted) return;
|
|
224
|
+
setRouting(false);
|
|
225
|
+
const latest = optionsRef.current;
|
|
226
|
+
for (const r of results) if (r?.status === "fallback") latest.onRoutingFallback?.(r);
|
|
227
|
+
const routedIds = results.flatMap((r) => (r ? [r.modelId] : []));
|
|
228
|
+
if (latest.onModelIdsChange) latest.onModelIdsChange(routedIds);
|
|
229
|
+
else if (routedIds[0]) latest.onModelChange?.(routedIds[0]);
|
|
230
|
+
start({
|
|
231
|
+
...payload,
|
|
232
|
+
modelId: routedIds[0] ?? payload.modelId,
|
|
233
|
+
selections: payload.selections.map((sel, i) => ({
|
|
234
|
+
...sel,
|
|
235
|
+
modelIds: results[i] ? [results[i]!.modelId] : sel.modelIds,
|
|
236
|
+
})),
|
|
237
|
+
}, signal);
|
|
238
|
+
});
|
|
239
|
+
},
|
|
240
|
+
[start, optionsRef, reasoningRef]
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Stops the current submit: wire it to Composer's `onAbort`. Its
|
|
245
|
+
* unfinished results turn `"cancelled"` right away, and each engine
|
|
246
|
+
* cancels at the provider where it can (best effort: some providers keep
|
|
247
|
+
* running, and billing, once a request has started).
|
|
248
|
+
*/
|
|
249
|
+
const cancel = useCallback(() => {
|
|
250
|
+
submitId.current += 1;
|
|
251
|
+
controller.current?.abort();
|
|
252
|
+
controller.current = null;
|
|
253
|
+
setRouting(false);
|
|
254
|
+
setRuns((prev) => prev.map((run) => run.results.reduce((acc, r) => cancelResult(acc, r.id), run)));
|
|
255
|
+
}, []);
|
|
256
|
+
|
|
257
|
+
const run = runs[0] ?? null;
|
|
258
|
+
|
|
259
|
+
// `undefined` (not a no-op function) when unconfigured, so a consumer
|
|
260
|
+
// can write `onEnhance={enhance}` directly — matching how `Composer`
|
|
261
|
+
// itself treats an omitted `onEnhance` as "hide the button entirely",
|
|
262
|
+
// rather than needing an `enhance ? enhance : undefined` at every call site.
|
|
263
|
+
const enhance = useMemo(() => {
|
|
264
|
+
if (!reasoning) return undefined;
|
|
265
|
+
return async (value: string) => {
|
|
266
|
+
setEnhancing(true);
|
|
267
|
+
try {
|
|
268
|
+
const prompt = (options.enhancePrompt ?? defaultEnhancePrompt)(value, run?.request.mode);
|
|
269
|
+
const { src } = await reasoning.engine.generate({ modelId: reasoning.model, prompt, attachments: [] });
|
|
270
|
+
return src;
|
|
271
|
+
} finally {
|
|
272
|
+
setEnhancing(false);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
}, [reasoning, options.enhancePrompt, run?.request.mode]);
|
|
276
|
+
|
|
277
|
+
return { run, runs, submit, cancel, enhance, enhancing, routing };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export type UseComposerReturn = ReturnType<typeof useComposer>;
|
|
281
|
+
|
|
282
|
+
let warnedNoReasoning = false;
|
|
283
|
+
function warnNoReasoningOnce() {
|
|
284
|
+
if (warnedNoReasoning) return;
|
|
285
|
+
warnedNoReasoning = true;
|
|
286
|
+
console.warn(
|
|
287
|
+
"[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."
|
|
288
|
+
);
|
|
289
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
createMockEngine,
|
|
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";
|
|
15
|
+
import { useLatest } from "./use-latest.js";
|
|
16
|
+
|
|
17
|
+
const mockTextEngine = createMockEngine({ outputKind: "text" });
|
|
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
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The binding behind `MediaAnalyzer`'s `onSubmit`: sends the attached
|
|
39
|
+
* media and the instruction for its kind and prompt length
|
|
40
|
+
* (`MEDIA_ANALYSIS_PROMPTS`) to the reasoning engine, and returns the
|
|
41
|
+
* generated prompt. `prompt` fills in as the text streams.
|
|
42
|
+
*/
|
|
43
|
+
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
|
+
const provided = useReasoning();
|
|
49
|
+
const latest = useLatest({ options, provided });
|
|
50
|
+
// Each submit gets an id; a late reply from an earlier submit is dropped,
|
|
51
|
+
// and its call aborted.
|
|
52
|
+
const callId = useRef(0);
|
|
53
|
+
const controller = useRef<AbortController | null>(null);
|
|
54
|
+
|
|
55
|
+
const submit = useCallback((payload: MediaAnalyzerSubmitPayload) => {
|
|
56
|
+
const { options: opts, provided: reasoning } = latest.current;
|
|
57
|
+
const id = ++callId.current;
|
|
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 };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
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
|
+
}
|