fix(frontend): harden the streaming ui and extract copy and constants
Some checks are pending
ci / backend (push) Waiting to run
ci / frontend (push) Waiting to run

This commit is contained in:
Justin Visser 2026-08-10 15:42:18 +02:00
parent 036ef3cc6f
commit a3af8f45cc
36 changed files with 1662 additions and 226 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -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);
}
}

View file

@ -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"

View file

@ -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>

View file

@ -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"
/>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 {

View file

@ -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>

View file

@ -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">&nearr;</span>
<a v-if="url" class="link" :href="url" target="_blank" rel="noreferrer">
{{ messages.playlistSavedOpenSpotify }} <span aria-hidden="true">&nearr;</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 {

View file

@ -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
})

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 {

View file

@ -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">&nearr;</span>
</a>
</article>

View file

@ -1,6 +1,8 @@
import { ref } from 'vue'
import { formatMessage, messages } from '../lib/messages'
import type { AuthState, HealthState } from '../lib/models'
import type { CurrentUser, PlaylistCreateRequest, PlaylistCreateResponse } from '../lib/types'
import { parseSpotifyUrl } from '../lib/spotifyUrl'
import type { CurrentUser, PlaylistCreateRequest } from '../lib/types'
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
@ -8,16 +10,16 @@ function isRecord(value: unknown): value is Record<string, unknown> {
function parseCurrentUser(value: unknown): CurrentUser {
if (!isRecord(value) || typeof value.display_name !== 'string') {
throw new Error('Invalid current user response.')
throw new Error(messages.useApiInvalidCurrentUser)
}
return { display_name: value.display_name }
}
function parsePlaylistResponse(value: unknown): PlaylistCreateResponse {
function parsePlaylistResponse(value: unknown): { url: string | null } {
if (!isRecord(value) || typeof value.url !== 'string') {
throw new Error('Invalid playlist response.')
throw new Error(messages.useApiInvalidPlaylist)
}
return { url: value.url }
return { url: parseSpotifyUrl(value.url) }
}
function loginFailed(): boolean {
@ -39,28 +41,30 @@ export function useApi() {
try {
const response = await fetch('/api/auth/me', { credentials: 'same-origin' })
if (response.status === 401) {
auth.value = hadLoginError
? {
status: 'failed',
user: null,
message: 'Spotify login did not complete. Please try again.',
}
: { status: 'anonymous', user: null, message: null }
if (hadLoginError) {
auth.value = {
status: 'failed',
user: null,
message: messages.useApiLoginFailed,
}
} else {
auth.value = { status: 'anonymous', user: null, message: null }
}
return
}
if (!response.ok) throw new Error('Authentication check failed.')
if (!response.ok) throw new Error(messages.useApiAuthenticationCheckFailed)
auth.value = {
status: 'authenticated',
user: parseCurrentUser(await response.json()),
message: null,
}
} catch {
let message: string = messages.useApiConnectionUnavailable
if (hadLoginError) message = messages.useApiLoginFailed
auth.value = {
status: 'failed',
user: null,
message: hadLoginError
? 'Spotify login did not complete. Please try again.'
: 'Spotify connection status is unavailable.',
message,
}
}
}
@ -68,21 +72,21 @@ export function useApi() {
async function loadHealth(): Promise<void> {
try {
const response = await fetch('/api/health', { credentials: 'same-origin' })
if (!response.ok) throw new Error('Health check failed.')
if (!response.ok) throw new Error(messages.useApiHealthCheckFailed)
const body: unknown = await response.json()
if (
!isRecord(body) ||
body.status !== 'ok' ||
(body.mode !== 'demo' && body.mode !== 'live')
) {
throw new Error('Invalid health response.')
throw new Error(messages.useApiInvalidHealth)
}
health.value = { status: 'ready', mode: body.mode, message: null }
} catch {
health.value = {
status: 'failed',
mode: null,
message: 'Service mode is unavailable.',
message: messages.useApiModeUnavailable,
}
}
}
@ -101,25 +105,27 @@ export function useApi() {
method: 'POST',
credentials: 'same-origin',
})
if (!response.ok) throw new Error('Logout failed.')
if (!response.ok) throw new Error(messages.useApiLogoutFailed)
auth.value = { status: 'anonymous', user: null, message: null }
} catch {
auth.value = {
status: 'failed',
user: null,
message: 'Spotify logout failed. Refresh before trying again.',
message: messages.useApiSpotifyLogoutFailed,
}
}
}
async function createPlaylist(request: PlaylistCreateRequest): Promise<PlaylistCreateResponse> {
async function createPlaylist(request: PlaylistCreateRequest): Promise<{ url: string | null }> {
const response = await fetch('/api/playlists', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
})
if (!response.ok) throw new Error(`Playlist creation returned ${response.status}.`)
if (!response.ok) {
throw new Error(formatMessage('useApiPlaylistCreationFailed', { status: response.status }))
}
return parsePlaylistResponse(await response.json())
}

View file

@ -1,4 +1,13 @@
import { computed, onUnmounted, ref } from 'vue'
import {
EVENT_LOG_MAX_ENTRIES,
HISTORY_CONTENT_MAX_LENGTH,
HISTORY_MAX_TURNS,
PLAYLIST_NAME_MAX_LENGTH,
PRIOR_RECOMMENDATIONS_MAX_TRACKS,
QUERY_MAX_LENGTH,
} from '../lib/constants'
import { formatMessage, messages } from '../lib/messages'
import type { AssistantTurn, ChatTurn, EventLogEntry, TransportFailure } from '../lib/models'
import { EMPTY_PLAYLIST_STATE } from '../lib/models'
import {
@ -9,24 +18,25 @@ import {
import type {
HistoryTurn,
PlaylistCreateRequest,
PlaylistCreateResponse,
PriorRecommendation,
RecommendationRequest,
StreamEvent,
} from '../lib/types'
const HISTORY_LIMIT = 12
const PRIOR_RECOMMENDATION_LIMIT = 50
const EVENT_LOG_LIMIT = 100
type PlaylistCreator = (request: PlaylistCreateRequest) => Promise<{ url: string | null }>
type PlaylistCreator = (request: PlaylistCreateRequest) => Promise<PlaylistCreateResponse>
interface ActiveRequest {
controller: AbortController
turnId: string
terminalReceived: boolean
}
function assistantContent(turn: AssistantTurn): string {
const trackLines = turn.tracks.map(
(event) => `${event.rank}. ${event.track.title} by ${event.track.artists.join(', ')}`,
)
const content = [turn.intentSummary, ...trackLines].filter(Boolean).join('\n')
return content.slice(0, 2000)
return content.slice(0, HISTORY_CONTENT_MAX_LENGTH)
}
function buildHistory(turns: ChatTurn[]): HistoryTurn[] {
@ -37,15 +47,21 @@ function buildHistory(turns: ChatTurn[]): HistoryTurn[] {
return content ? { role: 'assistant', content } : null
})
.filter((turn): turn is HistoryTurn => turn !== null)
.slice(-HISTORY_LIMIT)
.slice(-HISTORY_MAX_TURNS)
}
function findLatestCompletedAssistant(turns: ChatTurn[]): AssistantTurn | undefined {
for (let index = turns.length - 1; index >= 0; index -= 1) {
const turn = turns[index]
if (turn?.role === 'assistant' && turn.status === 'done') return turn
}
return undefined
}
function buildPriorRecommendations(turns: ChatTurn[]): PriorRecommendation[] {
const latest = [...turns]
.reverse()
.find((turn): turn is AssistantTurn => turn.role === 'assistant' && turn.status === 'done')
const latest = findLatestCompletedAssistant(turns)
if (!latest) return []
return latest.tracks.slice(0, PRIOR_RECOMMENDATION_LIMIT).map((event) => ({
return latest.tracks.slice(0, PRIOR_RECOMMENDATIONS_MAX_TRACKS).map((event) => ({
rank: event.rank,
track_id: event.track.id,
title: event.track.title,
@ -76,7 +92,7 @@ function reduceEvent(turn: AssistantTurn, event: StreamEvent): AssistantTurn {
completion: event,
transportFailure: {
kind: 'protocol',
message: 'The final track count did not match the streamed results.',
message: messages.useChatStreamTrackCountMismatch,
},
}
}
@ -85,64 +101,69 @@ function reduceEvent(turn: AssistantTurn, event: StreamEvent): AssistantTurn {
}
function playlistName(query: string): string {
return `[discovery-by-llm] ${query}`.slice(0, 100).trim()
return formatMessage('useChatStreamPlaylistName', { query })
.slice(0, PLAYLIST_NAME_MAX_LENGTH)
.trim()
}
function createTurnId(): string {
return typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID()
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
}
function findLatestAssistant(turns: ChatTurn[]): AssistantTurn | undefined {
for (let index = turns.length - 1; index >= 0; index -= 1) {
const turn = turns[index]
if (turn?.role === 'assistant') return turn
}
return undefined
}
/** Reduce recommendation streams into conversation view state. */
export function useChatStream(createPlaylist: PlaylistCreator) {
const turns = ref<ChatTurn[]>([])
const eventLog = ref<EventLogEntry[]>([])
let activeController: AbortController | null = null
let activeTurnId: string | null = null
let activeRequest: ActiveRequest | null = null
const isStreaming = computed(() =>
turns.value.some((turn) => turn.role === 'assistant' && turn.status === 'streaming'),
)
const turnCount = computed(() => turns.value.filter((turn) => turn.role === 'user').length)
const isEmpty = computed(() => turns.value.length === 0)
const latestAssistant = computed(() =>
[...turns.value].reverse().find((turn): turn is AssistantTurn => turn.role === 'assistant'),
)
const latestAssistant = computed(() => findLatestAssistant(turns.value))
function updateAssistant(id: string, update: (turn: AssistantTurn) => AssistantTurn): void {
turns.value = turns.value.map((turn) =>
turn.role === 'assistant' && turn.id === id ? update(turn) : turn,
)
turns.value = turns.value.map((turn) => {
if (turn.role === 'assistant' && turn.id === id) return update(turn)
return turn
})
}
function addLog(type: string, detail: string): void {
eventLog.value = [
...eventLog.value,
{ timestamp: new Date().toLocaleTimeString(), type, detail },
].slice(-EVENT_LOG_LIMIT)
].slice(-EVENT_LOG_MAX_ENTRIES)
}
function cancelActive(shouldMarkTurn: boolean): void {
if (!activeController) return
activeController.abort()
if (shouldMarkTurn && activeTurnId) {
const request = activeRequest
if (!request) return
request.controller.abort()
if (shouldMarkTurn && !request.terminalReceived) {
const failure: TransportFailure = {
kind: 'cancelled',
message: 'This request was replaced by a newer request.',
message: messages.useChatStreamRequestReplaced,
}
updateAssistant(activeTurnId, (turn) => ({
...turn,
status: 'error',
transportFailure: failure,
}))
updateAssistant(request.turnId, (turn) => {
if (turn.status !== 'streaming') return turn
return { ...turn, status: 'error', transportFailure: failure }
})
}
activeController = null
activeTurnId = null
activeRequest = null
}
async function send(query: string): Promise<void> {
const text = query.trim()
const text = query.trim().slice(0, QUERY_MAX_LENGTH)
if (!text) return
cancelActive(true)
@ -171,30 +192,40 @@ export function useChatStream(createPlaylist: PlaylistCreator) {
turns.value = [...turns.value, userTurn, assistantTurn]
const controller = new AbortController()
activeController = controller
activeTurnId = assistantTurn.id
const requestState: ActiveRequest = {
controller,
turnId: assistantTurn.id,
terminalReceived: false,
}
activeRequest = requestState
try {
await streamRecommendations(request, controller.signal, (event) => {
if (activeRequest !== requestState || requestState.terminalReceived) return
if (event.type === 'done' || event.type === 'error') {
requestState.terminalReceived = true
}
updateAssistant(assistantTurn.id, (turn) => reduceEvent(turn, event))
addLog(event.type, JSON.stringify(event))
})
} catch (error) {
if (activeRequest !== requestState || requestState.terminalReceived) return
if (isAbortError(error, controller.signal)) return
const failure: TransportFailure =
error instanceof StreamTransportError
? { kind: error.kind, message: error.message }
: { kind: 'network', message: 'The recommendation request failed unexpectedly.' }
let failure: TransportFailure
if (error instanceof StreamTransportError) {
failure = { kind: error.kind, message: error.message }
} else {
failure = { kind: 'network', message: messages.useChatStreamUnexpectedFailure }
}
updateAssistant(assistantTurn.id, (turn) => ({
...turn,
status: 'error',
transportFailure: failure,
}))
addLog('transport', `${failure.kind}: ${failure.message}`)
addLog(messages.useChatStreamTransportEvent, `${failure.kind}: ${failure.message}`)
} finally {
if (activeController === controller) {
activeController = null
activeTurnId = null
if (activeRequest === requestState) {
activeRequest = null
}
}
}
@ -237,7 +268,7 @@ export function useChatStream(createPlaylist: PlaylistCreator) {
status: 'error',
name,
url: null,
message: 'Spotify could not create the playlist. Nothing was retried.',
message: messages.useChatStreamPlaylistFailure,
},
}))
}
@ -256,7 +287,6 @@ export function useChatStream(createPlaylist: PlaylistCreator) {
eventLog,
isStreaming,
turnCount,
isEmpty,
latestAssistant,
send,
savePlaylist,

View file

@ -0,0 +1,46 @@
/** Shared frontend limits and timing values. */
// Mirrors the backend RecommendationRequest query max_length bound.
export const QUERY_MAX_LENGTH = 1000
// Mirrors the backend RecommendationRequest history max_length bound.
export const HISTORY_MAX_TURNS = 12
// Mirrors the backend RecommendationRequest prior_recommendations max_length bound.
export const PRIOR_RECOMMENDATIONS_MAX_TRACKS = 50
// Mirrors the backend HistoryTurn content max_length bound.
export const HISTORY_CONTENT_MAX_LENGTH = 2000
// Mirrors the backend PlaylistCreateRequest name max_length bound.
export const PLAYLIST_NAME_MAX_LENGTH = 100
// Keeps the developer event log useful without allowing unbounded growth.
export const EVENT_LOG_MAX_ENTRIES = 100
// Treats the message list as pinned when it is within this distance of the bottom.
export const MESSAGE_LIST_PIN_THRESHOLD_PX = 48
// Matches the composer placeholder switch to the mobile layout breakpoint.
export const NARROW_COMPOSER_MAX_WIDTH_PX = 559
// Keeps routine hover and focus transitions responsive.
export const FAST_TRANSITION_DURATION_MS = 160
// Gives track cards enough time to settle without slowing streamed results.
export const TRACK_CARD_ANIMATION_DURATION_MS = 340
// Makes new conversation turns noticeable without delaying interaction.
export const MESSAGE_ENTRANCE_ANIMATION_DURATION_MS = 300
// Gives playlist confirmation a brief visual acknowledgement.
export const PLAYLIST_SAVED_ANIMATION_DURATION_MS = 260
// Keeps the thinking pulse calm while work is in progress.
export const THINKING_PULSE_ANIMATION_DURATION_MS = 1100
// Separates thinking dots enough to make their sequence legible.
export const THINKING_PULSE_STAGGER_MS = 180
// Retains an effectively instant duration when reduced motion is requested.
export const REDUCED_MOTION_DURATION_MS = 1

View file

@ -0,0 +1,149 @@
/** Central English copy for future localization. */
export const messages = {
appHeaderBrandPrefix: 'discovery',
appHeaderBrandAccent: '-by-',
appHeaderBrandSuffix: 'llm',
appHeaderKicker: 'proof of concept',
appHeaderDemoMode: 'demo mode',
appHeaderLiveMode: 'live mode',
appHeaderModeUnavailable: 'mode unavailable',
appHeaderCheckingMode: 'checking mode',
appHeaderDevPanel: 'Dev panel',
appHeaderDevPanelShort: 'Dev',
appHeaderDevPanelShortcut: 'Ctrl+D',
assistantMessageReadingRequest: 'Reading the request',
assistantMessageCheckingCandidates: 'Checking candidates',
assistantMessageCheckingCandidateCount: 'Checking {count} candidates',
assistantMessageStreamingTracks: 'Streaming verified tracks',
assistantMessageAvatar: 'd',
authStatusCheckingConnection: 'Checking Spotify connection',
authStatusConnectSpotify: 'Connect Spotify',
authStatusConnect: 'Connect',
authStatusTryAgain: 'Try Spotify again',
authStatusRetry: 'Retry',
authStatusConnectedAs: 'Connected as ',
authStatusLoggingOut: 'Logging out...',
authStatusLogOut: 'Log out',
chatViewNoTurns: 'no turns yet',
chatViewOneTurn: '{count} turn in this session',
chatViewManyTurns: '{count} turns in this session',
devPanelTitle: 'Dev panel',
devPanelCloseLabel: 'Close dev panel',
devPanelCloseSymbol: 'X',
devPanelLatestRequest: 'Latest request',
devPanelContractCaption: 'Only counters supplied by the recommendation contract are shown.',
devPanelRequestId: 'request id',
devPanelCandidateCount: 'candidate count',
devPanelTrackCount: 'track count',
devPanelTotalMilliseconds: 'total ms',
devPanelWaiting: 'waiting',
devPanelConversation: 'Conversation',
devPanelClearConversation: 'Clear conversation',
devPanelEventStream: 'Event stream',
devPanelIdle: 'idle',
devPanelWaitingForRequest: 'waiting for a request',
emptyResultsTitle: 'No verified tracks matched this request.',
emptyResultsGuidance: 'Try broadening the moment or removing one constraint.',
emptyStateHeading: 'What should be playing',
emptyStateHeadingAccent: ' right now?',
emptyStateDescription:
'Describe the moment, not the genre. The assistant reads the request, proposes candidates, verifies each one on Spotify and explains why it picked them.',
emptyStateSuggestionLabel: 'Try one of these',
messageInputAriaLabel: 'Describe what you want to listen to',
messageInputPlaceholder:
'Describe the moment: "something calm while I am programming, but not boring"',
messageInputNarrowPlaceholder: 'Describe the moment...',
messageInputWorking: 'Working...',
messageInputSend: 'Send',
messageInputHint:
'Enter to send / refine with follow-ups like "more electronic and drop number 3"',
messageListConversationLabel: 'Conversation',
messageListRequestFailed: 'The recommendation request failed.',
messageListTracksReady: '{count} tracks ready.',
messageListWarning: 'Warning {count}: {message}',
messageListReadingRequest: 'Reading the request.',
messageListCheckingCandidates: 'Checking recommendation candidates.',
messageListVerifiedTracksReceived: '{count} verified tracks received.',
modeBannerDemo: 'Demo mode: replaying recorded recommendation sessions',
playlistSavedConfirmationPrefix: 'Saved ',
playlistSavedMetadata: '{count} tracks / private / created just now',
playlistSavedOpenSpotify: 'Open in Spotify',
requestMetadataCandidates: '{count} candidates considered',
requestMetadataVerified: '{count} verified',
requestMetadataDuration: '{milliseconds} ms',
resultActionsSaving: 'Saving...',
resultActionsTryAgain: 'Try saving again',
resultActionsSavePlaylist: 'Save as playlist',
resultActionsConnectSpotify: 'Connect Spotify to save',
resultActionsSummary: '{count} verified tracks ready for a private playlist',
resultSetRecommendedTracksLabel: 'Recommended tracks',
streamErrorRetry: 'Edit and retry',
trackCardArtworkAlt: '{album} album artwork',
trackCardOpenSpotify: 'Open in Spotify',
useApiInvalidCurrentUser: 'Invalid current user response.',
useApiInvalidPlaylist: 'Invalid playlist response.',
useApiLoginFailed: 'Spotify login did not complete. Please try again.',
useApiAuthenticationCheckFailed: 'Authentication check failed.',
useApiConnectionUnavailable: 'Spotify connection status is unavailable.',
useApiHealthCheckFailed: 'Health check failed.',
useApiInvalidHealth: 'Invalid health response.',
useApiModeUnavailable: 'Service mode is unavailable.',
useApiLogoutFailed: 'Logout failed.',
useApiSpotifyLogoutFailed: 'Spotify logout failed. Refresh before trying again.',
useApiPlaylistCreationFailed: 'Playlist creation returned {status}.',
useChatStreamTrackCountMismatch: 'The final track count did not match the streamed results.',
useChatStreamPlaylistName: '[discovery-by-llm] {query}',
useChatStreamRequestReplaced: 'This request was replaced by a newer request.',
useChatStreamUnexpectedFailure: 'The recommendation request failed unexpectedly.',
useChatStreamTransportEvent: 'transport',
useChatStreamPlaylistFailure: 'Spotify could not create the playlist. Nothing was retried.',
recommendationStreamInvalidJson: 'The response contained invalid JSON.',
recommendationStreamMissingMetadata: 'The stream did not begin with metadata.',
recommendationStreamDuplicateMetadata: 'The stream contained duplicate metadata.',
recommendationStreamAfterFinalEvent: 'The stream continued after its final event.',
recommendationStreamUnavailable: 'The recommendation service could not be reached.',
recommendationStreamHttpFailure: 'The recommendation service returned {status}.',
recommendationStreamInvalidContentType: 'The response was not an NDJSON stream.',
recommendationStreamEmpty: 'The response stream was empty.',
recommendationStreamUnexpectedEnd:
'The recommendation stream ended before a final event arrived.',
recommendationStreamInterrupted: 'The recommendation stream was interrupted.',
streamParserInvalidField: 'Invalid {field} field.',
streamParserInvalidTrack: 'Invalid track field.',
streamParserMissingType: 'Stream record has no valid type.',
streamParserUnknownEvent: 'Unknown stream event type: {type}.',
} as const satisfies Readonly<Record<string, string>>
export type MessageKey = keyof typeof messages
/** Fill named placeholders in one extracted message. */
export function formatMessage(
key: MessageKey,
values: Readonly<Record<string, string | number>>,
): string {
let message: string = messages[key]
for (const [name, value] of Object.entries(values)) {
message = message.replaceAll(`{${name}}`, String(value))
}
return message
}

View file

@ -1,6 +1,6 @@
import type { CurrentUser, DoneEvent, ErrorEvent, TrackEvent, WarningEvent } from './types'
export type AssistantStatus = 'streaming' | 'done' | 'error'
type AssistantStatus = 'streaming' | 'done' | 'error'
export type AppMode = 'demo' | 'live'
export type TransportFailureKind =
'cancelled' | 'http' | 'network' | 'parse' | 'protocol' | 'unexpected_eof'
@ -17,7 +17,7 @@ export interface PlaylistState {
message: string | null
}
export interface UserTurn {
interface UserTurn {
id: string
role: 'user'
text: string

View file

@ -1,7 +1,10 @@
import { formatMessage, messages } from './messages'
import type { RecommendationRequest, StreamEvent } from './types'
import type { TransportFailureKind } from './models'
import { parseStreamEvent, StreamParseError } from './streamParser'
type StreamPhase = 'metadata' | 'events' | 'terminal'
export class StreamTransportError extends Error {
readonly kind: TransportFailureKind
@ -17,7 +20,7 @@ function parseLine(line: string): StreamEvent {
try {
value = JSON.parse(line)
} catch {
throw new StreamTransportError('parse', 'The response contained invalid JSON.')
throw new StreamTransportError('parse', messages.recommendationStreamInvalidJson)
}
try {
@ -30,6 +33,43 @@ function parseLine(line: string): StreamEvent {
}
}
function advancePhase(phase: StreamPhase, event: StreamEvent): StreamPhase {
if (phase === 'metadata') {
if (event.type === 'metadata') return 'events'
throw new StreamTransportError('protocol', messages.recommendationStreamMissingMetadata)
}
if (phase === 'events') {
if (event.type === 'track' || event.type === 'warning') return 'events'
if (event.type === 'done' || event.type === 'error') return 'terminal'
throw new StreamTransportError('protocol', messages.recommendationStreamDuplicateMetadata)
}
throw new StreamTransportError('protocol', messages.recommendationStreamAfterFinalEvent)
}
async function cancelBody(body: ReadableStream<Uint8Array> | null): Promise<void> {
try {
await body?.cancel()
} catch {
return
}
}
async function cancelReader(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<void> {
try {
await reader.cancel()
} catch {
return
}
}
function releaseReader(reader: ReadableStreamDefaultReader<Uint8Array>): void {
try {
reader.releaseLock()
} catch {
return
}
}
/** Post a recommendation request and consume its NDJSON event stream. */
export async function streamRecommendations(
request: RecommendationRequest,
@ -47,26 +87,29 @@ export async function streamRecommendations(
})
} catch (error) {
if (signal.aborted) throw error
throw new StreamTransportError('network', 'The recommendation service could not be reached.')
throw new StreamTransportError('network', messages.recommendationStreamUnavailable)
}
if (!response.ok) {
await cancelBody(response.body)
throw new StreamTransportError(
'http',
`The recommendation service returned ${response.status}.`,
formatMessage('recommendationStreamHttpFailure', { status: response.status }),
)
}
const contentType = response.headers.get('content-type')?.split(';')[0].trim()
if (contentType !== 'application/x-ndjson') {
throw new StreamTransportError('protocol', 'The response was not an NDJSON stream.')
await cancelBody(response.body)
throw new StreamTransportError('protocol', messages.recommendationStreamInvalidContentType)
}
if (!response.body) {
throw new StreamTransportError('protocol', 'The response stream was empty.')
throw new StreamTransportError('protocol', messages.recommendationStreamEmpty)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let phase: StreamPhase = 'metadata'
try {
while (true) {
@ -79,9 +122,10 @@ export async function streamRecommendations(
const line = rawLine.trim()
if (!line) continue
const event = parseLine(line)
phase = advancePhase(phase, event)
onEvent(event)
if (event.type === 'done' || event.type === 'error') {
await reader.cancel()
if (phase === 'terminal') {
await cancelReader(reader)
return
}
}
@ -92,20 +136,19 @@ export async function streamRecommendations(
const finalLine = buffer.trim()
if (finalLine) {
const event = parseLine(finalLine)
phase = advancePhase(phase, event)
onEvent(event)
if (event.type === 'done' || event.type === 'error') return
if (phase === 'terminal') return
}
} catch (error) {
if (signal.aborted || error instanceof StreamTransportError) throw error
throw new StreamTransportError('network', 'The recommendation stream was interrupted.')
} finally {
reader.releaseLock()
}
throw new StreamTransportError(
'unexpected_eof',
'The recommendation stream ended before a final event arrived.',
)
throw new StreamTransportError('unexpected_eof', messages.recommendationStreamUnexpectedEnd)
} catch (error) {
await cancelReader(reader)
if (signal.aborted || error instanceof StreamTransportError) throw error
throw new StreamTransportError('network', messages.recommendationStreamInterrupted)
} finally {
releaseReader(reader)
}
}
/** Return whether a rejected stream operation was intentionally aborted. */

View file

@ -0,0 +1,13 @@
/** Return a normalized Spotify web URL when the untrusted value is safe to link. */
export function parseSpotifyUrl(value: string): string | null {
try {
const url = new URL(value)
const usesHttps = url.protocol === 'https:'
const usesSpotifyHost = url.host === 'open.spotify.com'
const hasCredentials = Boolean(url.username || url.password)
if (!usesHttps || !usesSpotifyHost || hasCredentials) return null
return url.href
} catch {
return null
}
}

View file

@ -1,4 +1,6 @@
import { formatMessage, messages } from './messages'
import type { StreamEvent, TrackCard } from './types'
import { parseSpotifyUrl } from './spotifyUrl'
export class StreamParseError extends Error {
constructor(message: string) {
@ -13,14 +15,16 @@ function isRecord(value: unknown): value is Record<string, unknown> {
function stringField(value: Record<string, unknown>, key: string): string {
const field = value[key]
if (typeof field !== 'string') throw new StreamParseError(`Invalid ${key} field.`)
if (typeof field !== 'string') {
throw new StreamParseError(formatMessage('streamParserInvalidField', { field: key }))
}
return field
}
function numberField(value: Record<string, unknown>, key: string): number {
const field = value[key]
if (typeof field !== 'number' || !Number.isFinite(field)) {
throw new StreamParseError(`Invalid ${key} field.`)
throw new StreamParseError(formatMessage('streamParserInvalidField', { field: key }))
}
return field
}
@ -28,7 +32,7 @@ function numberField(value: Record<string, unknown>, key: string): number {
function stringArrayField(value: Record<string, unknown>, key: string): string[] {
const field = value[key]
if (!Array.isArray(field) || !field.every((entry) => typeof entry === 'string')) {
throw new StreamParseError(`Invalid ${key} field.`)
throw new StreamParseError(formatMessage('streamParserInvalidField', { field: key }))
}
return field
}
@ -36,13 +40,19 @@ function stringArrayField(value: Record<string, unknown>, key: string): string[]
function nullableStringField(value: Record<string, unknown>, key: string): string | null {
const field = value[key]
if (field !== null && typeof field !== 'string') {
throw new StreamParseError(`Invalid ${key} field.`)
throw new StreamParseError(formatMessage('streamParserInvalidField', { field: key }))
}
return field
}
function spotifyUrlField(value: Record<string, unknown>, key: string): string | null {
const field = nullableStringField(value, key)
if (field === null) return null
return parseSpotifyUrl(field)
}
function parseTrackCard(value: unknown): TrackCard {
if (!isRecord(value)) throw new StreamParseError('Invalid track field.')
if (!isRecord(value)) throw new StreamParseError(messages.streamParserInvalidTrack)
return {
id: stringField(value, 'id'),
uri: stringField(value, 'uri'),
@ -50,14 +60,14 @@ function parseTrackCard(value: unknown): TrackCard {
artists: stringArrayField(value, 'artists'),
album_name: stringField(value, 'album_name'),
album_art_url: nullableStringField(value, 'album_art_url'),
external_url: nullableStringField(value, 'external_url'),
external_url: spotifyUrlField(value, 'external_url'),
}
}
/** Parse one untrusted NDJSON value into the frozen event union. */
export function parseStreamEvent(value: unknown): StreamEvent {
if (!isRecord(value) || typeof value.type !== 'string') {
throw new StreamParseError('Stream record has no valid type.')
throw new StreamParseError(messages.streamParserMissingType)
}
switch (value.type) {
@ -94,6 +104,6 @@ export function parseStreamEvent(value: unknown): StreamEvent {
total_ms: numberField(value, 'total_ms'),
}
default:
throw new StreamParseError(`Unknown stream event type: ${value.type}.`)
throw new StreamParseError(formatMessage('streamParserUnknownEvent', { type: value.type }))
}
}

View file

@ -14,9 +14,9 @@
--c-text: #e8ede8;
--c-text-muted: #8b948c;
--c-text-dim: #7d857e;
--c-text-faint: #666e68;
--c-text-ghost: #4e544f;
--c-text-dim: #858e86;
--c-text-faint: #808981;
--c-text-ghost: #7d857e;
--c-text-chip: #bec5bf;
--c-accent: #7fc96b;
@ -74,9 +74,6 @@
--shadow-drawer: -24px 0 60px rgba(0, 0, 0, 0.45);
--ease: cubic-bezier(0.2, 0.8, 0.3, 1);
--dur-fast: 0.16s;
--dur-card: 0.34s;
color-scheme: dark;
color: var(--c-text);
background: var(--c-bg);
@ -154,7 +151,7 @@ a:hover {
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.001s !important;
transition-duration: 0.001s !important;
animation-duration: var(--dur-reduced-motion) !important;
transition-duration: var(--dur-reduced-motion) !important;
}
}