@chai-ui/vue 0.0.0-stage → 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/DESIGN.md +235 -0
- package/LICENSE +21 -0
- package/README.md +122 -2
- package/dist/ChaiProvider.vue.d.ts +24 -0
- package/dist/ChaiProvider.vue.d.ts.map +1 -0
- package/dist/Composer.vue.d.ts +94 -0
- package/dist/Composer.vue.d.ts.map +1 -0
- package/dist/EditCard.vue.d.ts +38 -0
- package/dist/EditCard.vue.d.ts.map +1 -0
- package/dist/MediaAnalyzer.vue.d.ts +54 -0
- package/dist/MediaAnalyzer.vue.d.ts.map +1 -0
- package/dist/PageSlide.vue.d.ts +23 -0
- package/dist/PageSlide.vue.d.ts.map +1 -0
- package/dist/ResultCard.vue.d.ts +29 -0
- package/dist/ResultCard.vue.d.ts.map +1 -0
- package/dist/composer/AttachMenu.vue.d.ts +17 -0
- package/dist/composer/AttachMenu.vue.d.ts.map +1 -0
- package/dist/composer/Chip.vue.d.ts +14 -0
- package/dist/composer/Chip.vue.d.ts.map +1 -0
- package/dist/dismiss.d.ts +4 -0
- package/dist/dismiss.d.ts.map +1 -0
- package/dist/edit-card/ExpandedImage.vue.d.ts +12 -0
- package/dist/edit-card/ExpandedImage.vue.d.ts.map +1 -0
- package/dist/edit-card/RegionShape.vue.d.ts +20 -0
- package/dist/edit-card/RegionShape.vue.d.ts.map +1 -0
- package/dist/icons.d.ts +43 -0
- package/dist/icons.d.ts.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2515 -0
- package/dist/index.js.map +1 -0
- package/dist/page-slide.d.ts +31 -0
- package/dist/page-slide.d.ts.map +1 -0
- package/dist/primitives/Pagination.vue.d.ts +26 -0
- package/dist/primitives/Pagination.vue.d.ts.map +1 -0
- package/dist/primitives/SearchMenu.vue.d.ts +51 -0
- package/dist/primitives/SearchMenu.vue.d.ts.map +1 -0
- package/dist/primitives/Toggle.vue.d.ts +16 -0
- package/dist/primitives/Toggle.vue.d.ts.map +1 -0
- package/dist/primitives/search-menu.d.ts +19 -0
- package/dist/primitives/search-menu.d.ts.map +1 -0
- package/dist/reasoning.d.ts +6 -0
- package/dist/reasoning.d.ts.map +1 -0
- package/dist/result-card/IconButton.vue.d.ts +21 -0
- package/dist/result-card/IconButton.vue.d.ts.map +1 -0
- package/dist/result-card/MoreMenu.vue.d.ts +13 -0
- package/dist/result-card/MoreMenu.vue.d.ts.map +1 -0
- package/dist/result-card/ResultMedia.vue.d.ts +28 -0
- package/dist/result-card/ResultMedia.vue.d.ts.map +1 -0
- package/dist/result-card/ResultText.vue.d.ts +8 -0
- package/dist/result-card/ResultText.vue.d.ts.map +1 -0
- package/dist/result-card/VideoPlayer.vue.d.ts +18 -0
- package/dist/result-card/VideoPlayer.vue.d.ts.map +1 -0
- package/dist/result-card/types.d.ts +9 -0
- package/dist/result-card/types.d.ts.map +1 -0
- package/dist/style.css +2378 -0
- package/dist/useComposer.d.ts +22 -0
- package/dist/useComposer.d.ts.map +1 -0
- package/dist/useMediaAnalyzer.d.ts +17 -0
- package/dist/useMediaAnalyzer.d.ts.map +1 -0
- package/package.json +79 -3
- package/src/ChaiProvider.vue +26 -0
- package/src/Composer.vue +362 -0
- package/src/EditCard.vue +520 -0
- package/src/MediaAnalyzer.vue +216 -0
- package/src/PageSlide.vue +36 -0
- package/src/ResultCard.vue +297 -0
- package/src/composer/AttachMenu.vue +66 -0
- package/src/composer/Chip.vue +26 -0
- package/src/dismiss.ts +22 -0
- package/src/edit-card/ExpandedImage.vue +39 -0
- package/src/edit-card/RegionShape.vue +39 -0
- package/src/icons.ts +55 -0
- package/src/index.ts +77 -0
- package/src/page-slide.ts +32 -0
- package/src/primitives/Pagination.vue +76 -0
- package/src/primitives/SearchMenu.vue +167 -0
- package/src/primitives/Toggle.vue +23 -0
- package/src/primitives/search-menu.ts +20 -0
- package/src/reasoning.ts +10 -0
- package/src/result-card/IconButton.vue +18 -0
- package/src/result-card/MoreMenu.vue +35 -0
- package/src/result-card/ResultMedia.vue +77 -0
- package/src/result-card/ResultText.vue +37 -0
- package/src/result-card/VideoPlayer.vue +90 -0
- package/src/result-card/types.ts +10 -0
- package/src/useComposer.ts +38 -0
- package/src/useMediaAnalyzer.ts +33 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Turns attached media into a prompt: a drop zone for images, a video or
|
|
4
|
+
* audio, a model menu with auto-select, a prompt-length menu, and submit.
|
|
5
|
+
* Pair with `useMediaAnalyzer`. Same props as React's `MediaAnalyzer`, with
|
|
6
|
+
* `v-model:attachments`, `v-model:model-id`, `v-model:auto-select-model`
|
|
7
|
+
* and `v-model:prompt-length`.
|
|
8
|
+
*/
|
|
9
|
+
import "@material/web/fab/fab.js";
|
|
10
|
+
import { computed, ref, watch } from "vue";
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_MAX_ATTACHMENTS_BY_KIND,
|
|
13
|
+
isMediaAnalyzerAtCap,
|
|
14
|
+
mediaAnalyzerModelSections,
|
|
15
|
+
nextMediaAnalyzerAttachments,
|
|
16
|
+
PROMPT_LENGTH_HINT,
|
|
17
|
+
readMediaFiles,
|
|
18
|
+
replacedNote,
|
|
19
|
+
SUGGESTED_ANALYSIS_MODELS,
|
|
20
|
+
type MediaAnalysisPromptLength,
|
|
21
|
+
type MediaAnalyzerAttachment,
|
|
22
|
+
type MediaAnalyzerSubmitPayload,
|
|
23
|
+
type MediaKind,
|
|
24
|
+
type ModelOption,
|
|
25
|
+
} from "@chai-ui/core";
|
|
26
|
+
import { ArrowForwardIcon, AudioIcon, MixedMediaIcon, RemoveIcon, StopIcon, VideoIcon } from "./icons.js";
|
|
27
|
+
import SearchMenu from "./primitives/SearchMenu.vue";
|
|
28
|
+
|
|
29
|
+
const props = withDefaults(
|
|
30
|
+
defineProps<{
|
|
31
|
+
attachments: MediaAnalyzerAttachment[];
|
|
32
|
+
maxAttachmentsByKind?: Record<MediaKind, number>;
|
|
33
|
+
/** Fallback roster for a kind `modelsByKind` doesn't list. */
|
|
34
|
+
models?: ModelOption[];
|
|
35
|
+
/** Per-kind rosters; once a kind is attached, only its models show. Defaults to Chai's suggested OpenRouter models. */
|
|
36
|
+
modelsByKind?: Partial<Record<MediaKind, ModelOption[]>>;
|
|
37
|
+
/** The picked model. While `autoSelectModel` is on it's `null` until a submit routes, then the routed model. */
|
|
38
|
+
modelId: string | null;
|
|
39
|
+
/** The "Auto-select model" switch inside the model menu. Turning it on clears the pick. */
|
|
40
|
+
autoSelectModel: boolean;
|
|
41
|
+
/** `null` until a person picks one: the trigger says "Prompt length" rather than pre-filling a choice. */
|
|
42
|
+
promptLength: MediaAnalysisPromptLength | null;
|
|
43
|
+
promptLengthOptions: { value: MediaAnalysisPromptLength; label: string }[];
|
|
44
|
+
disabled?: boolean;
|
|
45
|
+
submitting?: boolean;
|
|
46
|
+
submitError?: string | null;
|
|
47
|
+
/** With a listener, submit turns into stop while `submitting` (wire it to `useMediaAnalyzer`'s `cancel`); without one, submit is just disabled. */
|
|
48
|
+
onAbort?: () => void;
|
|
49
|
+
}>(),
|
|
50
|
+
{ maxAttachmentsByKind: () => DEFAULT_MAX_ATTACHMENTS_BY_KIND, models: () => [], modelsByKind: () => SUGGESTED_ANALYSIS_MODELS }
|
|
51
|
+
);
|
|
52
|
+
const emit = defineEmits<{
|
|
53
|
+
"update:attachments": [next: MediaAnalyzerAttachment[]];
|
|
54
|
+
"update:modelId": [id: string | null];
|
|
55
|
+
"update:autoSelectModel": [next: boolean];
|
|
56
|
+
"update:promptLength": [value: MediaAnalysisPromptLength];
|
|
57
|
+
unsupportedFile: [fileName: string];
|
|
58
|
+
submit: [payload: MediaAnalyzerSubmitPayload];
|
|
59
|
+
}>();
|
|
60
|
+
|
|
61
|
+
const input = ref<HTMLInputElement>();
|
|
62
|
+
// A count, not a boolean: dragenter/dragleave fire for every child the
|
|
63
|
+
// pointer crosses too, so a boolean would flicker off over the icon or text.
|
|
64
|
+
const dragDepth = ref(0);
|
|
65
|
+
// What the last attach replaced (one kind per analysis), shown until the
|
|
66
|
+
// attachments change some other way. Matched by ids: a parent's ref hands
|
|
67
|
+
// the list back as a reactive copy, never the same array.
|
|
68
|
+
const replaced = ref<{ note: string; ids: string } | null>(null);
|
|
69
|
+
const idsOf = (list: MediaAnalyzerAttachment[]) => list.map((a) => a.id).join();
|
|
70
|
+
|
|
71
|
+
const kind = computed(() => props.attachments[0]?.kind);
|
|
72
|
+
const atCap = computed(() => isMediaAnalyzerAtCap(props.attachments, props.maxAttachmentsByKind));
|
|
73
|
+
const sections = computed(() => mediaAnalyzerModelSections(kind.value, props.modelsByKind, props.models));
|
|
74
|
+
// Looked up across every section, so a pick made before any media is attached still names itself on the trigger.
|
|
75
|
+
const selectedModel = computed(() => sections.value.flatMap((s) => s.options).find((m) => m.id === props.modelId));
|
|
76
|
+
const modelTriggerLabel = computed(
|
|
77
|
+
() => selectedModel.value?.label ?? (props.autoSelectModel ? "Auto-select model" : "Select model")
|
|
78
|
+
);
|
|
79
|
+
const lengthOptions = computed(() =>
|
|
80
|
+
// The clarifying suffix shows only in the list, never on the trigger.
|
|
81
|
+
props.promptLengthOptions.map((o) => ({ id: o.value, label: o.label, rowLabel: `${o.label} (${PROMPT_LENGTH_HINT[o.value]})` }))
|
|
82
|
+
);
|
|
83
|
+
const lengthLabel = computed(() => props.promptLengthOptions.find((o) => o.value === props.promptLength)?.label ?? "Prompt length");
|
|
84
|
+
const isSubmitDisabled = computed(() => props.disabled || props.attachments.length === 0);
|
|
85
|
+
const showStop = computed(() => Boolean(props.submitting && props.onAbort));
|
|
86
|
+
|
|
87
|
+
// Auto-select chooses at submit, so turning it on clears the current pick
|
|
88
|
+
// and the trigger says what will happen until it does.
|
|
89
|
+
watch(
|
|
90
|
+
() => props.autoSelectModel,
|
|
91
|
+
(on, was) => {
|
|
92
|
+
if (on && !was) emit("update:modelId", null);
|
|
93
|
+
}
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
/** Reads every file first, then applies them in drop order and commits once. */
|
|
97
|
+
async function handleFiles(files: FileList | null | undefined) {
|
|
98
|
+
if (!files || files.length === 0) return;
|
|
99
|
+
const { media, unsupported } = await readMediaFiles(files);
|
|
100
|
+
unsupported.forEach((name) => emit("unsupportedFile", name));
|
|
101
|
+
if (media.length === 0) return;
|
|
102
|
+
const next = media.reduce((acc, m) => nextMediaAnalyzerAttachments(acc, m, props.maxAttachmentsByKind), props.attachments);
|
|
103
|
+
const note = replacedNote(props.attachments, next);
|
|
104
|
+
replaced.value = note ? { note, ids: idsOf(next) } : null;
|
|
105
|
+
emit("update:attachments", next);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function onDrop(e: DragEvent) {
|
|
109
|
+
dragDepth.value = 0;
|
|
110
|
+
if (!props.disabled) void handleFiles(e.dataTransfer?.files);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function onFileChange(e: Event) {
|
|
114
|
+
const el = e.target as HTMLInputElement;
|
|
115
|
+
void handleFiles(el.files);
|
|
116
|
+
el.value = "";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function remove(id: string) {
|
|
120
|
+
emit(
|
|
121
|
+
"update:attachments",
|
|
122
|
+
props.attachments.filter((a) => a.id !== id)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function onSubmitClick() {
|
|
127
|
+
if (showStop.value) props.onAbort?.();
|
|
128
|
+
else if (!isSubmitDisabled.value && !props.submitting) {
|
|
129
|
+
emit("submit", {
|
|
130
|
+
attachments: props.attachments,
|
|
131
|
+
modelId: props.modelId,
|
|
132
|
+
autoSelectModel: props.autoSelectModel,
|
|
133
|
+
models: kind.value ? (props.modelsByKind[kind.value] ?? props.models) : [],
|
|
134
|
+
promptLength: props.promptLength,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
</script>
|
|
139
|
+
|
|
140
|
+
<template>
|
|
141
|
+
<div class="chai-media-analyzer">
|
|
142
|
+
<div
|
|
143
|
+
:class="[
|
|
144
|
+
'chai-media-analyzer__dropzone',
|
|
145
|
+
{ 'chai-media-analyzer__dropzone--disabled': atCap, 'chai-media-analyzer__dropzone--drag-active': dragDepth > 0 },
|
|
146
|
+
]"
|
|
147
|
+
@dragenter.prevent="dragDepth++"
|
|
148
|
+
@dragleave.prevent="dragDepth = Math.max(0, dragDepth - 1)"
|
|
149
|
+
@dragover.prevent
|
|
150
|
+
@drop.prevent="onDrop"
|
|
151
|
+
>
|
|
152
|
+
<span class="chai-media-analyzer__dropzone-icon" aria-hidden="true"><MixedMediaIcon /></span>
|
|
153
|
+
<!-- `atCap` is only ever true for image. -->
|
|
154
|
+
<p class="chai-media-analyzer__dropzone-hint">{{ atCap ? "Maximum images added" : "Add media to convert to prompt" }}</p>
|
|
155
|
+
<button type="button" class="chai-chip-button" :disabled="disabled" @click="input?.click()">Select files</button>
|
|
156
|
+
<input ref="input" type="file" accept="image/*,video/*,audio/*" multiple hidden :disabled="disabled" @change="onFileChange" />
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<div v-if="attachments.length > 0" class="chai-media-analyzer__thumbnails">
|
|
160
|
+
<div v-for="a in attachments" :key="a.id" class="chai-media-analyzer__thumb">
|
|
161
|
+
<img v-if="a.kind === 'image'" class="chai-media-analyzer__thumb-img" :src="a.src" alt="" />
|
|
162
|
+
<span v-else class="chai-media-analyzer__thumb-icon"><VideoIcon v-if="a.kind === 'video'" /><AudioIcon v-else /></span>
|
|
163
|
+
<button type="button" class="chai-media-analyzer__thumb-remove" :aria-label="`Remove ${a.name ?? a.kind}`" @click="remove(a.id)">
|
|
164
|
+
<RemoveIcon />
|
|
165
|
+
</button>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<p v-if="replaced && replaced.ids === idsOf(attachments)" class="chai-media-analyzer__note" role="status">{{ replaced.note }}</p>
|
|
170
|
+
<p v-if="submitError" class="chai-media-analyzer__error" role="alert">{{ submitError }}</p>
|
|
171
|
+
|
|
172
|
+
<div class="chai-media-analyzer__controls">
|
|
173
|
+
<div class="chai-media-analyzer__controls-start">
|
|
174
|
+
<SearchMenu
|
|
175
|
+
:options="sections.length === 1 ? sections[0]!.options : undefined"
|
|
176
|
+
:sections="sections.length > 1 ? sections : undefined"
|
|
177
|
+
:toggle-header="{ label: 'Auto-select model', value: autoSelectModel, onChange: (next) => emit('update:autoSelectModel', next) }"
|
|
178
|
+
:value="modelId"
|
|
179
|
+
:trigger-label="modelTriggerLabel"
|
|
180
|
+
menu-label="Select model"
|
|
181
|
+
:searchable="false"
|
|
182
|
+
trigger-variant="text"
|
|
183
|
+
:disabled="disabled"
|
|
184
|
+
:panel-width="320"
|
|
185
|
+
@select="emit('update:modelId', $event.id)"
|
|
186
|
+
/>
|
|
187
|
+
<SearchMenu
|
|
188
|
+
:options="lengthOptions"
|
|
189
|
+
:value="promptLength"
|
|
190
|
+
:trigger-label="lengthLabel"
|
|
191
|
+
menu-label="Prompt length"
|
|
192
|
+
:searchable="false"
|
|
193
|
+
trigger-variant="text"
|
|
194
|
+
:disabled="disabled"
|
|
195
|
+
@select="emit('update:promptLength', $event.id as MediaAnalysisPromptLength)"
|
|
196
|
+
/>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<!-- The same unfilled-until-ready submit as Composer's FAB. -->
|
|
200
|
+
<md-fab
|
|
201
|
+
class="chai-media-analyzer__submit"
|
|
202
|
+
size="small"
|
|
203
|
+
variant="secondary"
|
|
204
|
+
label=""
|
|
205
|
+
:data-ready="showStop || !isSubmitDisabled || undefined"
|
|
206
|
+
:data-error="Boolean(submitError) || undefined"
|
|
207
|
+
:data-stop="showStop || undefined"
|
|
208
|
+
:aria-label="showStop ? 'Stop' : submitError ? 'Retry' : 'Analyze media'"
|
|
209
|
+
:aria-disabled="showStop ? 'false' : submitting || isSubmitDisabled ? 'true' : 'false'"
|
|
210
|
+
@click="onSubmitClick"
|
|
211
|
+
>
|
|
212
|
+
<span slot="icon"><StopIcon v-if="showStop" /><ArrowForwardIcon v-else /></span>
|
|
213
|
+
</md-fab>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
</template>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Renders a page with its slide. The outgoing image stays in the layout
|
|
4
|
+
* while it slides away, so the card holds its height even while the new
|
|
5
|
+
* image is still loading, and the new page slides in over that space. The
|
|
6
|
+
* page keeps its key once the slide ends, so it isn't remounted (no
|
|
7
|
+
* reloaded image, no restarted video).
|
|
8
|
+
*/
|
|
9
|
+
import { computed } from "vue";
|
|
10
|
+
import type { Slide } from "./page-slide.js";
|
|
11
|
+
|
|
12
|
+
const props = defineProps<{ slide: Slide | null; pageClass?: string }>();
|
|
13
|
+
const emit = defineEmits<{ end: [] }>();
|
|
14
|
+
|
|
15
|
+
const side = computed(() => (props.slide?.forward ? "left" : "right"));
|
|
16
|
+
const outgoing = computed(() => (props.slide?.active ? props.slide.from : undefined));
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<img
|
|
21
|
+
v-if="outgoing"
|
|
22
|
+
:key="`out-${slide!.key}`"
|
|
23
|
+
:class="`chai-slide-out chai-slide-out--${side}`"
|
|
24
|
+
:src="outgoing"
|
|
25
|
+
alt=""
|
|
26
|
+
aria-hidden="true"
|
|
27
|
+
draggable="false"
|
|
28
|
+
/>
|
|
29
|
+
<div
|
|
30
|
+
:key="`in-${slide?.key ?? 0}`"
|
|
31
|
+
:class="[pageClass, slide?.active && `chai-slide-in chai-slide-in--${side}`, outgoing && 'chai-slide-in--over']"
|
|
32
|
+
@animationend.self="emit('end')"
|
|
33
|
+
>
|
|
34
|
+
<slot />
|
|
35
|
+
</div>
|
|
36
|
+
</template>
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The Composer bar's complement, and the last stop in the pipeline: the
|
|
4
|
+
* results from one prompt, one at a time, paged with the dots when there
|
|
5
|
+
* are several. Flips to show the prompt and details. Same props as React's
|
|
6
|
+
* `ResultCard`; `onAction` is the `action` event.
|
|
7
|
+
*/
|
|
8
|
+
import { computed, nextTick, reactive, ref, watch } from "vue";
|
|
9
|
+
import {
|
|
10
|
+
downloadFilename,
|
|
11
|
+
downloadMedia,
|
|
12
|
+
formatAspectRatio,
|
|
13
|
+
formatCost,
|
|
14
|
+
formatDuration,
|
|
15
|
+
formatTokens,
|
|
16
|
+
resultModelLabel,
|
|
17
|
+
shareMedia,
|
|
18
|
+
type ModelOption,
|
|
19
|
+
type Result,
|
|
20
|
+
} from "@chai-ui/core";
|
|
21
|
+
import { CheckIcon, CloseIcon, CopyIcon, InfoIcon, RefreshIcon, ThumbDownIcon, ThumbUpIcon } from "./icons.js";
|
|
22
|
+
import PageSlide from "./PageSlide.vue";
|
|
23
|
+
import { usePageSlide } from "./page-slide.js";
|
|
24
|
+
import Pagination from "./primitives/Pagination.vue";
|
|
25
|
+
import IconButton from "./result-card/IconButton.vue";
|
|
26
|
+
import MoreMenu from "./result-card/MoreMenu.vue";
|
|
27
|
+
import ResultMedia from "./result-card/ResultMedia.vue";
|
|
28
|
+
import VideoPlayer from "./result-card/VideoPlayer.vue";
|
|
29
|
+
import type { ResultCardMoreAction, ResultCardVote } from "./result-card/types.js";
|
|
30
|
+
|
|
31
|
+
const props = defineProps<{
|
|
32
|
+
/** The results from one prompt: `results[0]` if there's only one, paged internally if more. */
|
|
33
|
+
results: Result[];
|
|
34
|
+
/** Fixed width in px. Defaults to 375; height follows the media. */
|
|
35
|
+
width?: number;
|
|
36
|
+
/** Max height in px of a text result before it scrolls. Defaults to 400. */
|
|
37
|
+
textMaxHeight?: number;
|
|
38
|
+
/** The request's prompt, shown read-only (copiable) on the info side. */
|
|
39
|
+
prompt: string;
|
|
40
|
+
/** Alt text for a generated image. Defaults to `prompt`. */
|
|
41
|
+
altText?: (result: Result) => string;
|
|
42
|
+
/** Resolves `Result.modelId` to a label, on the info side and beside the page dots. */
|
|
43
|
+
models?: ModelOption[];
|
|
44
|
+
/** A result's vote, if any. Controlled: what a vote means is the app's policy. */
|
|
45
|
+
getVote?: (result: Result) => ResultCardVote;
|
|
46
|
+
/** Extra items in the ⋯ menu. Omit to hide the menu. */
|
|
47
|
+
moreActions?: ResultCardMoreAction[];
|
|
48
|
+
disabled?: boolean;
|
|
49
|
+
}>();
|
|
50
|
+
const emit = defineEmits<{
|
|
51
|
+
/** `"like"`, `"dislike"`, `"download"`, `"share"`, `"copy-prompt"`, `"retry"`, or a `moreActions` id. */
|
|
52
|
+
action: [action: string, result: Result];
|
|
53
|
+
}>();
|
|
54
|
+
|
|
55
|
+
const activeIndex = ref(0);
|
|
56
|
+
const flipped = ref(false);
|
|
57
|
+
const copied = ref(false);
|
|
58
|
+
const linkCopied = ref(false);
|
|
59
|
+
const mediaExpanded = ref(false);
|
|
60
|
+
// Measured off the loaded media for the aspect-ratio chip, with the id it
|
|
61
|
+
// was measured from, so a stale ratio never shows after paging.
|
|
62
|
+
const measured = ref<{ id: string; width: number; height: number } | null>(null);
|
|
63
|
+
const videoTime = reactive({ current: 0 });
|
|
64
|
+
const videoWasPlaying = ref(false);
|
|
65
|
+
const front = ref<HTMLElement>();
|
|
66
|
+
const back = ref<HTMLElement>();
|
|
67
|
+
const overlay = ref<HTMLElement>();
|
|
68
|
+
|
|
69
|
+
const index = computed(() => Math.min(activeIndex.value, Math.max(0, props.results.length - 1)));
|
|
70
|
+
const result = computed(() => props.results[index.value]);
|
|
71
|
+
const kind = computed(() => result.value?.output?.kind);
|
|
72
|
+
const paginated = computed(() => props.results.length > 1);
|
|
73
|
+
// An image or video runs under a dark fade into the bar; text, audio and status pages keep the plain surface.
|
|
74
|
+
const overMedia = computed(() => kind.value === "image" || kind.value === "video");
|
|
75
|
+
const vote = computed(() => (result.value && props.getVote?.(result.value)) ?? null);
|
|
76
|
+
const modelLabel = computed(() => (result.value ? resultModelLabel(result.value, props.models) : ""));
|
|
77
|
+
const alt = computed(() => (result.value ? (props.altText?.(result.value) ?? props.prompt) : ""));
|
|
78
|
+
const chips = computed(() => {
|
|
79
|
+
const r = result.value;
|
|
80
|
+
const dims = measured.value?.id === r?.id ? measured.value : null;
|
|
81
|
+
return [
|
|
82
|
+
modelLabel.value,
|
|
83
|
+
formatTokens(r?.usage?.totalTokens),
|
|
84
|
+
formatCost(r?.usage?.costUsd),
|
|
85
|
+
formatDuration(r?.durationMs),
|
|
86
|
+
dims ? formatAspectRatio(dims.width, dims.height) : undefined,
|
|
87
|
+
].filter(Boolean) as string[];
|
|
88
|
+
});
|
|
89
|
+
const cardStyle = computed(() => ({
|
|
90
|
+
...(props.width != null && { "--chai-result-card-width": `${props.width}px` }),
|
|
91
|
+
...(props.textMaxHeight != null && { "--chai-result-card-text-max-height": `${props.textMaxHeight}px` }),
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
// Paging slides, with the last image sliding out (other kinds just slide in).
|
|
95
|
+
const { slide, endSlide } = usePageSlide(() => ({
|
|
96
|
+
id: result.value?.id,
|
|
97
|
+
index: index.value,
|
|
98
|
+
image: kind.value === "image" ? result.value!.output!.src : undefined,
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
const act = (action: string) => emit("action", action, result.value!);
|
|
102
|
+
|
|
103
|
+
function flash(flag: typeof copied) {
|
|
104
|
+
flag.value = true;
|
|
105
|
+
setTimeout(() => (flag.value = false), 1500);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function copyPrompt() {
|
|
109
|
+
navigator.clipboard?.writeText(props.prompt).catch(() => {});
|
|
110
|
+
flash(copied);
|
|
111
|
+
act("copy-prompt");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function download() {
|
|
115
|
+
const r = result.value!;
|
|
116
|
+
if (r.output) await downloadMedia(r.output.src, downloadFilename(r.id, r.output.kind, r.output.src));
|
|
117
|
+
emit("action", "download", r);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function share() {
|
|
121
|
+
const r = result.value!;
|
|
122
|
+
// With no share sheet the link is copied; say so, or the click looks like nothing happened.
|
|
123
|
+
if (r.output && (await shareMedia(r.output.src)) === "copied") flash(linkCopied);
|
|
124
|
+
emit("action", "share", r);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Flipping moves focus to the side now showing, so a keyboard user isn't left on a button that just turned away.
|
|
128
|
+
watch(
|
|
129
|
+
flipped,
|
|
130
|
+
(isFlipped) => {
|
|
131
|
+
const target = isFlipped
|
|
132
|
+
? back.value?.querySelector<HTMLElement>('[aria-label="Close details"]')
|
|
133
|
+
: front.value?.querySelector<HTMLElement>('[aria-label="Show details"]');
|
|
134
|
+
target?.focus();
|
|
135
|
+
},
|
|
136
|
+
{ flush: "post" }
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// The expanded view is a modal dialog: focus moves in, Tab stays inside,
|
|
140
|
+
// Escape closes, and focus returns to Expand, the only way in.
|
|
141
|
+
watch(mediaExpanded, async (open) => {
|
|
142
|
+
await nextTick();
|
|
143
|
+
if (open) overlay.value?.querySelector<HTMLElement>(".chai-result-card__collapse")?.focus();
|
|
144
|
+
else front.value?.querySelector<HTMLElement>('[aria-label="Expand"]')?.focus();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
function onOverlayKeydown(e: KeyboardEvent) {
|
|
148
|
+
if (e.key === "Escape") {
|
|
149
|
+
e.stopPropagation();
|
|
150
|
+
mediaExpanded.value = false;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (e.key !== "Tab") return;
|
|
154
|
+
const focusable = [
|
|
155
|
+
...(e.currentTarget as HTMLElement).querySelectorAll<HTMLElement>('button, [tabindex="0"], a[href], input, video[controls]'),
|
|
156
|
+
].filter((el) => !el.hasAttribute("disabled"));
|
|
157
|
+
const first = focusable[0];
|
|
158
|
+
const last = focusable[focusable.length - 1];
|
|
159
|
+
if (!first || !last) return;
|
|
160
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
161
|
+
e.preventDefault();
|
|
162
|
+
last.focus();
|
|
163
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
first.focus();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
</script>
|
|
169
|
+
|
|
170
|
+
<template>
|
|
171
|
+
<template v-if="result">
|
|
172
|
+
<div :class="['chai-result-card', { 'chai-result-card--flipped': flipped }]" :style="cardStyle">
|
|
173
|
+
<div class="chai-result-card__inner">
|
|
174
|
+
<!-- The face turned away is inert, so its buttons can't be tabbed to. -->
|
|
175
|
+
<div
|
|
176
|
+
ref="front"
|
|
177
|
+
:class="['chai-result-card__face chai-result-card__front', { 'chai-result-card__front--paginated': paginated }]"
|
|
178
|
+
:aria-hidden="flipped"
|
|
179
|
+
:inert="flipped"
|
|
180
|
+
>
|
|
181
|
+
<div class="chai-result-card__media-frame">
|
|
182
|
+
<PageSlide :slide="slide" page-class="chai-result-card__media-page" @end="endSlide">
|
|
183
|
+
<ResultMedia
|
|
184
|
+
:result="result"
|
|
185
|
+
:alt="alt"
|
|
186
|
+
:disabled="disabled"
|
|
187
|
+
:media-expanded="mediaExpanded"
|
|
188
|
+
:link-copied="linkCopied"
|
|
189
|
+
:video-time="videoTime"
|
|
190
|
+
:video-was-playing="videoWasPlaying"
|
|
191
|
+
@download="download"
|
|
192
|
+
@share="share"
|
|
193
|
+
@expand="mediaExpanded = true"
|
|
194
|
+
@dimensions="(w, h) => (measured = { id: result!.id, width: w, height: h })"
|
|
195
|
+
@video-playing-change="videoWasPlaying = $event"
|
|
196
|
+
/>
|
|
197
|
+
</PageSlide>
|
|
198
|
+
<div v-if="paginated && overMedia" class="chai-card-fade" />
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<!-- With several results, the page dots are the card's last row, with the model that made this page on the left. -->
|
|
202
|
+
<div v-if="paginated" :class="['chai-card-pager-bar', { 'chai-card-pager-bar--over-media': overMedia }]">
|
|
203
|
+
<span class="chai-card-pager-label">{{ modelLabel }}</span>
|
|
204
|
+
<Pagination
|
|
205
|
+
:count="results.length"
|
|
206
|
+
:index="index"
|
|
207
|
+
label="Results"
|
|
208
|
+
:disabled="disabled"
|
|
209
|
+
@update:index="activeIndex = $event"
|
|
210
|
+
/>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<!-- Floating over the bottom of the media: votes and retry in one pill, details on the right. -->
|
|
214
|
+
<div class="chai-card-bottom">
|
|
215
|
+
<div class="chai-card-actions">
|
|
216
|
+
<div class="chai-card-pill">
|
|
217
|
+
<IconButton variant="pill" label="Like" :active="vote === 'like'" :disabled="disabled" @click="act('like')">
|
|
218
|
+
<ThumbUpIcon />
|
|
219
|
+
</IconButton>
|
|
220
|
+
<IconButton variant="pill" label="Dislike" :active="vote === 'dislike'" :disabled="disabled" @click="act('dislike')">
|
|
221
|
+
<ThumbDownIcon />
|
|
222
|
+
</IconButton>
|
|
223
|
+
<IconButton variant="pill" label="Retry" :disabled="disabled" @click="act('retry')"><RefreshIcon /></IconButton>
|
|
224
|
+
<MoreMenu v-if="moreActions?.length" :actions="moreActions" :disabled="disabled" @select="act($event.id)" />
|
|
225
|
+
</div>
|
|
226
|
+
<IconButton variant="round" label="Show details" :disabled="disabled" @click="flipped = true"><InfoIcon /></IconButton>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<div ref="back" class="chai-result-card__face chai-result-card__back" :aria-hidden="!flipped" :inert="!flipped">
|
|
232
|
+
<button type="button" class="chai-result-card__close" aria-label="Close details" @click="flipped = false">
|
|
233
|
+
<CloseIcon />
|
|
234
|
+
</button>
|
|
235
|
+
<div class="chai-result-card__prompt-box">
|
|
236
|
+
<p class="chai-result-card__prompt-text">{{ prompt }}</p>
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
class="chai-result-card__copy"
|
|
240
|
+
:aria-label="copied ? 'Copied' : 'Copy prompt'"
|
|
241
|
+
:title="copied ? 'Copied' : 'Copy prompt'"
|
|
242
|
+
@click="copyPrompt"
|
|
243
|
+
>
|
|
244
|
+
<CheckIcon v-if="copied" /><CopyIcon v-else />
|
|
245
|
+
</button>
|
|
246
|
+
</div>
|
|
247
|
+
<div class="chai-result-card__chips">
|
|
248
|
+
<span v-for="(chip, i) in chips" :key="i" class="chai-result-card__chip">{{ chip }}</span>
|
|
249
|
+
<!-- The builder's own backend checks: pass green with a check, fail red with a cross, so the verdict never relies on color alone. -->
|
|
250
|
+
<span
|
|
251
|
+
v-for="evaluation in result.evaluations"
|
|
252
|
+
:key="evaluation.id"
|
|
253
|
+
:class="[
|
|
254
|
+
'chai-result-card__chip chai-result-card__chip--evaluation',
|
|
255
|
+
`chai-result-card__chip--${evaluation.passed ? 'passed' : 'failed'}`,
|
|
256
|
+
]"
|
|
257
|
+
:title="`${evaluation.label}: ${evaluation.passed ? 'passed' : 'failed'}`"
|
|
258
|
+
>
|
|
259
|
+
<CheckIcon v-if="evaluation.passed" /><CloseIcon v-else />{{ evaluation.label
|
|
260
|
+
}}<span class="chai-visually-hidden">{{ evaluation.passed ? ", passed" : ", failed" }}</span>
|
|
261
|
+
</span>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<!-- The expanded view is the card's sibling, not its child: the card's
|
|
268
|
+
`perspective` (for the flip) would make a nested `position: fixed`
|
|
269
|
+
overlay cover only the card. The expanded video is a second element;
|
|
270
|
+
`videoTime` and `videoWasPlaying` carry its place across. -->
|
|
271
|
+
<div
|
|
272
|
+
v-if="mediaExpanded && result.output && overMedia"
|
|
273
|
+
ref="overlay"
|
|
274
|
+
class="chai-result-card__expand-overlay"
|
|
275
|
+
role="dialog"
|
|
276
|
+
aria-modal="true"
|
|
277
|
+
:aria-label="kind === 'video' ? 'Expanded video' : 'Expanded image'"
|
|
278
|
+
@keydown="onOverlayKeydown"
|
|
279
|
+
>
|
|
280
|
+
<div class="chai-result-card__expand-scrim" role="presentation" @click="mediaExpanded = false" />
|
|
281
|
+
<div class="chai-result-card__expand-frame">
|
|
282
|
+
<VideoPlayer
|
|
283
|
+
v-if="kind === 'video'"
|
|
284
|
+
:src="result.output.src"
|
|
285
|
+
expanded
|
|
286
|
+
:time="videoTime"
|
|
287
|
+
:was-playing="videoWasPlaying"
|
|
288
|
+
@playing-change="videoWasPlaying = $event"
|
|
289
|
+
/>
|
|
290
|
+
<img v-else class="chai-result-card__image" :src="result.output.src" :alt="alt" />
|
|
291
|
+
<button type="button" class="chai-result-card__collapse" aria-label="Collapse" @click="mediaExpanded = false">
|
|
292
|
+
<CloseIcon />
|
|
293
|
+
</button>
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
</template>
|
|
297
|
+
</template>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/** The "+" / "×" attach control: a positioned popover of the use-case actions, and the hidden file picker behind "Add media". */
|
|
3
|
+
import { ref } from "vue";
|
|
4
|
+
import type { ComposerAttachMenuAction, composerAttachMenuItems } from "@chai-ui/core";
|
|
5
|
+
import { useDismiss } from "../dismiss.js";
|
|
6
|
+
import { AttachFileIcon, CloseSymbolIcon, ContentCutIcon, ImageIcon, PlusIcon, VideocamIcon } from "../icons.js";
|
|
7
|
+
|
|
8
|
+
defineProps<{
|
|
9
|
+
items: ReturnType<typeof composerAttachMenuItems>;
|
|
10
|
+
/** Edit mode: one image file at a time. */
|
|
11
|
+
imagesOnly?: boolean;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
}>();
|
|
14
|
+
const emit = defineEmits<{ select: [action: ComposerAttachMenuAction]; files: [files: FileList | null] }>();
|
|
15
|
+
|
|
16
|
+
const ICONS = { "add-media": AttachFileIcon, "create-image": ImageIcon, "create-video": VideocamIcon, "edit-media": ContentCutIcon };
|
|
17
|
+
const open = ref(false);
|
|
18
|
+
const root = ref<HTMLElement>();
|
|
19
|
+
const input = ref<HTMLInputElement>();
|
|
20
|
+
useDismiss(root, open, () => (open.value = false));
|
|
21
|
+
|
|
22
|
+
function pick(action: ComposerAttachMenuAction) {
|
|
23
|
+
open.value = false;
|
|
24
|
+
if (action === "add-media") input.value?.click();
|
|
25
|
+
emit("select", action);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function onFileChange(e: Event) {
|
|
29
|
+
const el = e.target as HTMLInputElement;
|
|
30
|
+
emit("files", el.files);
|
|
31
|
+
el.value = "";
|
|
32
|
+
}
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<div ref="root" class="chai-composer__attach">
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
:class="['chai-composer__attach-toggle', { 'chai-composer__attach-toggle--open': open }]"
|
|
40
|
+
:disabled="disabled"
|
|
41
|
+
:aria-expanded="open"
|
|
42
|
+
aria-haspopup="menu"
|
|
43
|
+
:aria-label="open ? 'Close attach menu' : 'Add media'"
|
|
44
|
+
@click="open = !open"
|
|
45
|
+
>
|
|
46
|
+
<CloseSymbolIcon v-if="open" /><PlusIcon v-else />
|
|
47
|
+
</button>
|
|
48
|
+
<ul v-if="open" class="chai-composer__attach-menu" role="menu">
|
|
49
|
+
<li v-for="(item, i) in items" :key="item.action" role="none">
|
|
50
|
+
<button type="button" role="menuitem" class="chai-composer__attach-menu-item" @click="pick(item.action)">
|
|
51
|
+
<span class="chai-composer__attach-menu-icon"><component :is="ICONS[item.action]" /></span>{{ item.label }}
|
|
52
|
+
</button>
|
|
53
|
+
<hr v-if="item.divider && i < items.length - 1" class="chai-composer__attach-menu-divider" role="separator" />
|
|
54
|
+
</li>
|
|
55
|
+
</ul>
|
|
56
|
+
<input
|
|
57
|
+
ref="input"
|
|
58
|
+
type="file"
|
|
59
|
+
:accept="imagesOnly ? 'image/*' : 'image/*,video/*,audio/*'"
|
|
60
|
+
:multiple="!imagesOnly"
|
|
61
|
+
hidden
|
|
62
|
+
:disabled="disabled"
|
|
63
|
+
@change="onFileChange"
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
</template>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A use-case or region chip: its label, and a remove button revealed on
|
|
4
|
+
* hover when it can be removed. A region chip shows its numbered badge in
|
|
5
|
+
* the region's color, matching the box on the image.
|
|
6
|
+
*/
|
|
7
|
+
import { regionColor } from "@chai-ui/core";
|
|
8
|
+
import { CloseSymbolIcon } from "../icons.js";
|
|
9
|
+
|
|
10
|
+
defineProps<{ label: string; regionNumber?: number; removable?: boolean; disabled?: boolean }>();
|
|
11
|
+
const emit = defineEmits<{ remove: [] }>();
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<span
|
|
16
|
+
:class="['chai-composer__chip', { 'chai-composer__chip--region': regionNumber != null }]"
|
|
17
|
+
:style="regionNumber != null ? { '--chai-region-color': regionColor(regionNumber) } : undefined"
|
|
18
|
+
>
|
|
19
|
+
<!-- Label and button touch: whitespace between them would render as a space. -->
|
|
20
|
+
<span v-if="regionNumber != null" class="chai-composer__region-badge" aria-hidden="true">{{ regionNumber }}</span
|
|
21
|
+
>{{ label }}<button
|
|
22
|
+
v-if="removable" type="button" class="chai-composer__chip-remove" :disabled="disabled" :aria-label="`Remove ${label}`" @click="emit('remove')">
|
|
23
|
+
<CloseSymbolIcon />
|
|
24
|
+
</button>
|
|
25
|
+
</span>
|
|
26
|
+
</template>
|
package/src/dismiss.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { onScopeDispose, watch, type Ref } from "vue";
|
|
2
|
+
|
|
3
|
+
/** While `open`, a press outside `root` or Escape calls `close`: the inline menus' popover behavior. */
|
|
4
|
+
export function useDismiss(root: Ref<HTMLElement | undefined>, open: Ref<boolean>, close: () => void) {
|
|
5
|
+
function onPointerDown(e: PointerEvent) {
|
|
6
|
+
if (!root.value?.contains(e.target as Node)) close();
|
|
7
|
+
}
|
|
8
|
+
function onKeydown(e: KeyboardEvent) {
|
|
9
|
+
if (e.key === "Escape") close();
|
|
10
|
+
}
|
|
11
|
+
function listen(on: boolean) {
|
|
12
|
+
if (on) {
|
|
13
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
14
|
+
document.addEventListener("keydown", onKeydown);
|
|
15
|
+
} else {
|
|
16
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
17
|
+
document.removeEventListener("keydown", onKeydown);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
watch(open, listen);
|
|
21
|
+
onScopeDispose(() => listen(false));
|
|
22
|
+
}
|