@chai-ui/core 0.6.1 → 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 +3 -2
- 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/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 +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -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 +47 -1
- package/dist/media-analyzer.d.ts.map +1 -1
- package/dist/media-analyzer.js +91 -0
- package/dist/media-analyzer.js.map +1 -1
- 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/edit-card.ts +138 -0
- package/src/index.ts +4 -0
- package/src/media-actions.ts +55 -0
- package/src/media-analyzer.ts +112 -1
- package/src/region-edit.ts +8 -0
- package/src/result-format.ts +34 -0
- package/src/unique-id.ts +6 -0
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
|
@@ -11,3 +11,7 @@ export * from "./check-suggested-models.js";
|
|
|
11
11
|
export * from "./reasoning.js";
|
|
12
12
|
export * from "./composer.js";
|
|
13
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";
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Download and share for a piece of generated media, shared by the cards in
|
|
3
|
+
* every framework binding.
|
|
4
|
+
*
|
|
5
|
+
* A plain `<a download href={remoteUrl}>` only forces a download for a
|
|
6
|
+
* same-origin or `blob:`/`data:` URL; for a cross-origin CDN URL (what a
|
|
7
|
+
* generated result usually is) browsers ignore `download` and open it
|
|
8
|
+
* instead. Fetching it into a blob first works wherever the source allows
|
|
9
|
+
* cross-origin fetches. When it doesn't, opening the media is the fallback.
|
|
10
|
+
*/
|
|
11
|
+
export async function downloadMedia(src: string, filename: string): Promise<void> {
|
|
12
|
+
try {
|
|
13
|
+
const response = await fetch(src);
|
|
14
|
+
const blob = await response.blob();
|
|
15
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
16
|
+
const a = document.createElement("a");
|
|
17
|
+
a.href = blobUrl;
|
|
18
|
+
a.download = filename;
|
|
19
|
+
document.body.appendChild(a);
|
|
20
|
+
a.click();
|
|
21
|
+
a.remove();
|
|
22
|
+
URL.revokeObjectURL(blobUrl);
|
|
23
|
+
} catch {
|
|
24
|
+
window.open(src, "_blank", "noopener");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The native share sheet where there is one, otherwise the media's URL
|
|
30
|
+
* copied to the clipboard. Says which, so the button can confirm a copy.
|
|
31
|
+
*/
|
|
32
|
+
export async function shareMedia(src: string): Promise<"shared" | "copied" | "none"> {
|
|
33
|
+
if (navigator.share) {
|
|
34
|
+
try {
|
|
35
|
+
await navigator.share({ url: src });
|
|
36
|
+
return "shared";
|
|
37
|
+
} catch {
|
|
38
|
+
// Dismissed, or not shareable: same as closing a native share sheet.
|
|
39
|
+
return "none";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
await navigator.clipboard.writeText(src);
|
|
44
|
+
return "copied";
|
|
45
|
+
} catch {
|
|
46
|
+
return "none";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A file name for a download: the URL's own extension when it has one, otherwise a guess by kind. */
|
|
51
|
+
export function downloadFilename(id: string, kind: string, src: string): string {
|
|
52
|
+
const match = /\.([a-zA-Z0-9]{2,5})(?:[?#]|$)/.exec(src);
|
|
53
|
+
const ext = match?.[1] ?? (kind === "video" ? "mp4" : kind === "audio" ? "mp3" : kind === "text" ? "txt" : "png");
|
|
54
|
+
return `${kind}-${id}.${ext}`;
|
|
55
|
+
}
|
package/src/media-analyzer.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* (`getState` + `subscribe`) that `useMediaAnalyzer` (React) and the Vue
|
|
7
7
|
* composable wrap.
|
|
8
8
|
*/
|
|
9
|
+
import { uniqueId } from "./unique-id.js";
|
|
9
10
|
import { warnNoReasoningOnce } from "./composer.js";
|
|
10
11
|
import { createMockEngine, isAbortError, normalizeGenerationError, type GenerationEngine } from "./engine.js";
|
|
11
12
|
import {
|
|
@@ -15,7 +16,7 @@ import {
|
|
|
15
16
|
} from "./media-analysis-prompts.js";
|
|
16
17
|
import { routeModel, type ModelRoutingInput, type ModelRoutingResult } from "./model-routing.js";
|
|
17
18
|
import { reasoningModelFor, type Reasoning } from "./reasoning.js";
|
|
18
|
-
import type { DroppedMedia, ModelOption } from "./types.js";
|
|
19
|
+
import type { DroppedMedia, MediaKind, ModelOption } from "./types.js";
|
|
19
20
|
|
|
20
21
|
export interface MediaAnalyzerAttachment extends DroppedMedia {
|
|
21
22
|
id: string;
|
|
@@ -180,3 +181,113 @@ export function createMediaAnalyzerStore(
|
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
export type MediaAnalyzerStore = ReturnType<typeof createMediaAnalyzerStore>;
|
|
184
|
+
|
|
185
|
+
// --- Attachments and menus: what the MediaAnalyzer component shows, the same in every framework binding.
|
|
186
|
+
|
|
187
|
+
const ACCEPTED_KINDS: MediaKind[] = ["image", "video", "audio"];
|
|
188
|
+
|
|
189
|
+
export const MEDIA_KIND_LABEL: Record<MediaKind, string> = { image: "Image", video: "Video", audio: "Audio", text: "Text" };
|
|
190
|
+
|
|
191
|
+
/** Clarifies what "Terse"/"Concise"/"Detailed" mean in the "Prompt length" menu's rows. UI copy, not sent to any model. */
|
|
192
|
+
export const PROMPT_LENGTH_HINT: Record<MediaAnalysisPromptLength, string> = {
|
|
193
|
+
terse: "short",
|
|
194
|
+
concise: "moderate",
|
|
195
|
+
detailed: "long",
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* How many attachments of each kind an analysis can hold at once. Image
|
|
200
|
+
* defaults to 5 (a sequence/series reads as one analysis); video and audio
|
|
201
|
+
* default to 1. Only one *kind* is ever active per analysis — these caps only
|
|
202
|
+
* bound how many of that one kind can pile up before the drop zone stops
|
|
203
|
+
* accepting more. `text` is output, never dropped: present only so this
|
|
204
|
+
* stays a total map.
|
|
205
|
+
*/
|
|
206
|
+
export const DEFAULT_MAX_ATTACHMENTS_BY_KIND: Record<MediaKind, number> = { image: 5, video: 1, audio: 1, text: 0 };
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Applies one newly-read file to the current attachment list. Only one media
|
|
210
|
+
* *kind* exists per analysis: a different kind replaces the whole list,
|
|
211
|
+
* never merges. Video and audio are single-slot (a same-kind drop replaces
|
|
212
|
+
* it); image fills up to `max`, then silently stops accepting more (the drop
|
|
213
|
+
* zone's "maximum added" look explains why).
|
|
214
|
+
*/
|
|
215
|
+
export function nextMediaAnalyzerAttachments(
|
|
216
|
+
current: MediaAnalyzerAttachment[],
|
|
217
|
+
incoming: DroppedMedia,
|
|
218
|
+
maxByKind: Record<MediaKind, number> = DEFAULT_MAX_ATTACHMENTS_BY_KIND
|
|
219
|
+
): MediaAnalyzerAttachment[] {
|
|
220
|
+
const existingKind = current[0]?.kind;
|
|
221
|
+
const withId: MediaAnalyzerAttachment = { ...incoming, id: uniqueId() };
|
|
222
|
+
if (!existingKind || existingKind !== incoming.kind) return [withId];
|
|
223
|
+
if (incoming.kind === "image") {
|
|
224
|
+
if (current.length >= (maxByKind.image ?? DEFAULT_MAX_ATTACHMENTS_BY_KIND.image)) return current;
|
|
225
|
+
return [...current, withId];
|
|
226
|
+
}
|
|
227
|
+
return [withId];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** True once a same-kind drop would be rejected: only ever for image, since video and audio replace. */
|
|
231
|
+
export function isMediaAnalyzerAtCap(
|
|
232
|
+
current: MediaAnalyzerAttachment[],
|
|
233
|
+
maxByKind: Record<MediaKind, number> = DEFAULT_MAX_ATTACHMENTS_BY_KIND
|
|
234
|
+
): boolean {
|
|
235
|
+
if (current[0]?.kind !== "image") return false;
|
|
236
|
+
return current.length >= (maxByKind.image ?? DEFAULT_MAX_ATTACHMENTS_BY_KIND.image);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Reads image, video and audio files as data URLs, all of them before
|
|
241
|
+
* returning, so a caller applies them in drop order in one go (applying
|
|
242
|
+
* each as its reader finishes would stack them on a stale list).
|
|
243
|
+
* `unsupported` names the files of another type, or that couldn't be read.
|
|
244
|
+
*/
|
|
245
|
+
export async function readMediaFiles(files: Iterable<File>): Promise<{ media: DroppedMedia[]; unsupported: string[] }> {
|
|
246
|
+
const unsupported: string[] = [];
|
|
247
|
+
const read = await Promise.all(
|
|
248
|
+
[...files].map((file) => {
|
|
249
|
+
const kind = ACCEPTED_KINDS.find((k) => k === file.type.split("/")[0]);
|
|
250
|
+
if (!kind) {
|
|
251
|
+
unsupported.push(file.name);
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
return new Promise<DroppedMedia | null>((resolve) => {
|
|
255
|
+
const reader = new FileReader();
|
|
256
|
+
reader.onload = () => resolve({ src: String(reader.result), kind, name: file.name, size: file.size });
|
|
257
|
+
reader.onerror = () => {
|
|
258
|
+
unsupported.push(file.name);
|
|
259
|
+
resolve(null);
|
|
260
|
+
};
|
|
261
|
+
reader.readAsDataURL(file);
|
|
262
|
+
});
|
|
263
|
+
})
|
|
264
|
+
);
|
|
265
|
+
return { media: read.filter((m): m is DroppedMedia => m !== null), unsupported };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** "Replaced 2 images with clip.wav.", or `null` when nothing was replaced. */
|
|
269
|
+
export function replacedNote(before: MediaAnalyzerAttachment[], after: MediaAnalyzerAttachment[]): string | null {
|
|
270
|
+
const removed = before.filter((a) => !after.some((n) => n.id === a.id));
|
|
271
|
+
const added = after.filter((n) => !before.some((a) => a.id === n.id));
|
|
272
|
+
if (removed.length === 0 || added.length === 0) return null;
|
|
273
|
+
const name = (a: MediaAnalyzerAttachment) => a.name ?? MEDIA_KIND_LABEL[a.kind].toLowerCase();
|
|
274
|
+
const describe = (list: MediaAnalyzerAttachment[]) =>
|
|
275
|
+
list.length === 1 ? name(list[0]!) : `${list.length} ${MEDIA_KIND_LABEL[list[0]!.kind].toLowerCase()}s`;
|
|
276
|
+
return `Replaced ${describe(removed)} with ${describe(added)}.`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The "Select model" menu's sections: once a kind is attached, only its
|
|
281
|
+
* models (falling back to `models`); before that, all three kinds, each
|
|
282
|
+
* under its own header.
|
|
283
|
+
*/
|
|
284
|
+
export function mediaAnalyzerModelSections(
|
|
285
|
+
kind: MediaKind | undefined,
|
|
286
|
+
modelsByKind: Partial<Record<MediaKind, ModelOption[]>>,
|
|
287
|
+
models: ModelOption[]
|
|
288
|
+
): { label: string; options: { id: string; label: string }[] }[] {
|
|
289
|
+
const options = (list: ModelOption[] = []) => list.map((m) => ({ id: m.id, label: m.label }));
|
|
290
|
+
return kind
|
|
291
|
+
? [{ label: MEDIA_KIND_LABEL[kind], options: options(modelsByKind[kind] ?? models) }]
|
|
292
|
+
: (["image", "video", "audio"] as const).map((k) => ({ label: MEDIA_KIND_LABEL[k], options: options(modelsByKind[k]) }));
|
|
293
|
+
}
|
package/src/region-edit.ts
CHANGED
|
@@ -113,3 +113,11 @@ export function buildRegionEditPrompt({
|
|
|
113
113
|
.filter(Boolean)
|
|
114
114
|
.join(" ");
|
|
115
115
|
}
|
|
116
|
+
|
|
117
|
+
/** How many region colors the tokens define (`--chai-color-semantic-region-1` to `-6`). */
|
|
118
|
+
const REGION_COLOR_COUNT = 6;
|
|
119
|
+
|
|
120
|
+
/** The color token for a region number. Numbers past the last color start over at the first. */
|
|
121
|
+
export function regionColor(number: number): string {
|
|
122
|
+
return `var(--chai-color-semantic-region-${((Math.max(1, number) - 1) % REGION_COLOR_COUNT) + 1})`;
|
|
123
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** How `ResultCard` shows a result's details, the same in every framework binding. */
|
|
2
|
+
import { ROUTING_MODEL_ID, type Result } from "./run.js";
|
|
3
|
+
import type { ModelOption } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export function formatDuration(ms: number | undefined): string | undefined {
|
|
6
|
+
return ms == null ? undefined : `${(ms / 1000).toFixed(2)}s`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function formatCost(usd: number | undefined): string | undefined {
|
|
10
|
+
return usd == null ? undefined : `$${usd.toFixed(2)}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatTokens(total: number | undefined): string | undefined {
|
|
14
|
+
return total == null ? undefined : `${total.toLocaleString()} tokens`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function gcd(a: number, b: number): number {
|
|
18
|
+
return b === 0 ? a : gcd(b, a % b);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A real, exactly-reduced ratio (e.g. 1024×768 → "4:3", 1920×1080 → "16:9") from the media's own measured dimensions — not a guess at the nearest "named" ratio, so an unusual crop just shows its own honest (if less familiar) reduced fraction instead of a misleading rounded one. */
|
|
22
|
+
export function formatAspectRatio(width: number, height: number): string | undefined {
|
|
23
|
+
if (!width || !height) return undefined;
|
|
24
|
+
const w = Math.round(width);
|
|
25
|
+
const h = Math.round(height);
|
|
26
|
+
const divisor = gcd(w, h);
|
|
27
|
+
return `${w / divisor}:${h / divisor}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The model's label from `models`, falling back to its raw id; "Choosing model…" while auto-select is still picking. */
|
|
31
|
+
export function resultModelLabel(result: Result, models?: ModelOption[]): string {
|
|
32
|
+
if (result.modelId === ROUTING_MODEL_ID) return "Choosing model…";
|
|
33
|
+
return models?.find((m) => m.id === result.modelId)?.label ?? result.modelId;
|
|
34
|
+
}
|
package/src/unique-id.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** A unique id for an attachment or region. Internal: not exported from the package. */
|
|
2
|
+
export function uniqueId(): string {
|
|
3
|
+
return typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
4
|
+
? crypto.randomUUID()
|
|
5
|
+
: `id-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
6
|
+
}
|