fix(frontend): refine streamed presentation and resolve stream edge cases
This commit is contained in:
parent
a3af8f45cc
commit
2cc33a721c
18 changed files with 450 additions and 46 deletions
|
|
@ -273,3 +273,48 @@ Waarom:
|
||||||
- Machine-geconverteerde UI-code krijgt dezelfde behandeling als de
|
- Machine-geconverteerde UI-code krijgt dezelfde behandeling als de
|
||||||
backend: een review-pass met concrete findings en een test-suite die de
|
backend: een review-pass met concrete findings en een test-suite die de
|
||||||
contractkritische randen vastzet, voordat het richting main gaat.
|
contractkritische randen vastzet, voordat het richting main gaat.
|
||||||
|
|
||||||
|
## Dag 3 - avond
|
||||||
|
### Live eval run
|
||||||
|
|
||||||
|
Wat ik deed:
|
||||||
|
|
||||||
|
- Alle 8 scenario's in 1 run live gedraaid, plus de baseline arm (bare
|
||||||
|
search) voor de comparison table.
|
||||||
|
- Het Spotify-dagquotum was eerder al een keer vol; daarom vooraf
|
||||||
|
maatregelen genomen: scenario's sequentieel, geen retries, de server-boot
|
||||||
|
zelf als quota-check, en live meegekeken op 429's (niet gehit).
|
||||||
|
- Uitkomst: 4 scenario's pass, 3 vallen alleen op een te strenge
|
||||||
|
synonym-check in de eval zelf (gefixt), en discover-new faalt echt:
|
||||||
|
call 1 verzint bij familiarity=new titels die niet bestaan, dus er
|
||||||
|
overleeft niets de grounding. Fix volgt in de intent prompt.
|
||||||
|
- De refinement turn deed live 0 Spotify calls: pool reuse werkt.
|
||||||
|
|
||||||
|
Waarom:
|
||||||
|
|
||||||
|
- De comparison table in de README wil ik op echte metingen baseren, en het
|
||||||
|
quotum maakt herhalen duur.
|
||||||
|
|
||||||
|
Wat ik heb laten vallen of uitgesteld:
|
||||||
|
|
||||||
|
- discover-new opnieuw opnemen wacht op de prompt fix.
|
||||||
|
|
||||||
|
### Frontend: presentatie
|
||||||
|
|
||||||
|
Wat ik deed:
|
||||||
|
|
||||||
|
- Cards komen rustig gestaggerd binnen (met reduced-motion pad), results
|
||||||
|
en chips scrollen in eigen panelen zodat de summary zichtbaar blijft,
|
||||||
|
klik op het logo start een nieuwe chat, fonts self-hosted.
|
||||||
|
- Een error die voor de metadata binnenkomt is nu een echte foutmelding
|
||||||
|
in de chat, geen transport failure. Playlist-namen hadden een dubbele
|
||||||
|
prefix; de frontend stuurt nu de kale query. 10 nieuwe tests.
|
||||||
|
|
||||||
|
Waarom:
|
||||||
|
|
||||||
|
- Dit is wat de reviewer als eerste ziet; rustige presentatie en eerlijke
|
||||||
|
foutmeldingen gaan voor extra features.
|
||||||
|
|
||||||
|
Wat ik heb laten vallen of uitgesteld:
|
||||||
|
|
||||||
|
- Verdere styling; de tijd gaat naar eval, demo mode en de README.
|
||||||
|
|
|
||||||
27
frontend/package-lock.json
generated
27
frontend/package-lock.json
generated
|
|
@ -8,6 +8,9 @@
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/bricolage-grotesque": "^5.3.0",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||||
|
"@fontsource/public-sans": "^5.3.0",
|
||||||
"vue": "^3.5.40"
|
"vue": "^3.5.40"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -156,6 +159,30 @@
|
||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource/bricolage-grotesque": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/bricolage-grotesque/-/bricolage-grotesque-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-MdOVb/5in11IfN/IQJOExMuu6AroCIQbirl0yX7NUvKk+h0HYIKfGaVAXRS/gCPil4aFxWhjZnOw28tUwv7tFw==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource/jetbrains-mono": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource/public-sans": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/public-sans/-/public-sans-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-kjODI0S3zdv0mBYCIQ8TbBayaiqszpc2UbhJiO3bjIqVVXzcWfHSt2o3WBCLOY3juaGaQoy4MoWCcgmfI5hCuA==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@
|
||||||
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json"
|
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/bricolage-grotesque": "^5.3.0",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||||
|
"@fontsource/public-sans": "^5.3.0",
|
||||||
"vue": "^3.5.40"
|
"vue": "^3.5.40"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ const props = defineProps<{
|
||||||
mode: AppMode | null
|
mode: AppMode | null
|
||||||
healthFailed: boolean
|
healthFailed: boolean
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{ logout: []; toggleDev: [opener: HTMLElement] }>()
|
const emit = defineEmits<{ logout: []; reset: []; toggleDev: [opener: HTMLElement] }>()
|
||||||
|
|
||||||
function openDevPanel(event: MouseEvent): void {
|
function openDevPanel(event: MouseEvent): void {
|
||||||
if (event.currentTarget instanceof HTMLElement) {
|
if (event.currentTarget instanceof HTMLElement) {
|
||||||
|
|
@ -29,11 +29,18 @@ const modeLabel = computed(() => {
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="inner">
|
<div class="inner">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
|
<button
|
||||||
|
class="brand-control"
|
||||||
|
type="button"
|
||||||
|
:aria-label="messages.appHeaderBrandResetLabel"
|
||||||
|
@click="$emit('reset')"
|
||||||
|
>
|
||||||
<span class="wordmark"
|
<span class="wordmark"
|
||||||
>{{ messages.appHeaderBrandPrefix
|
>{{ messages.appHeaderBrandPrefix
|
||||||
}}<span class="accent">{{ messages.appHeaderBrandAccent }}</span
|
}}<span class="accent">{{ messages.appHeaderBrandAccent }}</span
|
||||||
>{{ messages.appHeaderBrandSuffix }}</span
|
>{{ messages.appHeaderBrandSuffix }}</span
|
||||||
>
|
>
|
||||||
|
</button>
|
||||||
<span class="kicker">{{ messages.appHeaderKicker }}</span>
|
<span class="kicker">{{ messages.appHeaderKicker }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
|
|
@ -78,6 +85,20 @@ const modeLabel = computed(() => {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-control {
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-control:focus-visible {
|
||||||
|
outline: 2px solid var(--c-accent);
|
||||||
|
outline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.wordmark {
|
.wordmark {
|
||||||
font-family: var(--f-display);
|
font-family: var(--f-display);
|
||||||
font-size: var(--t-brand);
|
font-size: var(--t-brand);
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,6 @@ const isEmptyResult = computed(
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<p v-if="turn.intentSummary" class="intent">{{ turn.intentSummary }}</p>
|
<p v-if="turn.intentSummary" class="intent">{{ turn.intentSummary }}</p>
|
||||||
|
|
||||||
<ResultSet v-if="turn.tracks.length" :tracks="turn.tracks" />
|
|
||||||
|
|
||||||
<ThinkingIndicator v-if="turn.status === 'streaming'" :label="thinkingLabel" />
|
<ThinkingIndicator v-if="turn.status === 'streaming'" :label="thinkingLabel" />
|
||||||
|
|
||||||
<StreamWarning
|
<StreamWarning
|
||||||
|
|
@ -85,6 +83,8 @@ const isEmptyResult = computed(
|
||||||
:url="turn.playlist.url"
|
:url="turn.playlist.url"
|
||||||
:track-count="turn.tracks.length"
|
:track-count="turn.tracks.length"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ResultSet v-if="turn.tracks.length" :tracks="turn.tracks" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ onUnmounted(() => window.removeEventListener('keydown', onShortcut))
|
||||||
:mode="mode"
|
:mode="mode"
|
||||||
:health-failed="health.status === 'failed'"
|
:health-failed="health.status === 'failed'"
|
||||||
@logout="logout"
|
@logout="logout"
|
||||||
|
@reset="reset"
|
||||||
@toggle-dev="openDevPanel"
|
@toggle-dev="openDevPanel"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,13 @@ async function scrollToLatest(): Promise<void> {
|
||||||
if (!pinned.value) return
|
if (!pinned.value) return
|
||||||
await nextTick()
|
await nextTick()
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (list.value) list.value.scrollTop = list.value.scrollHeight
|
const element = list.value
|
||||||
|
if (!element) return
|
||||||
|
if (latestAssistant.value?.status === 'done') {
|
||||||
|
element.querySelector<HTMLElement>('.turn:last-child')?.scrollIntoView({ block: 'start' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
element.scrollTop = element.scrollHeight
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,28 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { messages } from '../lib/messages'
|
import { computed } from 'vue'
|
||||||
|
import { formatMessage } from '../lib/messages'
|
||||||
import type { TrackEvent } from '../lib/types'
|
import type { TrackEvent } from '../lib/types'
|
||||||
import TrackCard from './TrackCard.vue'
|
import TrackCard from './TrackCard.vue'
|
||||||
|
|
||||||
defineProps<{ tracks: TrackEvent[] }>()
|
const props = defineProps<{ tracks: TrackEvent[] }>()
|
||||||
|
const regionLabel = computed(() =>
|
||||||
|
formatMessage(
|
||||||
|
props.tracks.length === 1
|
||||||
|
? 'resultSetRecommendedTrackLabel'
|
||||||
|
: 'resultSetRecommendedTracksLabel',
|
||||||
|
{ count: props.tracks.length },
|
||||||
|
),
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="set" :aria-label="messages.resultSetRecommendedTracksLabel">
|
<div class="set" role="region" :aria-label="regionLabel" tabindex="0">
|
||||||
<TrackCard v-for="event in tracks" :key="event.track.id" :event="event" />
|
<TrackCard
|
||||||
|
v-for="(event, index) in tracks"
|
||||||
|
:key="event.track.id"
|
||||||
|
:event="event"
|
||||||
|
:style="`--card-order: ${index}`"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -17,5 +31,18 @@ defineProps<{ tracks: TrackEvent[] }>()
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--s-2);
|
gap: var(--s-2);
|
||||||
|
max-height: min(42dvh, 480px);
|
||||||
|
padding: var(--s-2) var(--s-3) var(--s-2) 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
|
border-block: 1px solid var(--c-line);
|
||||||
|
scrollbar-color: var(--c-line-strong) transparent;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.set {
|
||||||
|
max-height: min(34dvh, 360px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { messages } from '../lib/messages'
|
||||||
|
|
||||||
defineProps<{ suggestions: readonly string[] }>()
|
defineProps<{ suggestions: readonly string[] }>()
|
||||||
defineEmits<{ pick: [suggestion: string] }>()
|
defineEmits<{ pick: [suggestion: string] }>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="chips">
|
<div class="frame">
|
||||||
|
<div class="chips" role="region" :aria-label="messages.suggestionChipsRegionLabel" tabindex="0">
|
||||||
<button
|
<button
|
||||||
v-for="suggestion in suggestions"
|
v-for="suggestion in suggestions"
|
||||||
:key="suggestion"
|
:key="suggestion"
|
||||||
|
|
@ -15,13 +18,43 @@ defineEmits<{ pick: [suggestion: string] }>()
|
||||||
{{ suggestion }}
|
{{ suggestion }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.frame {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--c-surface-raised);
|
||||||
|
border: 1px solid var(--c-line);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
right: 1px;
|
||||||
|
bottom: var(--s-3);
|
||||||
|
width: var(--s-10);
|
||||||
|
pointer-events: none;
|
||||||
|
content: '';
|
||||||
|
background: linear-gradient(to right, transparent, var(--c-surface-raised));
|
||||||
|
}
|
||||||
|
|
||||||
.chips {
|
.chips {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
gap: var(--s-3);
|
gap: var(--s-3);
|
||||||
|
padding: var(--s-3) var(--s-10) var(--s-3) var(--s-3);
|
||||||
|
overflow-x: auto;
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
|
scrollbar-color: var(--c-line-strong) transparent;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips:focus-visible {
|
||||||
|
outline-offset: -3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip {
|
.chip {
|
||||||
|
|
@ -31,7 +64,7 @@ defineEmits<{ pick: [suggestion: string] }>()
|
||||||
font-size: var(--t-small);
|
font-size: var(--t-small);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--c-surface-raised);
|
background: var(--c-surface);
|
||||||
border: 1px solid var(--c-line);
|
border: 1px solid var(--c-line);
|
||||||
border-radius: var(--r-pill);
|
border-radius: var(--r-pill);
|
||||||
transition: all var(--dur-fast) ease;
|
transition: all var(--dur-fast) ease;
|
||||||
|
|
@ -42,18 +75,4 @@ defineEmits<{ pick: [suggestion: string] }>()
|
||||||
border-color: var(--c-accent);
|
border-color: var(--c-accent);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
|
||||||
.chips {
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
padding: 0 var(--gutter);
|
|
||||||
margin: 0 calc(-1 * var(--gutter));
|
|
||||||
overflow-x: auto;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chips::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ const artworkAlt = computed(() =>
|
||||||
border-color var(--dur-fast) ease,
|
border-color var(--dur-fast) ease,
|
||||||
background var(--dur-fast) ease;
|
background var(--dur-fast) ease;
|
||||||
animation: card-in var(--dur-card) var(--ease) both;
|
animation: card-in var(--dur-card) var(--ease) both;
|
||||||
|
animation-delay: calc(var(--card-order, 0) * var(--dur-card-stagger));
|
||||||
}
|
}
|
||||||
|
|
||||||
.card:hover {
|
.card:hover {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
PRIOR_RECOMMENDATIONS_MAX_TRACKS,
|
PRIOR_RECOMMENDATIONS_MAX_TRACKS,
|
||||||
QUERY_MAX_LENGTH,
|
QUERY_MAX_LENGTH,
|
||||||
} from '../lib/constants'
|
} from '../lib/constants'
|
||||||
import { formatMessage, messages } from '../lib/messages'
|
import { messages } from '../lib/messages'
|
||||||
import type { AssistantTurn, ChatTurn, EventLogEntry, TransportFailure } from '../lib/models'
|
import type { AssistantTurn, ChatTurn, EventLogEntry, TransportFailure } from '../lib/models'
|
||||||
import { EMPTY_PLAYLIST_STATE } from '../lib/models'
|
import { EMPTY_PLAYLIST_STATE } from '../lib/models'
|
||||||
import {
|
import {
|
||||||
|
|
@ -101,9 +101,7 @@ function reduceEvent(turn: AssistantTurn, event: StreamEvent): AssistantTurn {
|
||||||
}
|
}
|
||||||
|
|
||||||
function playlistName(query: string): string {
|
function playlistName(query: string): string {
|
||||||
return formatMessage('useChatStreamPlaylistName', { query })
|
return query.slice(0, PLAYLIST_NAME_MAX_LENGTH).trim()
|
||||||
.slice(0, PLAYLIST_NAME_MAX_LENGTH)
|
|
||||||
.trim()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTurnId(): string {
|
function createTurnId(): string {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
export const messages = {
|
export const messages = {
|
||||||
appHeaderBrandPrefix: 'discovery',
|
appHeaderBrandPrefix: 'discovery',
|
||||||
|
appHeaderBrandResetLabel: 'discovery-by-llm: start a new chat',
|
||||||
appHeaderBrandAccent: '-by-',
|
appHeaderBrandAccent: '-by-',
|
||||||
appHeaderBrandSuffix: 'llm',
|
appHeaderBrandSuffix: 'llm',
|
||||||
appHeaderKicker: 'proof of concept',
|
appHeaderKicker: 'proof of concept',
|
||||||
|
|
@ -90,10 +91,13 @@ export const messages = {
|
||||||
resultActionsConnectSpotify: 'Connect Spotify to save',
|
resultActionsConnectSpotify: 'Connect Spotify to save',
|
||||||
resultActionsSummary: '{count} verified tracks ready for a private playlist',
|
resultActionsSummary: '{count} verified tracks ready for a private playlist',
|
||||||
|
|
||||||
resultSetRecommendedTracksLabel: 'Recommended tracks',
|
resultSetRecommendedTrackLabel: '{count} recommended track',
|
||||||
|
resultSetRecommendedTracksLabel: '{count} recommended tracks',
|
||||||
|
|
||||||
streamErrorRetry: 'Edit and retry',
|
streamErrorRetry: 'Edit and retry',
|
||||||
|
|
||||||
|
suggestionChipsRegionLabel: 'Listening suggestions',
|
||||||
|
|
||||||
trackCardArtworkAlt: '{album} album artwork',
|
trackCardArtworkAlt: '{album} album artwork',
|
||||||
trackCardOpenSpotify: 'Open in Spotify',
|
trackCardOpenSpotify: 'Open in Spotify',
|
||||||
|
|
||||||
|
|
@ -110,7 +114,6 @@ export const messages = {
|
||||||
useApiPlaylistCreationFailed: 'Playlist creation returned {status}.',
|
useApiPlaylistCreationFailed: 'Playlist creation returned {status}.',
|
||||||
|
|
||||||
useChatStreamTrackCountMismatch: 'The final track count did not match the streamed results.',
|
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.',
|
useChatStreamRequestReplaced: 'This request was replaced by a newer request.',
|
||||||
useChatStreamUnexpectedFailure: 'The recommendation request failed unexpectedly.',
|
useChatStreamUnexpectedFailure: 'The recommendation request failed unexpectedly.',
|
||||||
useChatStreamTransportEvent: 'transport',
|
useChatStreamTransportEvent: 'transport',
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ function parseLine(line: string): StreamEvent {
|
||||||
function advancePhase(phase: StreamPhase, event: StreamEvent): StreamPhase {
|
function advancePhase(phase: StreamPhase, event: StreamEvent): StreamPhase {
|
||||||
if (phase === 'metadata') {
|
if (phase === 'metadata') {
|
||||||
if (event.type === 'metadata') return 'events'
|
if (event.type === 'metadata') return 'events'
|
||||||
|
if (event.type === 'error') return 'terminal'
|
||||||
throw new StreamTransportError('protocol', messages.recommendationStreamMissingMetadata)
|
throw new StreamTransportError('protocol', messages.recommendationStreamMissingMetadata)
|
||||||
}
|
}
|
||||||
if (phase === 'events') {
|
if (phase === 'events') {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
|
import '@fontsource/bricolage-grotesque/600.css'
|
||||||
|
import '@fontsource/jetbrains-mono/400.css'
|
||||||
|
import '@fontsource/public-sans/400.css'
|
||||||
|
import '@fontsource/public-sans/500.css'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,11 @@
|
||||||
|
|
||||||
--shadow-drawer: -24px 0 60px rgba(0, 0, 0, 0.45);
|
--shadow-drawer: -24px 0 60px rgba(0, 0, 0, 0.45);
|
||||||
--ease: cubic-bezier(0.2, 0.8, 0.3, 1);
|
--ease: cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||||
|
--dur-fast: 0.16s;
|
||||||
|
--dur-card: 0.4s;
|
||||||
|
--dur-card-stagger: 0.08s;
|
||||||
|
--dur-reduced-motion: 0.001s;
|
||||||
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
color: var(--c-text);
|
color: var(--c-text);
|
||||||
background: var(--c-bg);
|
background: var(--c-bg);
|
||||||
|
|
@ -151,7 +156,9 @@ a:hover {
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
scroll-behavior: auto !important;
|
scroll-behavior: auto !important;
|
||||||
|
animation-delay: var(--dur-reduced-motion) !important;
|
||||||
animation-duration: var(--dur-reduced-motion) !important;
|
animation-duration: var(--dur-reduced-motion) !important;
|
||||||
|
transition-delay: var(--dur-reduced-motion) !important;
|
||||||
transition-duration: var(--dur-reduced-motion) !important;
|
transition-duration: var(--dur-reduced-motion) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -128,8 +128,23 @@ describe('streamRecommendations', () => {
|
||||||
expect(wasCancelled).toBe(true)
|
expect(wasCancelled).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('accepts a terminal error as the only event', async () => {
|
||||||
|
const error = { type: 'error', code: 'first_stage_failed', message: 'Planning failed.' }
|
||||||
|
stubResponse(streamResponse([encoder.encode(`${JSON.stringify(error)}\n`)]))
|
||||||
|
const events: StreamEvent[] = []
|
||||||
|
|
||||||
|
await streamRecommendations(
|
||||||
|
{ schema_version: 1, query: 'focus', history: [], prior_recommendations: [] },
|
||||||
|
new AbortController().signal,
|
||||||
|
(event) => events.push(event),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(events).toEqual([error])
|
||||||
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['an event before metadata', [{ type: 'warning', code: 'early', message: 'No metadata.' }]],
|
['an event before metadata', [{ type: 'warning', code: 'early', message: 'No metadata.' }]],
|
||||||
|
['done before metadata', [{ type: 'done', track_count: 0, total_ms: 4 }]],
|
||||||
[
|
[
|
||||||
'duplicate metadata',
|
'duplicate metadata',
|
||||||
[
|
[
|
||||||
|
|
|
||||||
186
frontend/tests/resultPresentation.test.ts
Normal file
186
frontend/tests/resultPresentation.test.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
import { createApp, defineComponent, h, nextTick, ref } from 'vue'
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import AppHeader from '../src/components/AppHeader.vue'
|
||||||
|
import AssistantMessage from '../src/components/AssistantMessage.vue'
|
||||||
|
import MessageList from '../src/components/MessageList.vue'
|
||||||
|
import SuggestionChips from '../src/components/SuggestionChips.vue'
|
||||||
|
import type { AssistantTurn, ChatTurn } from '../src/lib/models'
|
||||||
|
import type { TrackEvent } from '../src/lib/types'
|
||||||
|
|
||||||
|
const unmountCallbacks: Array<() => void> = []
|
||||||
|
|
||||||
|
function track(rank: number): TrackEvent {
|
||||||
|
return {
|
||||||
|
type: 'track',
|
||||||
|
rank,
|
||||||
|
track: {
|
||||||
|
id: `track-${rank}`,
|
||||||
|
uri: `spotify:track:${rank}`,
|
||||||
|
title: `Track ${rank}`,
|
||||||
|
artists: ['Artist'],
|
||||||
|
album_name: 'Album',
|
||||||
|
album_art_url: null,
|
||||||
|
external_url: null,
|
||||||
|
},
|
||||||
|
justification: 'It fits.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function doneTurn(): AssistantTurn {
|
||||||
|
return {
|
||||||
|
id: 'assistant-1',
|
||||||
|
role: 'assistant',
|
||||||
|
query: 'Focused listening',
|
||||||
|
status: 'done',
|
||||||
|
requestId: 'request-1',
|
||||||
|
intentSummary: 'Calm music for focused work.',
|
||||||
|
candidateCount: 20,
|
||||||
|
tracks: [track(1), track(2)],
|
||||||
|
warnings: [{ type: 'warning', code: 'limited_pool', message: 'The pool was limited.' }],
|
||||||
|
error: null,
|
||||||
|
transportFailure: null,
|
||||||
|
completion: { type: 'done', track_count: 2, total_ms: 12 },
|
||||||
|
playlist: { status: 'idle', name: null, url: null, message: null },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountComponent(component: Component, props: Record<string, unknown>): HTMLElement {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
document.body.append(root)
|
||||||
|
const app = createApp(
|
||||||
|
defineComponent({
|
||||||
|
render: () => h(component, props),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
app.mount(root)
|
||||||
|
unmountCallbacks.push(() => {
|
||||||
|
app.unmount()
|
||||||
|
root.remove()
|
||||||
|
})
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (unmountCallbacks.length) unmountCallbacks.pop()?.()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('result presentation', () => {
|
||||||
|
it('uses the app title as a fresh-chat control', () => {
|
||||||
|
const onReset = vi.fn()
|
||||||
|
const root = mountComponent(AppHeader, {
|
||||||
|
auth: { status: 'anonymous', user: null, message: null },
|
||||||
|
mode: 'demo',
|
||||||
|
healthFailed: false,
|
||||||
|
onReset,
|
||||||
|
})
|
||||||
|
const button = root.querySelector<HTMLButtonElement>(
|
||||||
|
'button[aria-label="discovery-by-llm: start a new chat"]',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(button?.type).toBe('button')
|
||||||
|
expect(button?.textContent).toContain('discovery-by-llm')
|
||||||
|
button?.click()
|
||||||
|
expect(onReset).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps completion context outside the focusable track region', () => {
|
||||||
|
const root = mountComponent(AssistantMessage, { turn: doneTurn(), canSave: true })
|
||||||
|
const region = root.querySelector<HTMLElement>(
|
||||||
|
'[role="region"][aria-label="2 recommended tracks"]',
|
||||||
|
)
|
||||||
|
const summary = root.querySelector<HTMLElement>('.intent')
|
||||||
|
const warning = root.querySelector<HTMLElement>('[data-warning-code="limited_pool"]')
|
||||||
|
const action = root.querySelector<HTMLButtonElement>('button')
|
||||||
|
|
||||||
|
expect(region?.tabIndex).toBe(0)
|
||||||
|
expect(region?.querySelectorAll('article')).toHaveLength(2)
|
||||||
|
expect(summary?.textContent).toContain('Calm music')
|
||||||
|
expect(warning?.textContent).toContain('pool was limited')
|
||||||
|
expect(action?.textContent).toContain('Save as playlist')
|
||||||
|
expect(region?.contains(summary ?? null)).toBe(false)
|
||||||
|
expect(region?.contains(warning ?? null)).toBe(false)
|
||||||
|
expect(region?.contains(action ?? null)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('assigns a deliberate stagger order to arriving cards', () => {
|
||||||
|
const root = mountComponent(AssistantMessage, { turn: doneTurn(), canSave: true })
|
||||||
|
const cards = root.querySelectorAll<HTMLElement>('article')
|
||||||
|
|
||||||
|
expect(cards[0]?.style.getPropertyValue('--card-order')).toBe('0')
|
||||||
|
expect(cards[1]?.style.getPropertyValue('--card-order')).toBe('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('anchors a completed response at the top of the conversation viewport', async () => {
|
||||||
|
const scrollIntoView = vi
|
||||||
|
.spyOn(Element.prototype, 'scrollIntoView')
|
||||||
|
.mockImplementation(() => undefined)
|
||||||
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||||
|
callback(0)
|
||||||
|
return 1
|
||||||
|
})
|
||||||
|
const complete = doneTurn()
|
||||||
|
const turns = ref<ChatTurn[]>([
|
||||||
|
{ id: 'user-1', role: 'user', text: 'Focused listening' },
|
||||||
|
{ ...complete, status: 'streaming', completion: null },
|
||||||
|
])
|
||||||
|
const root = document.createElement('div')
|
||||||
|
document.body.append(root)
|
||||||
|
const app = createApp(
|
||||||
|
defineComponent({
|
||||||
|
render: () =>
|
||||||
|
h(MessageList, {
|
||||||
|
turns: turns.value,
|
||||||
|
suggestions: [],
|
||||||
|
canSave: true,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
app.mount(root)
|
||||||
|
unmountCallbacks.push(() => {
|
||||||
|
app.unmount()
|
||||||
|
root.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
turns.value = [turns.value[0] as ChatTurn, complete]
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'start' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps suggestions in a labeled focusable row', () => {
|
||||||
|
const onPick = vi.fn()
|
||||||
|
const suggestions = ['Focus', 'Energy', 'Surprise']
|
||||||
|
const root = mountComponent(SuggestionChips, { suggestions, onPick })
|
||||||
|
const region = root.querySelector<HTMLElement>('[aria-label="Listening suggestions"]')
|
||||||
|
const buttons = root.querySelectorAll<HTMLButtonElement>('button')
|
||||||
|
|
||||||
|
expect(region?.tabIndex).toBe(0)
|
||||||
|
expect(buttons).toHaveLength(suggestions.length)
|
||||||
|
expect([...buttons].map((button) => button.textContent?.trim())).toEqual(suggestions)
|
||||||
|
|
||||||
|
buttons[0]?.click()
|
||||||
|
expect(onPick).toHaveBeenCalledWith('Focus')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a first-stage stream error with its code and message', () => {
|
||||||
|
const turn: AssistantTurn = {
|
||||||
|
...doneTurn(),
|
||||||
|
status: 'error',
|
||||||
|
requestId: null,
|
||||||
|
intentSummary: '',
|
||||||
|
tracks: [],
|
||||||
|
warnings: [],
|
||||||
|
error: { type: 'error', code: 'first_stage_failed', message: 'Planning failed.' },
|
||||||
|
transportFailure: null,
|
||||||
|
completion: null,
|
||||||
|
}
|
||||||
|
const root = mountComponent(AssistantMessage, { turn, canSave: false })
|
||||||
|
const alert = root.querySelector<HTMLElement>('[role="alert"]')
|
||||||
|
|
||||||
|
expect(alert?.dataset.errorCode).toBe('first_stage_failed')
|
||||||
|
expect(alert?.textContent).toContain('Planning failed.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -13,14 +13,16 @@ vi.mock('../src/lib/recommendationStream', async (importOriginal) => {
|
||||||
const streamMock = vi.mocked(streamRecommendations)
|
const streamMock = vi.mocked(streamRecommendations)
|
||||||
const unmountCallbacks: Array<() => void> = []
|
const unmountCallbacks: Array<() => void> = []
|
||||||
|
|
||||||
function mountChat() {
|
function mountChat(
|
||||||
|
createPlaylist: Parameters<typeof useChatStream>[0] = vi.fn(async () => ({ url: null })),
|
||||||
|
) {
|
||||||
let chat: ReturnType<typeof useChatStream> | undefined
|
let chat: ReturnType<typeof useChatStream> | undefined
|
||||||
const root = document.createElement('div')
|
const root = document.createElement('div')
|
||||||
document.body.append(root)
|
document.body.append(root)
|
||||||
const app = createApp(
|
const app = createApp(
|
||||||
defineComponent({
|
defineComponent({
|
||||||
setup() {
|
setup() {
|
||||||
chat = useChatStream(vi.fn(async () => ({ url: null })))
|
chat = useChatStream(createPlaylist)
|
||||||
return () => h('div')
|
return () => h('div')
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
@ -106,6 +108,22 @@ describe('useChatStream', () => {
|
||||||
expect(turn?.transportFailure).toBeNull()
|
expect(turn?.transportFailure).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('stores a metadata-free terminal error as a normal stream error', async () => {
|
||||||
|
streamMock.mockImplementation(async (_request, _signal, onEvent) => {
|
||||||
|
onEvent({ type: 'error', code: 'first_stage_failed', message: 'Planning failed.' })
|
||||||
|
})
|
||||||
|
const chat = mountChat()
|
||||||
|
|
||||||
|
await chat.send('error')
|
||||||
|
|
||||||
|
expect(assistantTurns(chat)[0]).toMatchObject({
|
||||||
|
status: 'error',
|
||||||
|
requestId: null,
|
||||||
|
error: { code: 'first_stage_failed', message: 'Planning failed.' },
|
||||||
|
transportFailure: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('stores an incomplete stream as a transport failure', async () => {
|
it('stores an incomplete stream as a transport failure', async () => {
|
||||||
streamMock.mockRejectedValue(
|
streamMock.mockRejectedValue(
|
||||||
new StreamTransportError('unexpected_eof', 'The stream ended early.'),
|
new StreamTransportError('unexpected_eof', 'The stream ended early.'),
|
||||||
|
|
@ -239,4 +257,26 @@ describe('useChatStream', () => {
|
||||||
|
|
||||||
expect(receivedQuery).toHaveLength(1000)
|
expect(receivedQuery).toHaveLength(1000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses the bare query as the capped playlist name', async () => {
|
||||||
|
const createPlaylist = vi.fn(async () => ({ url: null }))
|
||||||
|
streamMock.mockImplementation(async (_request, _signal, onEvent) => {
|
||||||
|
onEvent(metadata('playlist'))
|
||||||
|
onEvent(track(1))
|
||||||
|
onEvent({ type: 'done', track_count: 1, total_ms: 5 })
|
||||||
|
})
|
||||||
|
const chat = mountChat(createPlaylist)
|
||||||
|
const query = `Late-night instrumental focus ${'x'.repeat(100)}`
|
||||||
|
|
||||||
|
await chat.send(query)
|
||||||
|
const turn = assistantTurns(chat)[0]
|
||||||
|
if (!turn) throw new Error('Assistant turn was not created.')
|
||||||
|
await chat.savePlaylist(turn.id)
|
||||||
|
|
||||||
|
expect(createPlaylist).toHaveBeenCalledWith({
|
||||||
|
schema_version: 1,
|
||||||
|
name: query.slice(0, 100),
|
||||||
|
track_uris: ['spotify:track:1'],
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue