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
|
|
@ -9,7 +9,7 @@ const props = defineProps<{
|
|||
mode: AppMode | null
|
||||
healthFailed: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ logout: []; toggleDev: [opener: HTMLElement] }>()
|
||||
const emit = defineEmits<{ logout: []; reset: []; toggleDev: [opener: HTMLElement] }>()
|
||||
|
||||
function openDevPanel(event: MouseEvent): void {
|
||||
if (event.currentTarget instanceof HTMLElement) {
|
||||
|
|
@ -29,11 +29,18 @@ const modeLabel = computed(() => {
|
|||
<header class="header">
|
||||
<div class="inner">
|
||||
<div class="brand">
|
||||
<span class="wordmark"
|
||||
>{{ messages.appHeaderBrandPrefix
|
||||
}}<span class="accent">{{ messages.appHeaderBrandAccent }}</span
|
||||
>{{ messages.appHeaderBrandSuffix }}</span
|
||||
<button
|
||||
class="brand-control"
|
||||
type="button"
|
||||
:aria-label="messages.appHeaderBrandResetLabel"
|
||||
@click="$emit('reset')"
|
||||
>
|
||||
<span class="wordmark"
|
||||
>{{ messages.appHeaderBrandPrefix
|
||||
}}<span class="accent">{{ messages.appHeaderBrandAccent }}</span
|
||||
>{{ messages.appHeaderBrandSuffix }}</span
|
||||
>
|
||||
</button>
|
||||
<span class="kicker">{{ messages.appHeaderKicker }}</span>
|
||||
</div>
|
||||
<div class="controls">
|
||||
|
|
@ -78,6 +85,20 @@ const modeLabel = computed(() => {
|
|||
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 {
|
||||
font-family: var(--f-display);
|
||||
font-size: var(--t-brand);
|
||||
|
|
|
|||
|
|
@ -42,8 +42,6 @@ const isEmptyResult = computed(
|
|||
<div class="column">
|
||||
<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" />
|
||||
|
||||
<StreamWarning
|
||||
|
|
@ -85,6 +83,8 @@ const isEmptyResult = computed(
|
|||
:url="turn.playlist.url"
|
||||
:track-count="turn.tracks.length"
|
||||
/>
|
||||
|
||||
<ResultSet v-if="turn.tracks.length" :tracks="turn.tracks" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ onUnmounted(() => window.removeEventListener('keydown', onShortcut))
|
|||
:mode="mode"
|
||||
:health-failed="health.status === 'failed'"
|
||||
@logout="logout"
|
||||
@reset="reset"
|
||||
@toggle-dev="openDevPanel"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,13 @@ async function scrollToLatest(): Promise<void> {
|
|||
if (!pinned.value) return
|
||||
await nextTick()
|
||||
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">
|
||||
import { messages } from '../lib/messages'
|
||||
import { computed } from 'vue'
|
||||
import { formatMessage } from '../lib/messages'
|
||||
import type { TrackEvent } from '../lib/types'
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="set" :aria-label="messages.resultSetRecommendedTracksLabel">
|
||||
<TrackCard v-for="event in tracks" :key="event.track.id" :event="event" />
|
||||
<div class="set" role="region" :aria-label="regionLabel" tabindex="0">
|
||||
<TrackCard
|
||||
v-for="(event, index) in tracks"
|
||||
:key="event.track.id"
|
||||
:event="event"
|
||||
:style="`--card-order: ${index}`"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -17,5 +31,18 @@ defineProps<{ tracks: TrackEvent[] }>()
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,60 @@
|
|||
<script setup lang="ts">
|
||||
import { messages } from '../lib/messages'
|
||||
|
||||
defineProps<{ suggestions: readonly string[] }>()
|
||||
defineEmits<{ pick: [suggestion: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chips">
|
||||
<button
|
||||
v-for="suggestion in suggestions"
|
||||
:key="suggestion"
|
||||
class="chip"
|
||||
type="button"
|
||||
@click="$emit('pick', suggestion)"
|
||||
>
|
||||
{{ suggestion }}
|
||||
</button>
|
||||
<div class="frame">
|
||||
<div class="chips" role="region" :aria-label="messages.suggestionChipsRegionLabel" tabindex="0">
|
||||
<button
|
||||
v-for="suggestion in suggestions"
|
||||
:key="suggestion"
|
||||
class="chip"
|
||||
type="button"
|
||||
@click="$emit('pick', suggestion)"
|
||||
>
|
||||
{{ suggestion }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
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 {
|
||||
|
|
@ -31,7 +64,7 @@ defineEmits<{ pick: [suggestion: string] }>()
|
|||
font-size: var(--t-small);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
background: var(--c-surface-raised);
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-line);
|
||||
border-radius: var(--r-pill);
|
||||
transition: all var(--dur-fast) ease;
|
||||
|
|
@ -42,18 +75,4 @@ defineEmits<{ pick: [suggestion: string] }>()
|
|||
border-color: var(--c-accent);
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ const artworkAlt = computed(() =>
|
|||
border-color var(--dur-fast) ease,
|
||||
background var(--dur-fast) ease;
|
||||
animation: card-in var(--dur-card) var(--ease) both;
|
||||
animation-delay: calc(var(--card-order, 0) * var(--dur-card-stagger));
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
PRIOR_RECOMMENDATIONS_MAX_TRACKS,
|
||||
QUERY_MAX_LENGTH,
|
||||
} from '../lib/constants'
|
||||
import { formatMessage, messages } from '../lib/messages'
|
||||
import { messages } from '../lib/messages'
|
||||
import type { AssistantTurn, ChatTurn, EventLogEntry, TransportFailure } from '../lib/models'
|
||||
import { EMPTY_PLAYLIST_STATE } from '../lib/models'
|
||||
import {
|
||||
|
|
@ -101,9 +101,7 @@ function reduceEvent(turn: AssistantTurn, event: StreamEvent): AssistantTurn {
|
|||
}
|
||||
|
||||
function playlistName(query: string): string {
|
||||
return formatMessage('useChatStreamPlaylistName', { query })
|
||||
.slice(0, PLAYLIST_NAME_MAX_LENGTH)
|
||||
.trim()
|
||||
return query.slice(0, PLAYLIST_NAME_MAX_LENGTH).trim()
|
||||
}
|
||||
|
||||
function createTurnId(): string {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
export const messages = {
|
||||
appHeaderBrandPrefix: 'discovery',
|
||||
appHeaderBrandResetLabel: 'discovery-by-llm: start a new chat',
|
||||
appHeaderBrandAccent: '-by-',
|
||||
appHeaderBrandSuffix: 'llm',
|
||||
appHeaderKicker: 'proof of concept',
|
||||
|
|
@ -90,10 +91,13 @@ export const messages = {
|
|||
resultActionsConnectSpotify: 'Connect Spotify to save',
|
||||
resultActionsSummary: '{count} verified tracks ready for a private playlist',
|
||||
|
||||
resultSetRecommendedTracksLabel: 'Recommended tracks',
|
||||
resultSetRecommendedTrackLabel: '{count} recommended track',
|
||||
resultSetRecommendedTracksLabel: '{count} recommended tracks',
|
||||
|
||||
streamErrorRetry: 'Edit and retry',
|
||||
|
||||
suggestionChipsRegionLabel: 'Listening suggestions',
|
||||
|
||||
trackCardArtworkAlt: '{album} album artwork',
|
||||
trackCardOpenSpotify: 'Open in Spotify',
|
||||
|
||||
|
|
@ -110,7 +114,6 @@ export const messages = {
|
|||
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',
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ function parseLine(line: string): StreamEvent {
|
|||
function advancePhase(phase: StreamPhase, event: StreamEvent): StreamPhase {
|
||||
if (phase === 'metadata') {
|
||||
if (event.type === 'metadata') return 'events'
|
||||
if (event.type === 'error') return 'terminal'
|
||||
throw new StreamTransportError('protocol', messages.recommendationStreamMissingMetadata)
|
||||
}
|
||||
if (phase === 'events') {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
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 App from './App.vue'
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,11 @@
|
|||
|
||||
--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.4s;
|
||||
--dur-card-stagger: 0.08s;
|
||||
--dur-reduced-motion: 0.001s;
|
||||
|
||||
color-scheme: dark;
|
||||
color: var(--c-text);
|
||||
background: var(--c-bg);
|
||||
|
|
@ -151,7 +156,9 @@ a:hover {
|
|||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-delay: 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue