fix(frontend): harden the streaming ui and extract copy and constants
This commit is contained in:
parent
036ef3cc6f
commit
a3af8f45cc
36 changed files with 1662 additions and 226 deletions
|
|
@ -1,29 +1,52 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { messages } from '../lib/messages'
|
||||
import type { AppMode, AuthState } from '../lib/models'
|
||||
import AuthStatus from './AuthStatus.vue'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
auth: AuthState
|
||||
mode: AppMode | null
|
||||
healthFailed: boolean
|
||||
}>()
|
||||
defineEmits<{ logout: []; toggleDev: [] }>()
|
||||
const emit = defineEmits<{ logout: []; toggleDev: [opener: HTMLElement] }>()
|
||||
|
||||
function openDevPanel(event: MouseEvent): void {
|
||||
if (event.currentTarget instanceof HTMLElement) {
|
||||
emit('toggleDev', event.currentTarget)
|
||||
}
|
||||
}
|
||||
|
||||
const modeLabel = computed(() => {
|
||||
if (props.mode === 'demo') return messages.appHeaderDemoMode
|
||||
if (props.mode === 'live') return messages.appHeaderLiveMode
|
||||
if (props.healthFailed) return messages.appHeaderModeUnavailable
|
||||
return messages.appHeaderCheckingMode
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="header">
|
||||
<div class="inner">
|
||||
<div class="brand">
|
||||
<span class="wordmark">discovery<span class="accent">-by-</span>llm</span>
|
||||
<span class="kicker">proof of concept</span>
|
||||
<span class="wordmark"
|
||||
>{{ messages.appHeaderBrandPrefix
|
||||
}}<span class="accent">{{ messages.appHeaderBrandAccent }}</span
|
||||
>{{ messages.appHeaderBrandSuffix }}</span
|
||||
>
|
||||
<span class="kicker">{{ messages.appHeaderKicker }}</span>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<span class="mode" :class="{ failed: healthFailed }">
|
||||
{{ mode ? `${mode} mode` : healthFailed ? 'mode unavailable' : 'checking mode' }}
|
||||
{{ modeLabel }}
|
||||
</span>
|
||||
<AuthStatus :auth="auth" @logout="$emit('logout')" />
|
||||
<button class="button" type="button" @click="$emit('toggleDev')">
|
||||
Dev panel <span class="key">Ctrl+D</span>
|
||||
<button class="button" type="button" data-dev-panel-opener @click="openDevPanel">
|
||||
<span class="full-label">
|
||||
{{ messages.appHeaderDevPanel }}
|
||||
<span class="key">{{ messages.appHeaderDevPanelShortcut }}</span>
|
||||
</span>
|
||||
<span class="short-label">{{ messages.appHeaderDevPanelShort }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -116,6 +139,10 @@ defineEmits<{ logout: []; toggleDev: [] }>()
|
|||
color: var(--c-text-ghost);
|
||||
}
|
||||
|
||||
.short-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.kicker,
|
||||
.mode,
|
||||
|
|
@ -132,8 +159,12 @@ defineEmits<{ logout: []; toggleDev: [] }>()
|
|||
font-size: 0;
|
||||
}
|
||||
|
||||
.button::after {
|
||||
content: 'Dev';
|
||||
.full-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.short-label {
|
||||
display: inline;
|
||||
font-size: var(--t-micro);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatMessage, messages } from '../lib/messages'
|
||||
import type { AssistantTurn } from '../lib/models'
|
||||
import EmptyResults from './EmptyResults.vue'
|
||||
import PlaylistError from './PlaylistError.vue'
|
||||
|
|
@ -15,13 +16,16 @@ const props = defineProps<{ turn: AssistantTurn; canSave: boolean }>()
|
|||
defineEmits<{ save: []; retry: [query: string] }>()
|
||||
|
||||
const thinkingLabel = computed(() => {
|
||||
if (props.turn.requestId === null) return 'Reading the request'
|
||||
if (props.turn.requestId === null) return messages.assistantMessageReadingRequest
|
||||
if (props.turn.tracks.length === 0) {
|
||||
return props.turn.candidateCount === null
|
||||
? 'Checking candidates'
|
||||
: `Checking ${props.turn.candidateCount} candidates`
|
||||
if (props.turn.candidateCount === null) {
|
||||
return messages.assistantMessageCheckingCandidates
|
||||
}
|
||||
return formatMessage('assistantMessageCheckingCandidateCount', {
|
||||
count: props.turn.candidateCount,
|
||||
})
|
||||
}
|
||||
return 'Streaming verified tracks'
|
||||
return messages.assistantMessageStreamingTracks
|
||||
})
|
||||
|
||||
const isEmptyResult = computed(
|
||||
|
|
@ -34,7 +38,7 @@ const isEmptyResult = computed(
|
|||
|
||||
<template>
|
||||
<div class="assistant">
|
||||
<div class="avatar" aria-hidden="true">d</div>
|
||||
<div class="avatar" aria-hidden="true">{{ messages.assistantMessageAvatar }}</div>
|
||||
<div class="column">
|
||||
<p v-if="turn.intentSummary" class="intent">{{ turn.intentSummary }}</p>
|
||||
|
||||
|
|
@ -76,7 +80,7 @@ const isEmptyResult = computed(
|
|||
:message="turn.playlist.message"
|
||||
/>
|
||||
<PlaylistSaved
|
||||
v-if="turn.playlist.status === 'saved' && turn.playlist.name && turn.playlist.url"
|
||||
v-if="turn.playlist.status === 'saved' && turn.playlist.name"
|
||||
:name="turn.playlist.name"
|
||||
:url="turn.playlist.url"
|
||||
:track-count="turn.tracks.length"
|
||||
|
|
|
|||
|
|
@ -1,29 +1,39 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { messages } from '../lib/messages'
|
||||
import type { AuthState } from '../lib/models'
|
||||
|
||||
defineProps<{ auth: AuthState }>()
|
||||
const props = defineProps<{ auth: AuthState }>()
|
||||
defineEmits<{ logout: [] }>()
|
||||
|
||||
const logoutLabel = computed(() => {
|
||||
if (props.auth.status === 'logging_out') return messages.authStatusLoggingOut
|
||||
return messages.authStatusLogOut
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="auth.status === 'checking'"
|
||||
class="checking"
|
||||
aria-label="Checking Spotify connection"
|
||||
:aria-label="messages.authStatusCheckingConnection"
|
||||
/>
|
||||
<a v-else-if="auth.status === 'anonymous'" class="button" href="/api/auth/login">
|
||||
<span class="full-label">Connect Spotify</span><span class="short-label">Connect</span>
|
||||
<span class="full-label">{{ messages.authStatusConnectSpotify }}</span
|
||||
><span class="short-label">{{ messages.authStatusConnect }}</span>
|
||||
</a>
|
||||
<span v-else-if="auth.status === 'failed'" class="failed" role="alert">
|
||||
<span class="failure-message">{{ auth.message }}</span>
|
||||
<a class="button" href="/api/auth/login">
|
||||
<span class="full-label">Try Spotify again</span><span class="short-label">Retry</span>
|
||||
<span class="full-label">{{ messages.authStatusTryAgain }}</span
|
||||
><span class="short-label">{{ messages.authStatusRetry }}</span>
|
||||
</a>
|
||||
</span>
|
||||
<span v-else class="authenticated">
|
||||
<span class="user">
|
||||
<span class="dot" aria-hidden="true" />
|
||||
<span class="connected">Connected as </span>{{ auth.user.display_name }}
|
||||
<span class="connected">{{ messages.authStatusConnectedAs }}</span
|
||||
>{{ auth.user.display_name }}
|
||||
</span>
|
||||
<button
|
||||
class="button"
|
||||
|
|
@ -31,7 +41,7 @@ defineEmits<{ logout: [] }>()
|
|||
:disabled="auth.status === 'logging_out'"
|
||||
@click="$emit('logout')"
|
||||
>
|
||||
{{ auth.status === 'logging_out' ? 'Logging out...' : 'Log out' }}
|
||||
{{ logoutLabel }}
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,16 @@
|
|||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useApi } from '../composables/useApi'
|
||||
import { useChatStream } from '../composables/useChatStream'
|
||||
import {
|
||||
FAST_TRANSITION_DURATION_MS,
|
||||
MESSAGE_ENTRANCE_ANIMATION_DURATION_MS,
|
||||
PLAYLIST_SAVED_ANIMATION_DURATION_MS,
|
||||
REDUCED_MOTION_DURATION_MS,
|
||||
THINKING_PULSE_ANIMATION_DURATION_MS,
|
||||
THINKING_PULSE_STAGGER_MS,
|
||||
TRACK_CARD_ANIMATION_DURATION_MS,
|
||||
} from '../lib/constants'
|
||||
import { formatMessage, messages } from '../lib/messages'
|
||||
import { DISCOVERY_SUGGESTIONS } from '../lib/suggestions'
|
||||
import AppHeader from './AppHeader.vue'
|
||||
import DevPanel from './DevPanel.vue'
|
||||
|
|
@ -11,16 +21,32 @@ import ModeBanner from './ModeBanner.vue'
|
|||
|
||||
const draft = ref('')
|
||||
const devOpen = ref(false)
|
||||
const devOpener = ref<HTMLElement | null>(null)
|
||||
const input = ref<InstanceType<typeof MessageInput> | null>(null)
|
||||
const { auth, health, bootstrap, logout, createPlaylist } = useApi()
|
||||
const { turns, eventLog, isStreaming, turnCount, latestAssistant, send, savePlaylist, reset } =
|
||||
useChatStream(createPlaylist)
|
||||
|
||||
const mode = computed(() => (health.value.status === 'ready' ? health.value.mode : null))
|
||||
const timingStyles: Record<string, string> = {
|
||||
'--dur-fast': `${FAST_TRANSITION_DURATION_MS}ms`,
|
||||
'--dur-card': `${TRACK_CARD_ANIMATION_DURATION_MS}ms`,
|
||||
'--dur-message': `${MESSAGE_ENTRANCE_ANIMATION_DURATION_MS}ms`,
|
||||
'--dur-playlist-saved': `${PLAYLIST_SAVED_ANIMATION_DURATION_MS}ms`,
|
||||
'--dur-thinking-pulse': `${THINKING_PULSE_ANIMATION_DURATION_MS}ms`,
|
||||
'--delay-thinking-pulse': `${THINKING_PULSE_STAGGER_MS}ms`,
|
||||
'--dur-reduced-motion': `${REDUCED_MOTION_DURATION_MS}ms`,
|
||||
}
|
||||
const mode = computed(() => {
|
||||
if (health.value.status === 'ready') return health.value.mode
|
||||
return null
|
||||
})
|
||||
const canSave = computed(() => auth.value.status === 'authenticated')
|
||||
const turnCounter = computed(() => {
|
||||
if (turnCount.value === 0) return 'no turns yet'
|
||||
return `${turnCount.value} ${turnCount.value === 1 ? 'turn' : 'turns'} in this session`
|
||||
if (turnCount.value === 0) return messages.chatViewNoTurns
|
||||
if (turnCount.value === 1) {
|
||||
return formatMessage('chatViewOneTurn', { count: turnCount.value })
|
||||
}
|
||||
return formatMessage('chatViewManyTurns', { count: turnCount.value })
|
||||
})
|
||||
|
||||
async function focusInput(): Promise<void> {
|
||||
|
|
@ -44,10 +70,29 @@ function submit(query: string): void {
|
|||
void send(query)
|
||||
}
|
||||
|
||||
function openDevPanel(opener?: HTMLElement): void {
|
||||
if (opener) {
|
||||
devOpener.value = opener
|
||||
devOpen.value = true
|
||||
return
|
||||
}
|
||||
const activeElement = document.activeElement
|
||||
if (activeElement instanceof HTMLElement) {
|
||||
devOpener.value = activeElement
|
||||
} else {
|
||||
devOpener.value = null
|
||||
}
|
||||
devOpen.value = true
|
||||
}
|
||||
|
||||
function onShortcut(event: KeyboardEvent): void {
|
||||
if (event.ctrlKey && event.key.toLowerCase() === 'd') {
|
||||
event.preventDefault()
|
||||
devOpen.value = !devOpen.value
|
||||
if (devOpen.value) {
|
||||
devOpen.value = false
|
||||
} else {
|
||||
openDevPanel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -60,14 +105,14 @@ onUnmounted(() => window.removeEventListener('keydown', onShortcut))
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-view">
|
||||
<div class="chat-view" :style="timingStyles">
|
||||
<div class="shell" :inert="devOpen">
|
||||
<AppHeader
|
||||
:auth="auth"
|
||||
:mode="mode"
|
||||
:health-failed="health.status === 'failed'"
|
||||
@logout="logout"
|
||||
@toggle-dev="devOpen = true"
|
||||
@toggle-dev="openDevPanel"
|
||||
/>
|
||||
|
||||
<ModeBanner v-if="mode === 'demo'" />
|
||||
|
|
@ -94,6 +139,7 @@ onUnmounted(() => window.removeEventListener('keydown', onShortcut))
|
|||
v-if="devOpen"
|
||||
:latest="latestAssistant"
|
||||
:log="eventLog"
|
||||
:opener="devOpener"
|
||||
@close="devOpen = false"
|
||||
@reset="reset"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,40 +1,49 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { messages } from '../lib/messages'
|
||||
import type { AssistantTurn, EventLogEntry } from '../lib/models'
|
||||
|
||||
const props = defineProps<{
|
||||
latest: AssistantTurn | undefined
|
||||
log: EventLogEntry[]
|
||||
opener: HTMLElement | null
|
||||
}>()
|
||||
const emit = defineEmits<{ close: []; reset: [] }>()
|
||||
|
||||
const panel = ref<HTMLElement | null>(null)
|
||||
const closeButton = ref<HTMLButtonElement | null>(null)
|
||||
let returnFocus: HTMLElement | null = null
|
||||
|
||||
function latestValue(value: string | null | undefined): string {
|
||||
return value ?? messages.devPanelWaiting
|
||||
}
|
||||
|
||||
const rows = computed(() => [
|
||||
{ label: 'request id', value: props.latest?.requestId ?? 'waiting' },
|
||||
{ label: messages.devPanelRequestId, value: latestValue(props.latest?.requestId) },
|
||||
{
|
||||
label: 'candidate count',
|
||||
value: props.latest?.candidateCount?.toString() ?? 'waiting',
|
||||
label: messages.devPanelCandidateCount,
|
||||
value: latestValue(props.latest?.candidateCount?.toString()),
|
||||
},
|
||||
{
|
||||
label: 'track count',
|
||||
value:
|
||||
props.latest?.completion?.track_count.toString() ??
|
||||
props.latest?.tracks.length.toString() ??
|
||||
'waiting',
|
||||
label: messages.devPanelTrackCount,
|
||||
value: latestValue(
|
||||
props.latest?.completion?.track_count.toString() ?? props.latest?.tracks.length.toString(),
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'total ms',
|
||||
value: props.latest?.completion?.total_ms.toString() ?? 'waiting',
|
||||
label: messages.devPanelTotalMilliseconds,
|
||||
value: latestValue(props.latest?.completion?.total_ms.toString()),
|
||||
},
|
||||
])
|
||||
const entries = computed(() =>
|
||||
props.log.length
|
||||
? [...props.log].reverse()
|
||||
: [{ timestamp: '--:--:--', type: 'idle', detail: 'waiting for a request' }],
|
||||
)
|
||||
const entries = computed(() => {
|
||||
if (props.log.length) return [...props.log].reverse()
|
||||
return [
|
||||
{
|
||||
timestamp: '--:--:--',
|
||||
type: messages.devPanelIdle,
|
||||
detail: messages.devPanelWaitingForRequest,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
function focusableElements(): HTMLElement[] {
|
||||
if (!panel.value) return []
|
||||
|
|
@ -67,12 +76,15 @@ function onKeydown(event: KeyboardEvent): void {
|
|||
}
|
||||
|
||||
onMounted(async () => {
|
||||
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
await nextTick()
|
||||
closeButton.value?.focus()
|
||||
})
|
||||
|
||||
onUnmounted(() => returnFocus?.focus())
|
||||
onUnmounted(() => {
|
||||
const fallback = document.querySelector<HTMLElement>('[data-dev-panel-opener]')
|
||||
const target = props.opener?.isConnected ? props.opener : fallback
|
||||
target?.focus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -86,21 +98,21 @@ onUnmounted(() => returnFocus?.focus())
|
|||
@keydown="onKeydown"
|
||||
>
|
||||
<div class="bar">
|
||||
<h2 id="dev-panel-title" class="caps">Dev panel</h2>
|
||||
<h2 id="dev-panel-title" class="caps">{{ messages.devPanelTitle }}</h2>
|
||||
<button
|
||||
ref="closeButton"
|
||||
class="close"
|
||||
type="button"
|
||||
aria-label="Close dev panel"
|
||||
:aria-label="messages.devPanelCloseLabel"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<span aria-hidden="true">X</span>
|
||||
<span aria-hidden="true">{{ messages.devPanelCloseSymbol }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="body">
|
||||
<section>
|
||||
<h3 class="caps">Latest request</h3>
|
||||
<p class="caption">Only counters supplied by the recommendation contract are shown.</p>
|
||||
<h3 class="caps">{{ messages.devPanelLatestRequest }}</h3>
|
||||
<p class="caption">{{ messages.devPanelContractCaption }}</p>
|
||||
<div class="table">
|
||||
<div v-for="row in rows" :key="row.label" class="row">
|
||||
<span class="key">{{ row.label }}</span
|
||||
|
|
@ -109,11 +121,13 @@ onUnmounted(() => returnFocus?.focus())
|
|||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h3 class="caps section-heading">Conversation</h3>
|
||||
<button class="reset" type="button" @click="$emit('reset')">Clear conversation</button>
|
||||
<h3 class="caps section-heading">{{ messages.devPanelConversation }}</h3>
|
||||
<button class="reset" type="button" @click="$emit('reset')">
|
||||
{{ messages.devPanelClearConversation }}
|
||||
</button>
|
||||
</section>
|
||||
<section>
|
||||
<h3 class="caps section-heading">Event stream</h3>
|
||||
<h3 class="caps section-heading">{{ messages.devPanelEventStream }}</h3>
|
||||
<div class="log">
|
||||
<div v-for="(entry, index) in entries" :key="index" class="entry">
|
||||
<span class="time">{{ entry.timestamp }}</span>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { messages } from '../lib/messages'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty-results">
|
||||
<strong>No verified tracks matched this request.</strong>
|
||||
<span>Try broadening the moment or removing one constraint.</span>
|
||||
<strong>{{ messages.emptyResultsTitle }}</strong>
|
||||
<span>{{ messages.emptyResultsGuidance }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { messages } from '../lib/messages'
|
||||
import SuggestionChips from './SuggestionChips.vue'
|
||||
|
||||
defineProps<{ suggestions: readonly string[] }>()
|
||||
|
|
@ -7,13 +8,15 @@ defineEmits<{ pick: [suggestion: string] }>()
|
|||
|
||||
<template>
|
||||
<div class="empty">
|
||||
<h1>What should be playing<span class="accent"> right now?</span></h1>
|
||||
<h1>
|
||||
{{ messages.emptyStateHeading
|
||||
}}<span class="accent">{{ messages.emptyStateHeadingAccent }}</span>
|
||||
</h1>
|
||||
<p class="lede">
|
||||
Describe the moment, not the genre. The assistant reads the request, proposes candidates,
|
||||
verifies each one on Spotify and explains why it picked them.
|
||||
{{ messages.emptyStateDescription }}
|
||||
</p>
|
||||
<template v-if="suggestions.length">
|
||||
<div class="label">Try one of these</div>
|
||||
<div class="label">{{ messages.emptyStateSuggestionLabel }}</div>
|
||||
<SuggestionChips :suggestions="suggestions" @pick="$emit('pick', $event)" />
|
||||
</template>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { NARROW_COMPOSER_MAX_WIDTH_PX, QUERY_MAX_LENGTH } from '../lib/constants'
|
||||
import { messages } from '../lib/messages'
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
const props = defineProps<{ disabled: boolean; turnCounter: string }>()
|
||||
|
|
@ -7,10 +9,17 @@ const emit = defineEmits<{ send: [query: string] }>()
|
|||
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
const narrow = ref(false)
|
||||
const placeholder = 'Describe the moment: "something calm while I am programming, but not boring"'
|
||||
let mediaQuery: MediaQueryList | null = null
|
||||
|
||||
const sendDisabled = computed(() => props.disabled || model.value.trim().length === 0)
|
||||
const placeholder = computed(() => {
|
||||
if (narrow.value) return messages.messageInputNarrowPlaceholder
|
||||
return messages.messageInputPlaceholder
|
||||
})
|
||||
const buttonLabel = computed(() => {
|
||||
if (props.disabled) return messages.messageInputWorking
|
||||
return messages.messageInputSend
|
||||
})
|
||||
|
||||
function updateWidth(event?: MediaQueryListEvent): void {
|
||||
narrow.value = event?.matches ?? mediaQuery?.matches ?? false
|
||||
|
|
@ -27,7 +36,7 @@ function focus(): void {
|
|||
}
|
||||
|
||||
onMounted(() => {
|
||||
mediaQuery = window.matchMedia('(max-width: 559px)')
|
||||
mediaQuery = window.matchMedia(`(max-width: ${NARROW_COMPOSER_MAX_WIDTH_PX}px)`)
|
||||
updateWidth()
|
||||
mediaQuery.addEventListener('change', updateWidth)
|
||||
})
|
||||
|
|
@ -43,17 +52,18 @@ defineExpose({ focus })
|
|||
<input
|
||||
ref="input"
|
||||
v-model="model"
|
||||
aria-label="Describe what you want to listen to"
|
||||
:placeholder="narrow ? 'Describe the moment...' : placeholder"
|
||||
:aria-label="messages.messageInputAriaLabel"
|
||||
:maxlength="QUERY_MAX_LENGTH"
|
||||
:placeholder="placeholder"
|
||||
@keydown.enter.prevent="submit($event)"
|
||||
/>
|
||||
<button class="send" type="button" :disabled="sendDisabled" @click="submit()">
|
||||
{{ disabled ? 'Working...' : 'Send' }}
|
||||
{{ buttonLabel }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="hints">
|
||||
<span class="hint">
|
||||
Enter to send / refine with follow-ups like "more electronic and drop number 3"
|
||||
{{ messages.messageInputHint }}
|
||||
</span>
|
||||
<span>{{ turnCounter }}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { MESSAGE_LIST_PIN_THRESHOLD_PX } from '../lib/constants'
|
||||
import { formatMessage, messages } from '../lib/messages'
|
||||
import type { AssistantTurn, ChatTurn } from '../lib/models'
|
||||
import AssistantMessage from './AssistantMessage.vue'
|
||||
import EmptyState from './EmptyState.vue'
|
||||
|
|
@ -18,37 +20,49 @@ defineEmits<{
|
|||
|
||||
const list = ref<HTMLElement | null>(null)
|
||||
const pinned = ref(true)
|
||||
const fingerprint = computed(() =>
|
||||
props.turns
|
||||
.map((turn) =>
|
||||
turn.role === 'user'
|
||||
? turn.id
|
||||
: `${turn.id}:${turn.tracks.length}:${turn.warnings.length}:${turn.status}`,
|
||||
)
|
||||
.join('|'),
|
||||
)
|
||||
function turnFingerprint(turn: ChatTurn): string {
|
||||
if (turn.role === 'user') return turn.id
|
||||
return `${turn.id}:${turn.tracks.length}:${turn.warnings.length}:${turn.status}`
|
||||
}
|
||||
|
||||
function findLatestAssistant(): AssistantTurn | undefined {
|
||||
for (let index = props.turns.length - 1; index >= 0; index -= 1) {
|
||||
const turn = props.turns[index]
|
||||
if (turn?.role === 'assistant') return turn
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const fingerprint = computed(() => props.turns.map(turnFingerprint).join('|'))
|
||||
const streaming = computed(() =>
|
||||
props.turns.some((turn) => turn.role === 'assistant' && turn.status === 'streaming'),
|
||||
)
|
||||
const latestAssistant = computed(() =>
|
||||
[...props.turns].reverse().find((turn): turn is AssistantTurn => turn.role === 'assistant'),
|
||||
)
|
||||
const latestAssistant = computed(findLatestAssistant)
|
||||
const announcement = computed(() => {
|
||||
const turn = latestAssistant.value
|
||||
if (!turn) return ''
|
||||
if (turn.status === 'error') return 'The recommendation request failed.'
|
||||
if (turn.status === 'error') return messages.messageListRequestFailed
|
||||
if (turn.status === 'done') {
|
||||
return `${turn.completion?.track_count ?? turn.tracks.length} tracks ready.`
|
||||
const trackCount = turn.completion?.track_count ?? turn.tracks.length
|
||||
return formatMessage('messageListTracksReady', { count: trackCount })
|
||||
}
|
||||
if (turn.requestId === null) return 'Reading the request.'
|
||||
if (turn.tracks.length === 0) return 'Checking recommendation candidates.'
|
||||
return `${turn.tracks.length} verified tracks received.`
|
||||
const warning = turn.warnings.at(-1)
|
||||
if (warning) {
|
||||
return formatMessage('messageListWarning', {
|
||||
count: turn.warnings.length,
|
||||
message: warning.message,
|
||||
})
|
||||
}
|
||||
if (turn.requestId === null) return messages.messageListReadingRequest
|
||||
if (turn.tracks.length === 0) return messages.messageListCheckingCandidates
|
||||
return formatMessage('messageListVerifiedTracksReceived', { count: turn.tracks.length })
|
||||
})
|
||||
|
||||
function onScroll(): void {
|
||||
const element = list.value
|
||||
if (!element) return
|
||||
pinned.value = element.scrollHeight - element.scrollTop - element.clientHeight < 48
|
||||
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight
|
||||
pinned.value = distanceFromBottom < MESSAGE_LIST_PIN_THRESHOLD_PX
|
||||
}
|
||||
|
||||
async function scrollToLatest(): Promise<void> {
|
||||
|
|
@ -66,7 +80,12 @@ watch(streaming, (value) => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<main ref="list" class="list" aria-label="Conversation" @scroll="onScroll">
|
||||
<main
|
||||
ref="list"
|
||||
class="list"
|
||||
:aria-label="messages.messageListConversationLabel"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<p class="visually-hidden" aria-live="polite" aria-atomic="true">
|
||||
{{ announcement }}
|
||||
</p>
|
||||
|
|
@ -107,7 +126,7 @@ watch(streaming, (value) => {
|
|||
}
|
||||
|
||||
.turn {
|
||||
animation: message-in 0.3s ease both;
|
||||
animation: message-in var(--dur-message) ease both;
|
||||
}
|
||||
|
||||
@keyframes message-in {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import { messages } from '../lib/messages'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="banner" role="status">
|
||||
<div class="inner">
|
||||
<span class="dot" aria-hidden="true" />
|
||||
<span>Demo mode: replaying recorded recommendation sessions</span>
|
||||
<span>{{ messages.modeBannerDemo }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{ name: string; url: string; trackCount: number }>()
|
||||
import { computed } from 'vue'
|
||||
import { formatMessage, messages } from '../lib/messages'
|
||||
|
||||
const props = defineProps<{ name: string; url: string | null; trackCount: number }>()
|
||||
const metadata = computed(() => formatMessage('playlistSavedMetadata', { count: props.trackCount }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="saved" role="status">
|
||||
<div>
|
||||
<div class="line">
|
||||
Saved <span class="name">{{ name }}</span>
|
||||
{{ messages.playlistSavedConfirmationPrefix }}<span class="name">{{ name }}</span>
|
||||
</div>
|
||||
<div class="meta">{{ trackCount }} tracks / private / created just now</div>
|
||||
<div class="meta">{{ metadata }}</div>
|
||||
</div>
|
||||
<a class="link" :href="url" target="_blank" rel="noreferrer">
|
||||
Open in Spotify <span aria-hidden="true">↗</span>
|
||||
<a v-if="url" class="link" :href="url" target="_blank" rel="noreferrer">
|
||||
{{ messages.playlistSavedOpenSpotify }} <span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -27,7 +31,7 @@ defineProps<{ name: string; url: string; trackCount: number }>()
|
|||
background: var(--c-saved-bg);
|
||||
border: 1px solid var(--c-saved-line);
|
||||
border-radius: var(--r-md);
|
||||
animation: save-pop 0.26s ease both;
|
||||
animation: save-pop var(--dur-playlist-saved) ease both;
|
||||
}
|
||||
|
||||
.line {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatMessage } from '../lib/messages'
|
||||
import type { AssistantTurn } from '../lib/models'
|
||||
|
||||
const props = defineProps<{ turn: AssistantTurn }>()
|
||||
const parts = computed(() => {
|
||||
const values: string[] = []
|
||||
if (props.turn.candidateCount !== null) {
|
||||
values.push(`${props.turn.candidateCount} candidates considered`)
|
||||
values.push(formatMessage('requestMetadataCandidates', { count: props.turn.candidateCount }))
|
||||
}
|
||||
if (props.turn.completion) {
|
||||
values.push(`${props.turn.completion.track_count} verified`)
|
||||
values.push(`${props.turn.completion.total_ms} ms`)
|
||||
values.push(
|
||||
formatMessage('requestMetadataVerified', { count: props.turn.completion.track_count }),
|
||||
)
|
||||
values.push(
|
||||
formatMessage('requestMetadataDuration', {
|
||||
milliseconds: props.turn.completion.total_ms,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return values
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatMessage, messages } from '../lib/messages'
|
||||
import type { PlaylistState } from '../lib/models'
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -10,10 +11,11 @@ const props = defineProps<{
|
|||
defineEmits<{ save: [] }>()
|
||||
|
||||
const buttonLabel = computed(() => {
|
||||
if (props.state.status === 'saving') return 'Saving...'
|
||||
if (props.state.status === 'error') return 'Try saving again'
|
||||
return 'Save as playlist'
|
||||
if (props.state.status === 'saving') return messages.resultActionsSaving
|
||||
if (props.state.status === 'error') return messages.resultActionsTryAgain
|
||||
return messages.resultActionsSavePlaylist
|
||||
})
|
||||
const summary = computed(() => formatMessage('resultActionsSummary', { count: props.trackCount }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -27,8 +29,8 @@ const buttonLabel = computed(() => {
|
|||
>
|
||||
{{ buttonLabel }}
|
||||
</button>
|
||||
<a v-else class="connect" href="/api/auth/login">Connect Spotify to save</a>
|
||||
<span class="summary">{{ trackCount }} verified tracks ready for a private playlist</span>
|
||||
<a v-else class="connect" href="/api/auth/login">{{ messages.resultActionsConnectSpotify }}</a>
|
||||
<span class="summary">{{ summary }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { messages } from '../lib/messages'
|
||||
import type { TrackEvent } from '../lib/types'
|
||||
import TrackCard from './TrackCard.vue'
|
||||
|
||||
|
|
@ -6,7 +7,7 @@ defineProps<{ tracks: TrackEvent[] }>()
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="set" aria-label="Recommended tracks">
|
||||
<div class="set" :aria-label="messages.resultSetRecommendedTracksLabel">
|
||||
<TrackCard v-for="event in tracks" :key="event.track.id" :event="event" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { messages } from '../lib/messages'
|
||||
|
||||
defineProps<{ code: string; message: string }>()
|
||||
defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
|
|
@ -6,7 +8,7 @@ defineEmits<{ retry: [] }>()
|
|||
<template>
|
||||
<div class="error" role="alert" :data-error-code="code">
|
||||
<p>{{ message }}</p>
|
||||
<button type="button" @click="$emit('retry')">Edit and retry</button>
|
||||
<button type="button" @click="$emit('retry')">{{ messages.streamErrorRetry }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -26,15 +26,15 @@ defineProps<{ label: string }>()
|
|||
height: 5px;
|
||||
background: var(--c-accent);
|
||||
border-radius: 50%;
|
||||
animation: pulse-dot 1.1s infinite;
|
||||
animation: pulse-dot var(--dur-thinking-pulse) infinite;
|
||||
}
|
||||
|
||||
.dot:nth-child(2) {
|
||||
animation-delay: 0.18s;
|
||||
animation-delay: var(--delay-thinking-pulse);
|
||||
}
|
||||
|
||||
.dot:nth-child(3) {
|
||||
animation-delay: 0.36s;
|
||||
animation-delay: calc(2 * var(--delay-thinking-pulse));
|
||||
}
|
||||
|
||||
.label {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { formatMessage, messages } from '../lib/messages'
|
||||
import type { TrackEvent } from '../lib/types'
|
||||
|
||||
const props = defineProps<{ event: TrackEvent }>()
|
||||
const artworkFailed = ref(false)
|
||||
const artists = computed(() => props.event.track.artists.join(', '))
|
||||
const spotifyUrl = computed(() => props.event.track.external_url)
|
||||
const artworkAlt = computed(() =>
|
||||
formatMessage('trackCardArtworkAlt', { album: props.event.track.album_name }),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -14,7 +19,7 @@ const artists = computed(() => props.event.track.artists.join(', '))
|
|||
<img
|
||||
v-if="event.track.album_art_url && !artworkFailed"
|
||||
:src="event.track.album_art_url"
|
||||
:alt="`${event.track.album_name} album artwork`"
|
||||
:alt="artworkAlt"
|
||||
@error="artworkFailed = true"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -26,14 +31,8 @@ const artists = computed(() => props.event.track.artists.join(', '))
|
|||
<div class="album">{{ event.track.album_name }}</div>
|
||||
<div class="why">{{ event.justification }}</div>
|
||||
</div>
|
||||
<a
|
||||
v-if="event.track.external_url"
|
||||
class="link"
|
||||
:href="event.track.external_url"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Open in Spotify
|
||||
<a v-if="spotifyUrl" class="link" :href="spotifyUrl" target="_blank" rel="noreferrer">
|
||||
{{ messages.trackCardOpenSpotify }}
|
||||
<span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</article>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue