@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.
Files changed (66) hide show
  1. package/DESIGN.md +207 -0
  2. package/LICENSE +21 -0
  3. package/README.md +265 -0
  4. package/dist/ChaiProvider.d.ts +49 -0
  5. package/dist/ChaiProvider.d.ts.map +1 -0
  6. package/dist/ChaiProvider.js +54 -0
  7. package/dist/ChaiProvider.js.map +1 -0
  8. package/dist/Composer.d.ts +169 -0
  9. package/dist/Composer.d.ts.map +1 -0
  10. package/dist/Composer.js +389 -0
  11. package/dist/Composer.js.map +1 -0
  12. package/dist/MediaAnalyzer.d.ts +102 -0
  13. package/dist/MediaAnalyzer.d.ts.map +1 -0
  14. package/dist/MediaAnalyzer.js +250 -0
  15. package/dist/MediaAnalyzer.js.map +1 -0
  16. package/dist/ResultCard.d.ts +55 -0
  17. package/dist/ResultCard.d.ts.map +1 -0
  18. package/dist/ResultCard.js +409 -0
  19. package/dist/ResultCard.js.map +1 -0
  20. package/dist/index.d.ts +10 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +11 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/primitives/Pagination.d.ts +19 -0
  25. package/dist/primitives/Pagination.d.ts.map +1 -0
  26. package/dist/primitives/Pagination.js +13 -0
  27. package/dist/primitives/Pagination.js.map +1 -0
  28. package/dist/primitives/SearchMenu.d.ts +69 -0
  29. package/dist/primitives/SearchMenu.d.ts.map +1 -0
  30. package/dist/primitives/SearchMenu.js +88 -0
  31. package/dist/primitives/SearchMenu.js.map +1 -0
  32. package/dist/primitives/Toggle.d.ts +9 -0
  33. package/dist/primitives/Toggle.d.ts.map +1 -0
  34. package/dist/primitives/Toggle.js +28 -0
  35. package/dist/primitives/Toggle.js.map +1 -0
  36. package/dist/primitives/index.d.ts +4 -0
  37. package/dist/primitives/index.d.ts.map +1 -0
  38. package/dist/primitives/index.js +4 -0
  39. package/dist/primitives/index.js.map +1 -0
  40. package/dist/style.css +1860 -0
  41. package/dist/use-latest.d.ts +7 -0
  42. package/dist/use-latest.d.ts.map +1 -0
  43. package/dist/use-latest.js +17 -0
  44. package/dist/use-latest.js.map +1 -0
  45. package/dist/useComposer.d.ts +75 -0
  46. package/dist/useComposer.d.ts.map +1 -0
  47. package/dist/useComposer.js +191 -0
  48. package/dist/useComposer.js.map +1 -0
  49. package/dist/useMediaAnalyzer.d.ts +37 -0
  50. package/dist/useMediaAnalyzer.d.ts.map +1 -0
  51. package/dist/useMediaAnalyzer.js +114 -0
  52. package/dist/useMediaAnalyzer.js.map +1 -0
  53. package/package.json +88 -0
  54. package/src/ChaiProvider.tsx +88 -0
  55. package/src/Composer.tsx +961 -0
  56. package/src/MediaAnalyzer.tsx +527 -0
  57. package/src/ResultCard.tsx +980 -0
  58. package/src/index.ts +36 -0
  59. package/src/primitives/Pagination.tsx +41 -0
  60. package/src/primitives/SearchMenu.tsx +284 -0
  61. package/src/primitives/Toggle.tsx +43 -0
  62. package/src/primitives/index.ts +3 -0
  63. package/src/style.css +1860 -0
  64. package/src/use-latest.ts +18 -0
  65. package/src/useComposer.ts +289 -0
  66. 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
+ }