@chai-ui/core 0.6.0 → 0.7.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/README.md +10 -5
- package/dist/composer-view.d.ts +93 -0
- package/dist/composer-view.d.ts.map +1 -0
- package/dist/composer-view.js +142 -0
- package/dist/composer-view.js.map +1 -0
- package/dist/composer.d.ts +188 -0
- package/dist/composer.d.ts.map +1 -0
- package/dist/composer.js +326 -0
- package/dist/composer.js.map +1 -0
- package/dist/edit-card.d.ts +53 -0
- package/dist/edit-card.d.ts.map +1 -0
- package/dist/edit-card.js +119 -0
- package/dist/edit-card.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/media-actions.d.ts +19 -0
- package/dist/media-actions.d.ts.map +1 -0
- package/dist/media-actions.js +57 -0
- package/dist/media-actions.js.map +1 -0
- package/dist/media-analyzer.d.ts +101 -0
- package/dist/media-analyzer.d.ts.map +1 -0
- package/dist/media-analyzer.js +215 -0
- package/dist/media-analyzer.js.map +1 -0
- package/dist/reasoning.d.ts +59 -0
- package/dist/reasoning.d.ts.map +1 -0
- package/dist/reasoning.js +55 -0
- package/dist/reasoning.js.map +1 -0
- package/dist/region-edit.d.ts +2 -0
- package/dist/region-edit.d.ts.map +1 -1
- package/dist/region-edit.js +6 -0
- package/dist/region-edit.js.map +1 -1
- package/dist/result-format.d.ts +11 -0
- package/dist/result-format.d.ts.map +1 -0
- package/dist/result-format.js +30 -0
- package/dist/result-format.js.map +1 -0
- package/dist/unique-id.d.ts +3 -0
- package/dist/unique-id.d.ts.map +1 -0
- package/dist/unique-id.js +7 -0
- package/dist/unique-id.js.map +1 -0
- package/package.json +2 -1
- package/src/composer-view.ts +222 -0
- package/src/composer.ts +525 -0
- package/src/edit-card.ts +138 -0
- package/src/index.ts +7 -0
- package/src/media-actions.ts +55 -0
- package/src/media-analyzer.ts +293 -0
- package/src/reasoning.ts +90 -0
- package/src/region-edit.ts +8 -0
- package/src/result-format.ts +34 -0
- package/src/unique-id.ts +6 -0
package/src/composer.ts
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Composer's behavior with no framework: turns a submit payload into
|
|
3
|
+
* `Request` → `Run` → `Result` (§6), fanning out across `engine.generate`
|
|
4
|
+
* calls and folding each one back into state as it settles. A small store
|
|
5
|
+
* (`getState` + `subscribe`) that `useComposer` (React) and the Vue
|
|
6
|
+
* composable wrap; the pure transitions live in `run.ts`.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
createMockEngine,
|
|
10
|
+
isAbortError,
|
|
11
|
+
mockEngine,
|
|
12
|
+
normalizeGenerationError,
|
|
13
|
+
type GenerationEngine,
|
|
14
|
+
} from "./engine.js";
|
|
15
|
+
import { routeModel, type ModelRoutingInput, type ModelRoutingResult } from "./model-routing.js";
|
|
16
|
+
import { buildRegionEditPrompt, PRECISE_EDIT_MODELS, type EditRegion } from "./region-edit.js";
|
|
17
|
+
import { pickReasoning, reasoningModelFor, type Reasoning } from "./reasoning.js";
|
|
18
|
+
import {
|
|
19
|
+
cancelResult,
|
|
20
|
+
createRun,
|
|
21
|
+
deriveGenerationUseCase,
|
|
22
|
+
failResult,
|
|
23
|
+
resolveResult,
|
|
24
|
+
ROUTING_MODEL_ID,
|
|
25
|
+
startResult,
|
|
26
|
+
streamResult,
|
|
27
|
+
type Request,
|
|
28
|
+
type ResultStatus,
|
|
29
|
+
type Run,
|
|
30
|
+
} from "./run.js";
|
|
31
|
+
import type { DroppedMedia, GenerationUseCase, MediaKind, ModelOption } from "./types.js";
|
|
32
|
+
|
|
33
|
+
export interface ComposerAttachment extends DroppedMedia {
|
|
34
|
+
id: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The selected generation use case, shown as a removable chip (reference:
|
|
39
|
+
* the mock's "Video"/"Image" chip) — deliberately independent of
|
|
40
|
+
* `attachments`: a use case can be picked from the attach menu with nothing
|
|
41
|
+
* attached yet (e.g. "Create video"). Composer never derives this from
|
|
42
|
+
* `attachments` itself (that's app-level policy, not a Composer opinion —
|
|
43
|
+
* the design). With none picked, `defaultUseCase` applies.
|
|
44
|
+
*/
|
|
45
|
+
export interface ComposerUseCase {
|
|
46
|
+
kind: MediaKind;
|
|
47
|
+
label: string;
|
|
48
|
+
/**
|
|
49
|
+
* An edit of the attached image rather than a new one: no aspect ratio,
|
|
50
|
+
* region chips when `regions` is set, and a submit needs only an image and
|
|
51
|
+
* either a prompt or a region. Images only for now.
|
|
52
|
+
*/
|
|
53
|
+
edit?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A plain text request — what a submit means when no use case is picked, unless the builder sets `defaultUseCase`. */
|
|
57
|
+
export const TEXT_USE_CASE: ComposerUseCase = { kind: "text", label: "Text" };
|
|
58
|
+
|
|
59
|
+
/** Editing the attached image, the attach menu's "Edit image". Pair it with `useComposer`'s `editImage` and an `EditCard`. */
|
|
60
|
+
export const EDIT_IMAGE_USE_CASE: ComposerUseCase = { kind: "image", label: "Edit image", edit: true };
|
|
61
|
+
|
|
62
|
+
/** One use case to run and the models picked for it. Empty `modelIds` means the engine's default model. */
|
|
63
|
+
export interface ComposerSelection {
|
|
64
|
+
useCase: ComposerUseCase;
|
|
65
|
+
modelIds: string[];
|
|
66
|
+
/** The models offered for this use case: what auto-select routes among. */
|
|
67
|
+
models: ModelOption[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Everything Composer itself tracks, assembled into one object at submit
|
|
72
|
+
* time — an honest snapshot of the component's own state, handed to
|
|
73
|
+
* `onSubmit` so a builder doesn't have to re-derive it from five
|
|
74
|
+
* separately-controlled props they already hold.
|
|
75
|
+
*/
|
|
76
|
+
export interface ComposerSubmitPayload {
|
|
77
|
+
value: string;
|
|
78
|
+
attachments: ComposerAttachment[];
|
|
79
|
+
/** The use case to run — the picked one, or `defaultUseCase` when none is picked. The first one with `multiSelectUseCases`. */
|
|
80
|
+
useCase: ComposerUseCase;
|
|
81
|
+
/** The first picked model, or null for the engine's default. */
|
|
82
|
+
modelId: string | null;
|
|
83
|
+
aspectRatio: string | null;
|
|
84
|
+
/** Everything to run: one entry per use case with its picked models. A single entry unless `multiSelectUseCases` is on. */
|
|
85
|
+
selections: ComposerSelection[];
|
|
86
|
+
/** When true, the model is chosen at submit (`useComposer` routes among each selection's `models`), not by the picks. */
|
|
87
|
+
autoSelectModel: boolean;
|
|
88
|
+
/** An edit's marked regions, each with its own instruction. Empty unless the use case is an edit. */
|
|
89
|
+
regions?: EditRegion[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One version of the image being edited: the original, or the result of an
|
|
94
|
+
* edit. The composer's `edit.versions` builds these from its runs.
|
|
95
|
+
*/
|
|
96
|
+
export interface EditVersion {
|
|
97
|
+
id: string;
|
|
98
|
+
/** The image, once there is one. */
|
|
99
|
+
src?: string;
|
|
100
|
+
/** The image this version was edited from, shown under the progress spinner until `src` arrives. */
|
|
101
|
+
from?: string;
|
|
102
|
+
status: ResultStatus;
|
|
103
|
+
/** The regions this version was made with (none for the original). Shown faintly, for reference. */
|
|
104
|
+
regions: EditRegion[];
|
|
105
|
+
error?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A result's `modelId` when nothing was picked: the engine runs its own default model. */
|
|
109
|
+
export const DEFAULT_MODEL_ID = "default";
|
|
110
|
+
|
|
111
|
+
export interface ComposerOptions {
|
|
112
|
+
/** The generation engine a submitted run actually calls. Defaults to `mockEngine`. */
|
|
113
|
+
engine?: GenerationEngine;
|
|
114
|
+
/**
|
|
115
|
+
* Engines per use-case kind, overriding `engine` for that kind — needed
|
|
116
|
+
* for multi-use-case select, since one Fal engine outputs one kind, e.g.
|
|
117
|
+
* `{ image: createFalEngine({ outputKind: "image" }), video: createFalEngine({ outputKind: "video" }) }`.
|
|
118
|
+
*/
|
|
119
|
+
engineByKind?: Partial<Record<MediaKind, GenerationEngine>>;
|
|
120
|
+
/**
|
|
121
|
+
* The engine for text requests (a Composer with no use case picked, or a
|
|
122
|
+
* text use case). Defaults to `reasoningEngine`, and to a mock text engine
|
|
123
|
+
* when neither is set, so a text prompt always gets a reply.
|
|
124
|
+
*/
|
|
125
|
+
textEngine?: GenerationEngine;
|
|
126
|
+
/** 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. */
|
|
127
|
+
textModel?: string;
|
|
128
|
+
/**
|
|
129
|
+
* The reasoning engine Enhance calls — deliberately separate from
|
|
130
|
+
* `engine`: Fal's own text/vision access is itself an
|
|
131
|
+
* OpenRouter proxy, so defaulting Enhance to a Fal `engine` would
|
|
132
|
+
* silently double-proxy. Must return text. Set together with
|
|
133
|
+
* `reasoningModel` to override the nearest `ChaiProvider`'s pair; with
|
|
134
|
+
* neither set here nor a provider, `enhance` stays `undefined`, same
|
|
135
|
+
* shape as `Composer`'s own "omit `onEnhance` to hide the button" convention.
|
|
136
|
+
*/
|
|
137
|
+
reasoningEngine?: GenerationEngine;
|
|
138
|
+
/** Set with `reasoningEngine` to override the provider's pair; used for every kind (§5.5). */
|
|
139
|
+
reasoningModel?: string;
|
|
140
|
+
/** Overrides the instruction wrapping a raw prompt for Enhance. Defaults to a generic still/video-aware rewrite instruction. */
|
|
141
|
+
enhancePrompt?: (value: string, mode?: GenerationUseCase) => string;
|
|
142
|
+
/**
|
|
143
|
+
* Auto-select: with `autoSelectModel` on in the payload, the reasoning
|
|
144
|
+
* model picks one model per use case at submit, then these fire with the
|
|
145
|
+
* pick so the Composer's trigger shows it. Wire them to the same state as
|
|
146
|
+
* Composer's `onModelChange` / `onModelIdsChange`.
|
|
147
|
+
*/
|
|
148
|
+
onModelChange?: (modelId: string) => void;
|
|
149
|
+
onModelIdsChange?: (modelIds: string[]) => void;
|
|
150
|
+
/** Replaces the routing instruction (`buildModelRoutingPrompt`). */
|
|
151
|
+
routingPrompt?: (input: ModelRoutingInput) => string;
|
|
152
|
+
/**
|
|
153
|
+
* Fires when auto-select couldn't route and fell back to the first listed
|
|
154
|
+
* model: no reasoning model, a failed reasoning call (e.g. a bad key), or
|
|
155
|
+
* a reply naming no listed model. The request still runs.
|
|
156
|
+
*/
|
|
157
|
+
onRoutingFallback?: (result: Extract<ModelRoutingResult, { status: "fallback" }>) => void;
|
|
158
|
+
/**
|
|
159
|
+
* The image to edit, when the Composer's use case is an edit (e.g.
|
|
160
|
+
* `EDIT_IMAGE_USE_CASE`) and an image is attached. While it's set, `edit`
|
|
161
|
+
* returns props for an `EditCard`, and each edit's results become new
|
|
162
|
+
* versions of the image. Changing it to another image starts over.
|
|
163
|
+
*/
|
|
164
|
+
editImage?: DroppedMedia | null;
|
|
165
|
+
/** Replaces how an edit's prompt and regions become the model's prompt. Defaults to `buildRegionEditPrompt`, in the model's `regionFormat`. */
|
|
166
|
+
editPrompt?: (args: { prompt: string; regions: EditRegion[]; model?: ModelOption }) => string;
|
|
167
|
+
onRun?: (run: Run) => void;
|
|
168
|
+
onResult?: (run: Run, resultId: string) => void;
|
|
169
|
+
onError?: (run: Run, resultId: string) => void;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface ComposerState {
|
|
173
|
+
/** One run per use case submitted: a single run unless the Composer has `multiSelectUseCases` on. */
|
|
174
|
+
runs: Run[];
|
|
175
|
+
enhancing: boolean;
|
|
176
|
+
routing: boolean;
|
|
177
|
+
/** Edit mode. Each edit's runs are kept (not replaced by the next submit), since every result is a version of the image the person can page back to. */
|
|
178
|
+
editRuns: Run[];
|
|
179
|
+
regions: EditRegion[];
|
|
180
|
+
activeVersion: number;
|
|
181
|
+
/** The image the edit state belongs to. Another image starts over. */
|
|
182
|
+
editSource: string | null;
|
|
183
|
+
/** Numbers each image, so its original version has its own id and EditCard starts it clean. */
|
|
184
|
+
editImageCount: number;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Props for an `EditCard` (spread them in), and `regions` for `Composer`. */
|
|
188
|
+
export interface ComposerEdit {
|
|
189
|
+
versions: EditVersion[];
|
|
190
|
+
activeVersion: number;
|
|
191
|
+
onActiveVersionChange: (index: number) => void;
|
|
192
|
+
regions: EditRegion[];
|
|
193
|
+
onRegionsChange: (regions: EditRegion[]) => void;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const mockTextEngine = createMockEngine({ outputKind: "text" });
|
|
197
|
+
|
|
198
|
+
function defaultEnhancePrompt(value: string, mode?: GenerationUseCase): string {
|
|
199
|
+
const subject = mode?.includes("video")
|
|
200
|
+
? "a video-generation"
|
|
201
|
+
: mode === "text-to-text" || mode === "vision"
|
|
202
|
+
? "an AI assistant"
|
|
203
|
+
: "an image-generation";
|
|
204
|
+
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}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function defaultEditPrompt({ prompt, regions, model }: { prompt: string; regions: EditRegion[]; model?: ModelOption }): string {
|
|
208
|
+
return buildRegionEditPrompt({ prompt, regions, format: model?.regionFormat });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const imageOnly = (media: DroppedMedia | null | undefined) => (media?.kind === "image" ? media : null);
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* `getOptions` is read on every call, so options can change between
|
|
215
|
+
* renders; `getProvided` is the nearest provider's reasoning, if any.
|
|
216
|
+
*/
|
|
217
|
+
export function createComposerStore(getOptions: () => ComposerOptions, getProvided: () => Reasoning | undefined = () => undefined) {
|
|
218
|
+
let state: ComposerState = {
|
|
219
|
+
runs: [],
|
|
220
|
+
enhancing: false,
|
|
221
|
+
routing: false,
|
|
222
|
+
editRuns: [],
|
|
223
|
+
regions: [],
|
|
224
|
+
activeVersion: 0,
|
|
225
|
+
editSource: imageOnly(getOptions().editImage)?.src ?? null,
|
|
226
|
+
editImageCount: 0,
|
|
227
|
+
};
|
|
228
|
+
const listeners = new Set<() => void>();
|
|
229
|
+
|
|
230
|
+
// Edit state belongs to one image: a read sees another image's state as
|
|
231
|
+
// empty, and the first write after the change actually clears it.
|
|
232
|
+
const forImage = (s: ComposerState, image = imageOnly(getOptions().editImage)): ComposerState => {
|
|
233
|
+
const src = image?.src ?? null;
|
|
234
|
+
return src === s.editSource
|
|
235
|
+
? s
|
|
236
|
+
: { ...s, editSource: src, editImageCount: s.editImageCount + 1, editRuns: [], regions: [], activeVersion: 0 };
|
|
237
|
+
};
|
|
238
|
+
const set = (fn: (s: ComposerState) => Partial<ComposerState>) => {
|
|
239
|
+
const current = forImage(state);
|
|
240
|
+
state = { ...current, ...fn(current) };
|
|
241
|
+
for (const l of listeners) l();
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
let versionsCache: { image: DroppedMedia | null; editRuns: Run[]; count: number; versions: EditVersion[] } | null = null;
|
|
245
|
+
const versionsOf = (s: ComposerState, image: DroppedMedia | null): EditVersion[] => {
|
|
246
|
+
if (versionsCache && versionsCache.image === image && versionsCache.editRuns === s.editRuns && versionsCache.count === s.editImageCount) {
|
|
247
|
+
return versionsCache.versions;
|
|
248
|
+
}
|
|
249
|
+
const versions: EditVersion[] = image
|
|
250
|
+
? [
|
|
251
|
+
{ id: `original-${s.editImageCount}`, src: image.src, status: "done", regions: [] },
|
|
252
|
+
...s.editRuns.flatMap((run) =>
|
|
253
|
+
run.results.map((r) => ({
|
|
254
|
+
id: r.id,
|
|
255
|
+
src: r.output?.src,
|
|
256
|
+
from: run.request.attachments[0]?.src,
|
|
257
|
+
status: r.status,
|
|
258
|
+
regions: (run.request.params.regions as EditRegion[] | undefined) ?? [],
|
|
259
|
+
error: r.error?.message,
|
|
260
|
+
}))
|
|
261
|
+
),
|
|
262
|
+
]
|
|
263
|
+
: [];
|
|
264
|
+
versionsCache = { image, editRuns: s.editRuns, count: s.editImageCount, versions };
|
|
265
|
+
return versions;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const reasoning = () => pickReasoning(getOptions(), getProvided());
|
|
269
|
+
|
|
270
|
+
// Each submit gets an id, so routing that settles after a newer submit is dropped.
|
|
271
|
+
let submitId = 0;
|
|
272
|
+
// Aborts the current submit's engine calls: on `cancel()`, and when a newer
|
|
273
|
+
// submit replaces it (its results would be dropped anyway).
|
|
274
|
+
let controller: AbortController | null = null;
|
|
275
|
+
// Edit placeholders from routing waits, dropped from the history when the
|
|
276
|
+
// next real runs start (including ones a newer submit abandoned).
|
|
277
|
+
let placeholderIds = new Set<string>();
|
|
278
|
+
|
|
279
|
+
// One request per selection. An edit runs on the image version showing
|
|
280
|
+
// in the EditCard (the original until an edit lands) and carries its regions.
|
|
281
|
+
function buildRequest(payload: ComposerSubmitPayload, selection: ComposerSelection) {
|
|
282
|
+
const opts = getOptions();
|
|
283
|
+
const r = reasoning();
|
|
284
|
+
const isText = selection.useCase.kind === "text";
|
|
285
|
+
const isEdit = Boolean(selection.useCase.edit && opts.editImage?.kind === "image");
|
|
286
|
+
const image = imageOnly(opts.editImage);
|
|
287
|
+
const s = forImage(state, image);
|
|
288
|
+
const shown = versionsOf(s, image)[s.activeVersion];
|
|
289
|
+
const editFrom = shown?.status === "done" && shown.src ? shown.src : opts.editImage?.src;
|
|
290
|
+
const attachments: DroppedMedia[] = isEdit && editFrom ? [{ src: editFrom, kind: "image" }] : payload.attachments;
|
|
291
|
+
// Nothing picked: text uses the builder's text model when there is
|
|
292
|
+
// one; otherwise the engine runs its own default. Never a silent no-op.
|
|
293
|
+
const fallback = isText ? (opts.textModel ?? (r && reasoningModelFor(r, attachments))) : undefined;
|
|
294
|
+
const params: Record<string, unknown> = payload.aspectRatio && !isEdit ? { aspectRatio: payload.aspectRatio } : {};
|
|
295
|
+
if (isEdit) params.regions = payload.regions ?? [];
|
|
296
|
+
const request: Request = {
|
|
297
|
+
mode: deriveGenerationUseCase(selection.useCase, attachments),
|
|
298
|
+
prompt: payload.value,
|
|
299
|
+
attachments,
|
|
300
|
+
modelIds: selection.modelIds.length > 0 ? selection.modelIds : [fallback ?? DEFAULT_MODEL_ID],
|
|
301
|
+
params,
|
|
302
|
+
};
|
|
303
|
+
const engine = isText
|
|
304
|
+
? (opts.textEngine ?? r?.engine ?? mockTextEngine)
|
|
305
|
+
: (opts.engineByKind?.[selection.useCase.kind] ?? opts.engine ?? mockEngine);
|
|
306
|
+
return { request, engine, isEdit };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function start(payload: ComposerSubmitPayload, signal: AbortSignal) {
|
|
310
|
+
const opts = getOptions();
|
|
311
|
+
const newRuns = payload.selections.map((selection) => {
|
|
312
|
+
const { request, engine, isEdit } = buildRequest(payload, selection);
|
|
313
|
+
return { run: createRun(request), engine, isEdit, models: selection.models ?? [] };
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const placeholders = placeholderIds;
|
|
317
|
+
placeholderIds = new Set();
|
|
318
|
+
set((s) => {
|
|
319
|
+
const newEditRuns = newRuns.filter((r) => r.isEdit).map((r) => r.run);
|
|
320
|
+
const kept = s.editRuns.filter((r) => !placeholders.has(r.id));
|
|
321
|
+
const runs = newRuns.map((r) => r.run);
|
|
322
|
+
// Show the first new version, and start the next edit with no regions:
|
|
323
|
+
// these ones now belong to the versions they made.
|
|
324
|
+
if (newEditRuns.length > 0) {
|
|
325
|
+
return {
|
|
326
|
+
runs,
|
|
327
|
+
editRuns: [...kept, ...newEditRuns],
|
|
328
|
+
activeVersion: 1 + kept.reduce((n, r) => n + r.results.length, 0),
|
|
329
|
+
regions: [],
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
return placeholders.size > 0 ? { runs, editRuns: kept } : { runs };
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// Results settle independently and out of order — each update only
|
|
336
|
+
// ever touches its own run, and only if that run is still current. A
|
|
337
|
+
// run from an earlier submit is no longer in state (and its calls were
|
|
338
|
+
// aborted), so a late result can't clobber the newer ones.
|
|
339
|
+
const update = (runId: string, fn: (run: Run) => Run) => {
|
|
340
|
+
const apply = (prev: Run[]) => (prev.some((r) => r.id === runId) ? prev.map((r) => (r.id === runId ? fn(r) : r)) : prev);
|
|
341
|
+
set((s) => ({ runs: apply(s.runs), editRuns: apply(s.editRuns) }));
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
for (const { run: newRun, engine, isEdit, models } of newRuns) {
|
|
345
|
+
opts.onRun?.(newRun);
|
|
346
|
+
for (const result of newRun.results) {
|
|
347
|
+
update(newRun.id, (r) => startResult(r, result.id));
|
|
348
|
+
// An edit's prompt depends on the model: one with box support gets
|
|
349
|
+
// boxes, any other gets the regions described in words.
|
|
350
|
+
const regions = (newRun.request.params.regions as EditRegion[] | undefined) ?? [];
|
|
351
|
+
const model =
|
|
352
|
+
models.find((m) => m.id === result.modelId) ?? PRECISE_EDIT_MODELS.find((m) => m.id === result.modelId);
|
|
353
|
+
const prompt = isEdit
|
|
354
|
+
? (opts.editPrompt ?? defaultEditPrompt)({ prompt: newRun.request.prompt, regions, model })
|
|
355
|
+
: newRun.request.prompt;
|
|
356
|
+
void engine
|
|
357
|
+
.generate({
|
|
358
|
+
modelId: result.modelId === DEFAULT_MODEL_ID ? undefined : result.modelId,
|
|
359
|
+
prompt,
|
|
360
|
+
attachments: newRun.request.attachments,
|
|
361
|
+
aspectRatio: newRun.request.params.aspectRatio as string | undefined,
|
|
362
|
+
// Text engines that stream fill `output` in while still "running".
|
|
363
|
+
onText: (text) => update(newRun.id, (r) => streamResult(r, result.id, text)),
|
|
364
|
+
signal,
|
|
365
|
+
})
|
|
366
|
+
.then(
|
|
367
|
+
({ src, kind, usage, evaluations }) => {
|
|
368
|
+
update(newRun.id, (r) => resolveResult(r, result.id, { src, kind }, usage, evaluations));
|
|
369
|
+
getOptions().onResult?.(newRun, result.id);
|
|
370
|
+
},
|
|
371
|
+
(err) => {
|
|
372
|
+
if (isAbortError(err)) return update(newRun.id, (r) => cancelResult(r, result.id));
|
|
373
|
+
update(newRun.id, (r) => failResult(r, result.id, normalizeGenerationError(err)));
|
|
374
|
+
getOptions().onError?.(newRun, result.id);
|
|
375
|
+
}
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function submit(payload: ComposerSubmitPayload) {
|
|
382
|
+
const id = ++submitId;
|
|
383
|
+
controller?.abort();
|
|
384
|
+
const { signal } = (controller = new AbortController());
|
|
385
|
+
const toRoute = payload.autoSelectModel && payload.selections.some((sel) => sel.models.length > 0);
|
|
386
|
+
if (!toRoute) {
|
|
387
|
+
if (state.routing) set(() => ({ routing: false }));
|
|
388
|
+
start(payload, signal);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const opts = getOptions();
|
|
393
|
+
const r = reasoning();
|
|
394
|
+
if (!r) warnNoReasoningOnce("each request runs on the first listed model");
|
|
395
|
+
// Runs exist from the moment of submit, so a result card shows progress
|
|
396
|
+
// while the reasoning model chooses. Their results read "Choosing model".
|
|
397
|
+
const placeholders = payload.selections.map((sel) => {
|
|
398
|
+
const { request, isEdit } = buildRequest(payload, sel);
|
|
399
|
+
const modelIds = sel.models.length > 0 ? [ROUTING_MODEL_ID] : request.modelIds;
|
|
400
|
+
return { run: createRun({ ...request, modelIds }), isEdit };
|
|
401
|
+
});
|
|
402
|
+
set((s) => {
|
|
403
|
+
const next: Partial<ComposerState> = { routing: true, runs: placeholders.map((p) => p.run) };
|
|
404
|
+
const editPlaceholders = placeholders.filter((p) => p.isEdit).map((p) => p.run);
|
|
405
|
+
if (editPlaceholders.length > 0) {
|
|
406
|
+
const editRuns = s.editRuns.filter((run) => !placeholderIds.has(run.id));
|
|
407
|
+
for (const run of editPlaceholders) placeholderIds.add(run.id);
|
|
408
|
+
next.editRuns = [...editRuns, ...editPlaceholders];
|
|
409
|
+
next.activeVersion = 1 + editRuns.reduce((n, run) => n + run.results.length, 0);
|
|
410
|
+
}
|
|
411
|
+
return next;
|
|
412
|
+
});
|
|
413
|
+
void Promise.all(
|
|
414
|
+
payload.selections.map((sel) =>
|
|
415
|
+
sel.models.length === 0
|
|
416
|
+
? Promise.resolve(null)
|
|
417
|
+
: routeModel({
|
|
418
|
+
prompt: payload.value,
|
|
419
|
+
outputKind: sel.useCase.kind,
|
|
420
|
+
attachmentKinds: payload.attachments.map((a) => a.kind),
|
|
421
|
+
attachments: payload.attachments,
|
|
422
|
+
models: sel.models,
|
|
423
|
+
engine: r?.engine,
|
|
424
|
+
model: r && reasoningModelFor(r, payload.attachments),
|
|
425
|
+
routingPrompt: opts.routingPrompt,
|
|
426
|
+
signal,
|
|
427
|
+
})
|
|
428
|
+
)
|
|
429
|
+
).then((results) => {
|
|
430
|
+
if (id !== submitId || signal.aborted) return;
|
|
431
|
+
set(() => ({ routing: false }));
|
|
432
|
+
const latest = getOptions();
|
|
433
|
+
for (const res of results) if (res?.status === "fallback") latest.onRoutingFallback?.(res);
|
|
434
|
+
const routedIds = results.flatMap((res) => (res ? [res.modelId] : []));
|
|
435
|
+
if (latest.onModelIdsChange) latest.onModelIdsChange(routedIds);
|
|
436
|
+
else if (routedIds[0]) latest.onModelChange?.(routedIds[0]);
|
|
437
|
+
start(
|
|
438
|
+
{
|
|
439
|
+
...payload,
|
|
440
|
+
modelId: routedIds[0] ?? payload.modelId,
|
|
441
|
+
selections: payload.selections.map((sel, i) => ({
|
|
442
|
+
...sel,
|
|
443
|
+
modelIds: results[i] ? [results[i]!.modelId] : sel.modelIds,
|
|
444
|
+
})),
|
|
445
|
+
},
|
|
446
|
+
signal
|
|
447
|
+
);
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Stops the current submit: wire it to Composer's `onAbort`. Its
|
|
453
|
+
* unfinished results turn `"cancelled"` right away, and each engine
|
|
454
|
+
* cancels at the provider where it can (best effort: some providers keep
|
|
455
|
+
* running, and billing, once a request has started).
|
|
456
|
+
*/
|
|
457
|
+
function cancel() {
|
|
458
|
+
submitId += 1;
|
|
459
|
+
controller?.abort();
|
|
460
|
+
controller = null;
|
|
461
|
+
const cancelAll = (prev: Run[]) => prev.map((run) => run.results.reduce((acc, r) => cancelResult(acc, r.id), run));
|
|
462
|
+
set((s) => ({ routing: false, runs: cancelAll(s.runs), editRuns: cancelAll(s.editRuns) }));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Rewrites a prompt with the reasoning model. Only offer it when `reasoning()` is set. */
|
|
466
|
+
async function enhance(value: string): Promise<string> {
|
|
467
|
+
const r = reasoning();
|
|
468
|
+
if (!r) throw new Error("[chai-ui] Enhance needs a reasoning engine and model.");
|
|
469
|
+
set(() => ({ enhancing: true }));
|
|
470
|
+
try {
|
|
471
|
+
const prompt = (getOptions().enhancePrompt ?? defaultEnhancePrompt)(value, state.runs[0]?.request.mode);
|
|
472
|
+
const { src } = await r.engine.generate({ modelId: r.model, prompt, attachments: [] });
|
|
473
|
+
return src;
|
|
474
|
+
} finally {
|
|
475
|
+
set(() => ({ enhancing: false }));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const setActiveVersion = (activeVersion: number) => set(() => ({ activeVersion }));
|
|
480
|
+
const setRegions = (regions: EditRegion[]) => set(() => ({ regions }));
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The `EditCard` props for a state and the current `editImage`, or `null`
|
|
484
|
+
* without one. Pure, so a binding can call it while rendering with that
|
|
485
|
+
* render's options.
|
|
486
|
+
*/
|
|
487
|
+
function edit(s: ComposerState, editImage: DroppedMedia | null | undefined): ComposerEdit | null {
|
|
488
|
+
const image = imageOnly(editImage);
|
|
489
|
+
if (!image) return null;
|
|
490
|
+
const current = forImage(s, image);
|
|
491
|
+
const versions = versionsOf(current, image);
|
|
492
|
+
return {
|
|
493
|
+
versions,
|
|
494
|
+
activeVersion: Math.min(current.activeVersion, versions.length - 1),
|
|
495
|
+
onActiveVersionChange: setActiveVersion,
|
|
496
|
+
regions: current.regions,
|
|
497
|
+
onRegionsChange: setRegions,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
getState: () => state,
|
|
503
|
+
subscribe(listener: () => void) {
|
|
504
|
+
listeners.add(listener);
|
|
505
|
+
return () => void listeners.delete(listener);
|
|
506
|
+
},
|
|
507
|
+
submit,
|
|
508
|
+
cancel,
|
|
509
|
+
enhance,
|
|
510
|
+
edit,
|
|
511
|
+
reasoning,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export type ComposerStore = ReturnType<typeof createComposerStore>;
|
|
516
|
+
|
|
517
|
+
const warned = new Set<string>();
|
|
518
|
+
/** One console warning per message per page load: auto-select is on with no reasoning model to route with. */
|
|
519
|
+
export function warnNoReasoningOnce(consequence: string) {
|
|
520
|
+
if (warned.has(consequence)) return;
|
|
521
|
+
warned.add(consequence);
|
|
522
|
+
console.warn(
|
|
523
|
+
`[chai-ui] Auto-select model is on, but there's no reasoning model, so ${consequence}. Wrap your app in <ChaiProvider> to route with one.`
|
|
524
|
+
);
|
|
525
|
+
}
|
package/src/edit-card.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The EditCard's geometry, the same in every framework binding: region
|
|
3
|
+
* boxes, zoom and pan, where the instruction field goes, and the card's
|
|
4
|
+
* width. Boxes are fractions of the image (0 to 1); pan and sizes are px.
|
|
5
|
+
*/
|
|
6
|
+
import { uniqueId } from "./unique-id.js";
|
|
7
|
+
import { regionColor, type EditRegion, type RegionBox } from "./region-edit.js";
|
|
8
|
+
|
|
9
|
+
export const EDIT_ZOOM_STEPS = [1, 1.25, 1.5, 2, 3, 4];
|
|
10
|
+
const MIN_SIZE = 0.03;
|
|
11
|
+
const KEY_STEP = 0.01;
|
|
12
|
+
const KEY_STEP_LARGE = 0.05;
|
|
13
|
+
/** The instruction field's width, and a typical height (title row and a two-line field), for placing it. */
|
|
14
|
+
const POPOVER_WIDTH = 220;
|
|
15
|
+
const POPOVER_HEIGHT = 96;
|
|
16
|
+
|
|
17
|
+
export type RegionCorner = "nw" | "ne" | "sw" | "se";
|
|
18
|
+
export const REGION_CORNERS: RegionCorner[] = ["nw", "ne", "sw", "se"];
|
|
19
|
+
export interface Size {
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
}
|
|
23
|
+
export interface Pan {
|
|
24
|
+
x: number;
|
|
25
|
+
y: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));
|
|
29
|
+
|
|
30
|
+
/** Keeps a box inside the image and at least the minimum size. */
|
|
31
|
+
export function fitBox(box: RegionBox): RegionBox {
|
|
32
|
+
const width = clamp(box.width, MIN_SIZE, 1);
|
|
33
|
+
const height = clamp(box.height, MIN_SIZE, 1);
|
|
34
|
+
return { x: clamp(box.x, 0, 1 - width), y: clamp(box.y, 0, 1 - height), width, height };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A box resized by dragging one corner; the opposite corner stays put. */
|
|
38
|
+
export function resizeBox(box: RegionBox, corner: RegionCorner, dx: number, dy: number): RegionBox {
|
|
39
|
+
let left = box.x;
|
|
40
|
+
let top = box.y;
|
|
41
|
+
let right = box.x + box.width;
|
|
42
|
+
let bottom = box.y + box.height;
|
|
43
|
+
if (corner === "nw" || corner === "sw") left = clamp(left + dx, 0, right - MIN_SIZE);
|
|
44
|
+
else right = clamp(right + dx, left + MIN_SIZE, 1);
|
|
45
|
+
if (corner === "nw" || corner === "ne") top = clamp(top + dy, 0, bottom - MIN_SIZE);
|
|
46
|
+
else bottom = clamp(bottom + dy, top + MIN_SIZE, 1);
|
|
47
|
+
return { x: left, y: top, width: right - left, height: bottom - top };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A region's box after a key: arrows move it (Shift resizes, Alt takes bigger steps). `null` for any other key. */
|
|
51
|
+
export function regionBoxForKey(box: RegionBox, key: string, { shift = false, alt = false } = {}): RegionBox | null {
|
|
52
|
+
const step = alt ? KEY_STEP_LARGE : KEY_STEP;
|
|
53
|
+
const delta = ({ ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] } as Record<string, [number, number]>)[key];
|
|
54
|
+
if (!delta) return null;
|
|
55
|
+
const [dx, dy] = delta;
|
|
56
|
+
return shift ? resizeBox(box, "se", dx, dy) : fitBox({ ...box, x: box.x + dx, y: box.y + dy });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Pan limits at a zoom level: the image always covers the viewport. */
|
|
60
|
+
export function clampPan(pan: Pan, zoom: number, size: Size): Pan {
|
|
61
|
+
return {
|
|
62
|
+
x: clamp(pan.x, size.width - size.width * zoom, 0),
|
|
63
|
+
y: clamp(pan.y, size.height - size.height * zoom, 0),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The pan that zooms around the middle of the view, so what's centered stays centered. */
|
|
68
|
+
export function panForZoom(pan: Pan, zoom: number, next: number, size: Size): Pan {
|
|
69
|
+
const cx = size.width / 2;
|
|
70
|
+
const cy = size.height / 2;
|
|
71
|
+
const ratio = next / zoom;
|
|
72
|
+
return clampPan({ x: cx - (cx - pan.x) * ratio, y: cy - (cy - pan.y) * ratio }, next, size);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The pan after an arrow key on the zoomed image, or `null` for any other key. */
|
|
76
|
+
export function panForKey(pan: Pan, key: string, zoom: number, size: Size): Pan | null {
|
|
77
|
+
const step = 40;
|
|
78
|
+
const delta = ({ ArrowLeft: [step, 0], ArrowRight: [-step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] } as Record<string, [number, number]>)[key];
|
|
79
|
+
return delta ? clampPan({ x: pan.x + delta[0], y: pan.y + delta[1] }, zoom, size) : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The lowest number no current region uses, so freed numbers (and colors) come back first. */
|
|
83
|
+
export function freeRegionNumber(regions: EditRegion[]): number {
|
|
84
|
+
let n = 1;
|
|
85
|
+
while (regions.some((r) => r.number === n)) n += 1;
|
|
86
|
+
return n;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A new region, centered in the part of the image in view, so it's visible when zoomed in. */
|
|
90
|
+
export function newRegion(regions: EditRegion[], zoom: number, pan: Pan, size: Size): EditRegion {
|
|
91
|
+
const view = 1 / zoom;
|
|
92
|
+
const centerX = size.width ? -pan.x / (size.width * zoom) + view / 2 : 0.5;
|
|
93
|
+
const centerY = size.height ? -pan.y / (size.height * zoom) + view / 2 : 0.5;
|
|
94
|
+
const box = fitBox({ x: centerX - (0.3 * view) / 2, y: centerY - (0.2 * view) / 2, width: 0.3 * view, height: 0.2 * view });
|
|
95
|
+
return { id: uniqueId(), number: freeRegionNumber(regions), box, prompt: "" };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Where the instruction field goes: above its region, or below it when
|
|
100
|
+
* there isn't room above and it clears the controls along the bottom edge.
|
|
101
|
+
*/
|
|
102
|
+
export function regionPopoverPosition(box: RegionBox, zoom: number, pan: Pan, size: Size, paginated: boolean) {
|
|
103
|
+
const left = box.x * size.width * zoom + pan.x;
|
|
104
|
+
const top = box.y * size.height * zoom + pan.y;
|
|
105
|
+
const bottom = (box.y + box.height) * size.height * zoom + pan.y;
|
|
106
|
+
const reserved = paginated ? 100 : 56;
|
|
107
|
+
const fitsBelow = bottom + 8 + POPOVER_HEIGHT <= size.height - reserved;
|
|
108
|
+
const above = top > POPOVER_HEIGHT + 16 || !fitsBelow;
|
|
109
|
+
return {
|
|
110
|
+
left: clamp(left, 8, Math.max(8, size.width - POPOVER_WIDTH - 8)),
|
|
111
|
+
top: above ? Math.max(top - 8, POPOVER_HEIGHT + 8) : bottom + 8,
|
|
112
|
+
above,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The card's width: from `scale` (of the image's own width), else `width`,
|
|
118
|
+
* else `maxWidth`, then fitted inside `maxWidth` × `maxHeight` keeping the image's shape.
|
|
119
|
+
*/
|
|
120
|
+
export function editCardWidth(
|
|
121
|
+
natural: Size | null,
|
|
122
|
+
{ width, scale, maxWidth, maxHeight }: { width?: number; scale?: number; maxWidth: number; maxHeight: number }
|
|
123
|
+
): number {
|
|
124
|
+
const base = scale != null && natural ? natural.width * scale : (width ?? maxWidth);
|
|
125
|
+
const heightCap = natural && natural.height > 0 ? (maxHeight * natural.width) / natural.height : Infinity;
|
|
126
|
+
return Math.round(Math.min(base, maxWidth, heightCap));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A region's place on the image, as percentages, and its color. */
|
|
130
|
+
export function regionBoxStyle(r: EditRegion): Record<string, string> {
|
|
131
|
+
return {
|
|
132
|
+
left: `${r.box.x * 100}%`,
|
|
133
|
+
top: `${r.box.y * 100}%`,
|
|
134
|
+
width: `${r.box.width * 100}%`,
|
|
135
|
+
height: `${r.box.height * 100}%`,
|
|
136
|
+
"--chai-region-color": regionColor(r.number),
|
|
137
|
+
};
|
|
138
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,3 +8,10 @@ export * from "./model-routing.js";
|
|
|
8
8
|
export * from "./region-edit.js";
|
|
9
9
|
export * from "./suggested-models.js";
|
|
10
10
|
export * from "./check-suggested-models.js";
|
|
11
|
+
export * from "./reasoning.js";
|
|
12
|
+
export * from "./composer.js";
|
|
13
|
+
export * from "./media-analyzer.js";
|
|
14
|
+
export * from "./media-actions.js";
|
|
15
|
+
export * from "./result-format.js";
|
|
16
|
+
export * from "./composer-view.js";
|
|
17
|
+
export * from "./edit-card.js";
|