@chai-ui/vue 0.13.0 → 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chai-ui/vue",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Chai UI Vue components — smart, drop-in UI for AI apps: Composer, Media Analyzer, Result Card, Edit Card, and the useComposer composable.",
5
5
  "license": "MIT",
6
6
  "author": "kazarazat",
@@ -47,8 +47,8 @@
47
47
  "dependencies": {
48
48
  "@fontsource/roboto": "^5.3.0",
49
49
  "@material/web": "^2.5.0",
50
- "@chai-ui/core": "0.13.0",
51
- "@chai-ui/tokens": "0.13.0"
50
+ "@chai-ui/core": "0.14.0",
51
+ "@chai-ui/tokens": "0.14.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@vitejs/plugin-vue": "^6.0.9",
package/src/Composer.vue CHANGED
@@ -18,6 +18,7 @@ import "@material/web/fab/fab.js";
18
18
  import { computed, onScopeDispose, ref, shallowRef, watch } from "vue";
19
19
  import {
20
20
  composerAttachmentsFromFiles,
21
+ composerEvents,
21
22
  composerAttachMenuItems,
22
23
  composerView,
23
24
  createVoiceInput,
@@ -28,6 +29,7 @@ import {
28
29
  type ComposerSubmitPayload,
29
30
  type ComposerUseCase,
30
31
  type ComposerView,
32
+ type ChaiEvent,
31
33
  type EditRegion,
32
34
  type MediaKind,
33
35
  type MenuPlacement,
@@ -113,6 +115,8 @@ const props = withDefaults(
113
115
  submitting?: boolean;
114
116
  onAbort?: () => void;
115
117
  onAttachMenuSelect?: (action: ComposerAttachMenuAction) => void;
118
+ /** Front-end analytics (`@event`): what the person does in the bar, one `ChaiEvent` each, for any analytics tool. No prompt text or file contents, only lengths and counts. */
119
+ onEvent?: (event: ChaiEvent) => void;
116
120
  /** Which + menu items to show. Each still shows only when it can work. */
117
121
  attachMenuActions?: ComposerAttachMenuAction[];
118
122
  /** An edit's regions (from `useComposer`'s `edit`), shown as colored chips while editing. */
@@ -196,6 +200,7 @@ watch(
196
200
  );
197
201
 
198
202
  function pickModel(menu: ComposerView["modelMenus"][number], id: string) {
203
+ props.onEvent?.(composerEvents.modelPicked(menu.key, id));
199
204
  if (view.value.multiModel) props["onUpdate:modelIds"]?.(menu.next(id));
200
205
  else props["onUpdate:modelId"]?.(id);
201
206
  }
@@ -205,6 +210,7 @@ const enhancingField = ref<ComposerPromptField>("prompt");
205
210
  const enhanceField = (field: ComposerPromptField) =>
206
211
  props.onEnhance &&
207
212
  ((current: string) => {
213
+ props.onEvent?.(composerEvents.enhanceClicked(field, current.length));
208
214
  enhancingField.value = field;
209
215
  props.onEnhance?.(current, field, view.value.enhanceMode);
210
216
  });
@@ -227,20 +233,54 @@ onScopeDispose(() => {
227
233
  async function onFiles(files: FileList | null) {
228
234
  const onAttachments = props["onUpdate:attachments"];
229
235
  if (!files || files.length === 0 || !onAttachments) return;
236
+ // Counted now: the input is cleared for the next pick, which empties its live FileList.
237
+ const picked = files.length;
230
238
  // Edit mode works on one image: a new one replaces it, and the edit starts over.
231
239
  const { attachments, unsupported } = await composerAttachmentsFromFiles(files, props.attachments, view.value.editing);
232
240
  unsupported.forEach((name) => emit("unsupportedFile", name));
241
+ props.onEvent?.(composerEvents.mediaAttached(picked - unsupported.length, unsupported.length));
233
242
  if (attachments) onAttachments(attachments);
234
243
  }
235
244
 
236
245
  function removeUseCase(useCase: ComposerUseCase) {
246
+ props.onEvent?.(composerEvents.useCaseRemoved(useCase));
237
247
  if (props.multiSelectUseCases) props["onUpdate:useCases"]?.(props.useCases.filter((u) => u.kind !== useCase.kind));
238
248
  else props.onClearUseCase?.();
239
249
  }
240
250
 
241
251
  function onSubmitClick() {
242
- if (view.value.showStop) props.onAbort?.();
243
- else if (!view.value.isSubmitDisabled && !props.submitting) emit("submit", view.value.payload());
252
+ if (view.value.showStop) {
253
+ props.onEvent?.(composerEvents.stopped());
254
+ props.onAbort?.();
255
+ } else if (!view.value.isSubmitDisabled && !props.submitting) {
256
+ const payload = view.value.payload();
257
+ props.onEvent?.(composerEvents.submitted(payload, Boolean(props.submitError)));
258
+ emit("submit", payload);
259
+ }
260
+ }
261
+
262
+ function onMenuSelect(action: ComposerAttachMenuAction) {
263
+ if (action !== "add-media") props.onEvent?.(composerEvents.useCasePicked(action));
264
+ props.onAttachMenuSelect?.(action);
265
+ }
266
+
267
+ function toggleAutoSelect(on: boolean) {
268
+ props.onEvent?.(composerEvents.autoSelectToggled(on));
269
+ props["onUpdate:autoSelectModel"]?.(on);
270
+ }
271
+
272
+ function revertField(field: ComposerPromptField) {
273
+ props.onEvent?.(composerEvents.enhanceReverted(field));
274
+ }
275
+
276
+ function onVoiceStart() {
277
+ props.onEvent?.(composerEvents.voiceStarted());
278
+ }
279
+
280
+ /** An option menu's pick, reported, then passed to its handler. */
281
+ function pickOption(option: "aspect_ratio" | "voice" | "genre" | "frame_rate", id: string, apply: () => unknown) {
282
+ props.onEvent?.(composerEvents.optionPicked(option, id));
283
+ apply();
244
284
  }
245
285
  </script>
246
286
 
@@ -283,6 +323,7 @@ function onSubmitClick() {
283
323
  note="(required)"
284
324
  :hint="view.environmentHint"
285
325
  :on-enhance="enhanceField('environment')"
326
+ :on-revert="() => revertField('environment')"
286
327
  :enhancing="Boolean(enhancing) && enhancingField === 'environment'"
287
328
  :disabled="disabled"
288
329
  @change="emit('update:modelValue', $event)"
@@ -294,6 +335,7 @@ function onSubmitClick() {
294
335
  heading="Character"
295
336
  note="(optional)"
296
337
  :on-enhance="enhanceField('character')"
338
+ :on-revert="() => revertField('character')"
297
339
  :enhancing="Boolean(enhancing) && enhancingField === 'character'"
298
340
  :disabled="disabled"
299
341
  @change="props['onUpdate:character']?.($event)"
@@ -305,6 +347,7 @@ function onSubmitClick() {
305
347
  :placeholder="view.placeholder"
306
348
  :label="promptLabel"
307
349
  :on-enhance="enhanceField('prompt')"
350
+ :on-revert="() => revertField('prompt')"
308
351
  :enhancing="Boolean(enhancing)"
309
352
  :disabled="disabled"
310
353
  @change="emit('update:modelValue', $event)"
@@ -321,7 +364,7 @@ function onSubmitClick() {
321
364
  :images-only="view.editing"
322
365
  :disabled="disabled"
323
366
  :placement="menuPlacement"
324
- @select="onAttachMenuSelect?.($event)"
367
+ @select="onMenuSelect($event)"
325
368
  @files="onFiles"
326
369
  />
327
370
 
@@ -344,7 +387,7 @@ function onSubmitClick() {
344
387
  :multiple="menu.multiple"
345
388
  :toggle-header="
346
389
  showAutoSelectToggle && view.offersAutoSelect
347
- ? { label: 'Auto-select model', value: view.autoSelectModel, onChange: (v: boolean) => props['onUpdate:autoSelectModel']?.(v) }
390
+ ? { label: 'Auto-select model', value: view.autoSelectModel, onChange: toggleAutoSelect }
348
391
  : undefined
349
392
  "
350
393
  :trigger-label="menu.triggerLabel"
@@ -366,7 +409,7 @@ function onSubmitClick() {
366
409
  :searchable="false"
367
410
  trigger-variant="text"
368
411
  :disabled="disabled"
369
- @select="props['onUpdate:aspectRatio']?.($event.id)"
412
+ @select="pickOption('aspect_ratio', $event.id, () => props['onUpdate:aspectRatio']?.($event.id))"
370
413
  />
371
414
 
372
415
  <SearchMenu
@@ -379,7 +422,7 @@ function onSubmitClick() {
379
422
  :searchable="view.voiceOptions.length > view.searchableAt"
380
423
  trigger-variant="text"
381
424
  :disabled="disabled"
382
- @select="props['onUpdate:speechVoice']?.($event.id)"
425
+ @select="pickOption('voice', $event.id, () => props['onUpdate:speechVoice']?.($event.id))"
383
426
  />
384
427
 
385
428
  <SearchMenu
@@ -393,7 +436,7 @@ function onSubmitClick() {
393
436
  :searchable="view.genreOptions.length > view.searchableAt"
394
437
  trigger-variant="text"
395
438
  :disabled="disabled"
396
- @select="props['onUpdate:genres']?.(view.toggledGenres($event.id))"
439
+ @select="pickOption('genre', $event.id, () => props['onUpdate:genres']?.(view.toggledGenres($event.id)))"
397
440
  />
398
441
 
399
442
  <SearchMenu
@@ -406,12 +449,18 @@ function onSubmitClick() {
406
449
  :searchable="false"
407
450
  trigger-variant="text"
408
451
  :disabled="disabled"
409
- @select="props['onUpdate:frameRate']?.(Number($event.id))"
452
+ @select="pickOption('frame_rate', $event.id, () => props['onUpdate:frameRate']?.(Number($event.id)))"
410
453
  />
411
454
  </div>
412
455
 
413
456
  <div class="chai-composer__controls-end">
414
- <VoiceButton v-if="voiceInput" :voice="voice" :state="voiceState" :disabled="disabled" />
457
+ <VoiceButton
458
+ v-if="voiceInput"
459
+ :voice="voice"
460
+ :state="voiceState"
461
+ :disabled="disabled"
462
+ :on-start="onVoiceStart"
463
+ />
415
464
  <!-- MD3's md-fab has no disabled state of its own; Chai's unfilled-until-ready look comes from `data-ready` in style.css. `label=""` keeps it a plain icon FAB.
416
465
  Hidden while a recording is made or transcribed: the transcript is sent once it's in the prompt to check. -->
417
466
  <md-fab
@@ -13,8 +13,10 @@ import {
13
13
  formatCost,
14
14
  formatDuration,
15
15
  formatTokens,
16
+ resultCardEvents,
16
17
  resultModelLabel,
17
18
  shareMedia,
19
+ type ChaiEvent,
18
20
  type ModelOption,
19
21
  type Result,
20
22
  } from "@chai-ui/core";
@@ -46,6 +48,8 @@ const props = defineProps<{
46
48
  /** Extra items in the ⋯ menu. Omit to hide the menu. */
47
49
  moreActions?: ResultCardMoreAction[];
48
50
  disabled?: boolean;
51
+ /** Front-end analytics (`@event`): a result appearing, result actions, paging and opening the details side, one `ChaiEvent` each. */
52
+ onEvent?: (event: ChaiEvent) => void;
49
53
  }>();
50
54
  const emit = defineEmits<{
51
55
  /** `"like"`, `"dislike"`, `"download"`, `"share"`, `"copy-prompt"`, `"retry"`, or a `moreActions` id. */
@@ -98,7 +102,38 @@ const { slide, endSlide } = usePageSlide(() => ({
98
102
  image: kind.value === "image" ? result.value!.output!.src : undefined,
99
103
  }));
100
104
 
101
- const act = (action: string) => emit("action", action, result.value!);
105
+ /** A result action: reported, then emitted with the result it's for. */
106
+ function report(action: string, r: Result) {
107
+ props.onEvent?.(resultCardEvents.action(action));
108
+ emit("action", action, r);
109
+ }
110
+ const act = (action: string) => report(action, result.value!);
111
+
112
+ /** Paging to another result, reported. */
113
+ function goTo(next: number) {
114
+ if (next !== index.value) props.onEvent?.(resultCardEvents.paged(next, props.results.length));
115
+ activeIndex.value = next;
116
+ }
117
+
118
+ function openInfo() {
119
+ props.onEvent?.(resultCardEvents.infoOpened());
120
+ flipped.value = true;
121
+ }
122
+
123
+ // Each result reports once, the first time it's shown done or failed.
124
+ const shownIds = new Set<string>();
125
+ watch(
126
+ () => props.results,
127
+ (results) => {
128
+ for (const r of results) {
129
+ if ((r.status === "done" || r.status === "error") && !shownIds.has(r.id)) {
130
+ shownIds.add(r.id);
131
+ props.onEvent?.(resultCardEvents.shown(r.status, r.output?.kind, results.length));
132
+ }
133
+ }
134
+ },
135
+ { immediate: true, deep: true }
136
+ );
102
137
 
103
138
  function flash(flag: typeof copied) {
104
139
  flag.value = true;
@@ -114,14 +149,14 @@ function copyPrompt() {
114
149
  async function download() {
115
150
  const r = result.value!;
116
151
  if (r.output) await downloadMedia(r.output.src, downloadFilename(r.id, r.output.kind, r.output.src));
117
- emit("action", "download", r);
152
+ report("download", r);
118
153
  }
119
154
 
120
155
  async function share() {
121
156
  const r = result.value!;
122
157
  // With no share sheet the link is copied; say so, or the click looks like nothing happened.
123
158
  if (r.output && (await shareMedia(r.output.src)) === "copied") flash(linkCopied);
124
- emit("action", "share", r);
159
+ report("share", r);
125
160
  }
126
161
 
127
162
  // Flipping moves focus to the side now showing, so a keyboard user isn't left on a button that just turned away.
@@ -206,7 +241,7 @@ function onOverlayKeydown(e: KeyboardEvent) {
206
241
  :index="index"
207
242
  label="Results"
208
243
  :disabled="disabled"
209
- @update:index="activeIndex = $event"
244
+ @update:index="goTo($event)"
210
245
  />
211
246
  </div>
212
247
 
@@ -223,7 +258,7 @@ function onOverlayKeydown(e: KeyboardEvent) {
223
258
  <IconButton variant="pill" label="Retry" :disabled="disabled" @click="act('retry')"><RefreshIcon /></IconButton>
224
259
  <MoreMenu v-if="moreActions?.length" :actions="moreActions" :disabled="disabled" @select="act($event.id)" />
225
260
  </div>
226
- <IconButton variant="round" label="Show details" :disabled="disabled" @click="flipped = true"><InfoIcon /></IconButton>
261
+ <IconButton variant="round" label="Show details" :disabled="disabled" @click="openInfo"><InfoIcon /></IconButton>
227
262
  </div>
228
263
  </div>
229
264
  </div>
@@ -19,6 +19,8 @@ const props = defineProps<{
19
19
  /** Shown under a headed field, e.g. why submit can't go yet. */
20
20
  hint?: string | null;
21
21
  onEnhance?: (value: string) => void;
22
+ /** After an Enhance, the button's "Revert to original" click. */
23
+ onRevert?: () => void;
22
24
  enhancing: boolean;
23
25
  disabled: boolean;
24
26
  }>();
@@ -81,6 +83,7 @@ function onOptimizeClick() {
81
83
  enhancedValue.value = null;
82
84
  originalValue.value = null;
83
85
  if (original != null) emit("change", original);
86
+ props.onRevert?.();
84
87
  return;
85
88
  }
86
89
  originalValue.value = props.value; // for undo, before the rewrite lands
@@ -4,7 +4,7 @@ import { computed } from "vue";
4
4
  import type { VoiceInput, VoiceInputState } from "@chai-ui/core";
5
5
  import { MicIcon, StopIcon } from "../icons.js";
6
6
 
7
- const props = defineProps<{ voice: VoiceInput; state: VoiceInputState; disabled?: boolean }>();
7
+ const props = defineProps<{ voice: VoiceInput; state: VoiceInputState; disabled?: boolean; onStart?: () => void }>();
8
8
 
9
9
  const label = computed(() =>
10
10
  props.state.status === "recording"
@@ -20,6 +20,7 @@ function onClick() {
20
20
  else if (status === "transcribing") props.voice.cancel();
21
21
  else {
22
22
  props.voice.dismiss();
23
+ props.onStart?.();
23
24
  void props.voice.start();
24
25
  }
25
26
  }
package/src/index.ts CHANGED
@@ -51,6 +51,8 @@ export {
51
51
  combinesWithOthers,
52
52
  buildJudgePrompt,
53
53
  DEFAULT_EVALUATION_CHECKS,
54
+ composerEvents,
55
+ resultCardEvents,
54
56
  } from "@chai-ui/core";
55
57
  export type {
56
58
  AudioUseCase,
@@ -66,6 +68,8 @@ export type {
66
68
  Evaluation,
67
69
  EvaluationCheck,
68
70
  JudgePromptInput,
71
+ ChaiEvent,
72
+ ChaiEventName,
69
73
  GenerationEngine,
70
74
  GenerationUseCase,
71
75
  MediaAnalysisKind,