feat(frontend): add session and playlist API client
This commit is contained in:
parent
47cbeac87a
commit
96c69507ec
7 changed files with 837 additions and 279 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# Logboek
|
||||
|
||||
Bijgehouden tijdens de bouw. Per blok: wat ik deed, waarom, wat ik heb laten
|
||||
Bijgehouden tijdens de bouw. Per stap: wat ik deed, waarom, wat ik heb laten
|
||||
vallen.
|
||||
## Dag 1 - korte sessie in de avond
|
||||
### Opzet
|
||||
|
|
@ -120,7 +120,7 @@ Wat ik deed:
|
|||
client-disconnect stopt de stream en de grounding.
|
||||
- Playlist-endpoint dat schrijft met een vaste naam-prefix, zodat
|
||||
aangemaakte playlists later in bulk op te ruimen zijn (ik gebruik mijn
|
||||
persoonlijke spotify account/abbonement voor de demo)..
|
||||
persoonlijke spotify account/abonnement voor de demo).
|
||||
- Request-timing middleware met per-request counters (Spotify calls, cache
|
||||
hits, LLM tokens) in gestructureerde logs.
|
||||
- Seed session voor gehost draaien: in live mode installeert een refresh
|
||||
|
|
@ -179,3 +179,25 @@ Waarom:
|
|||
foutmelding die echte resultaten verbergt is dat niet. Bij "verras me
|
||||
met iets nieuws" vragen valt een groot deel van de kandidaten af bij de
|
||||
verificatie, dus juist daar telt dit. Een weg om dit potentieel te voorkomen/verbeteren in de toekomst is het verbeteren van de prompt, of de LLM met behulp van een derde partij API die zonder de Spotify API te overbelasten gebruikt kan worden om echte nummers te vinden.
|
||||
|
||||
### Frontend: fundament
|
||||
|
||||
Voor de frontend heb ik gedurende de eerste paar uur op de achtergrond Open Design (een
|
||||
design-tool) laten lopen. Ik ben geen visual designer, maar op deze manier lukt het mij om met minimale
|
||||
effort een geschikt UI bouwpakket te ontwikkelen. De oplevering heeft zo'n vorm dat ik het
|
||||
gemakkelijk door een cli agent kan laten uitbouwen in Vue.
|
||||
|
||||
Wat ik deed:
|
||||
|
||||
- Design tokens uit het ontwerppakket (donker thema, typografie, spacing,
|
||||
motion) als basis voor alle componenten.
|
||||
- Typed stream client: fetch met een ReadableStream, regelgebaseerde
|
||||
NDJSON-parsing naar de contract-events, en AbortController-cancellation
|
||||
zodra een nieuwe vraag start.
|
||||
- API client voor sessie-status, logout en playlist-save.
|
||||
|
||||
Waarom:
|
||||
|
||||
- De frontend is de tweede consument van het bevroren contract: elke event
|
||||
wordt tegen de TypeScript-union gevalideerd in plaats van los geparst;
|
||||
wat niet valideert is een transport failure, geen gok.
|
||||
|
|
|
|||
127
frontend/src/composables/useApi.ts
Normal file
127
frontend/src/composables/useApi.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { ref } from 'vue'
|
||||
import type { AuthState, HealthState } from '../lib/models'
|
||||
import type { CurrentUser, PlaylistCreateRequest, PlaylistCreateResponse } from '../lib/types'
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function parseCurrentUser(value: unknown): CurrentUser {
|
||||
if (!isRecord(value) || typeof value.display_name !== 'string') {
|
||||
throw new Error('Invalid current user response.')
|
||||
}
|
||||
return { display_name: value.display_name }
|
||||
}
|
||||
|
||||
function parsePlaylistResponse(value: unknown): PlaylistCreateResponse {
|
||||
if (!isRecord(value) || typeof value.url !== 'string') {
|
||||
throw new Error('Invalid playlist response.')
|
||||
}
|
||||
return { url: value.url }
|
||||
}
|
||||
|
||||
function loginFailed(): boolean {
|
||||
const url = new URL(window.location.href)
|
||||
const failed = url.searchParams.get('login') === 'error'
|
||||
if (failed) {
|
||||
url.searchParams.delete('login')
|
||||
window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`)
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
/** Manage authentication, service mode, and playlist API operations. */
|
||||
export function useApi() {
|
||||
const auth = ref<AuthState>({ status: 'checking', user: null, message: null })
|
||||
const health = ref<HealthState>({ status: 'checking', mode: null, message: null })
|
||||
|
||||
async function loadAuth(hadLoginError: boolean): Promise<void> {
|
||||
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 }
|
||||
return
|
||||
}
|
||||
if (!response.ok) throw new Error('Authentication check failed.')
|
||||
auth.value = {
|
||||
status: 'authenticated',
|
||||
user: parseCurrentUser(await response.json()),
|
||||
message: null,
|
||||
}
|
||||
} catch {
|
||||
auth.value = {
|
||||
status: 'failed',
|
||||
user: null,
|
||||
message: hadLoginError
|
||||
? 'Spotify login did not complete. Please try again.'
|
||||
: 'Spotify connection status is unavailable.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHealth(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch('/api/health', { credentials: 'same-origin' })
|
||||
if (!response.ok) throw new Error('Health check failed.')
|
||||
const body: unknown = await response.json()
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
body.status !== 'ok' ||
|
||||
(body.mode !== 'demo' && body.mode !== 'live')
|
||||
) {
|
||||
throw new Error('Invalid health response.')
|
||||
}
|
||||
health.value = { status: 'ready', mode: body.mode, message: null }
|
||||
} catch {
|
||||
health.value = {
|
||||
status: 'failed',
|
||||
mode: null,
|
||||
message: 'Service mode is unavailable.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const hadLoginError = loginFailed()
|
||||
await Promise.all([loadAuth(hadLoginError), loadHealth()])
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
if (auth.value.status !== 'authenticated') return
|
||||
const user = auth.value.user
|
||||
auth.value = { status: 'logging_out', user, message: null }
|
||||
try {
|
||||
const response = await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
if (!response.ok) throw new Error('Logout failed.')
|
||||
auth.value = { status: 'anonymous', user: null, message: null }
|
||||
} catch {
|
||||
auth.value = {
|
||||
status: 'failed',
|
||||
user: null,
|
||||
message: 'Spotify logout failed. Refresh before trying again.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createPlaylist(request: PlaylistCreateRequest): Promise<PlaylistCreateResponse> {
|
||||
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}.`)
|
||||
return parsePlaylistResponse(await response.json())
|
||||
}
|
||||
|
||||
return { auth, health, bootstrap, logout, createPlaylist }
|
||||
}
|
||||
265
frontend/src/composables/useChatStream.ts
Normal file
265
frontend/src/composables/useChatStream.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import type { AssistantTurn, ChatTurn, EventLogEntry, TransportFailure } from '../lib/models'
|
||||
import { EMPTY_PLAYLIST_STATE } from '../lib/models'
|
||||
import {
|
||||
isAbortError,
|
||||
streamRecommendations,
|
||||
StreamTransportError,
|
||||
} from '../lib/recommendationStream'
|
||||
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<PlaylistCreateResponse>
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function buildHistory(turns: ChatTurn[]): HistoryTurn[] {
|
||||
return turns
|
||||
.map((turn): HistoryTurn | null => {
|
||||
if (turn.role === 'user') return { role: 'user', content: turn.text }
|
||||
const content = assistantContent(turn)
|
||||
return content ? { role: 'assistant', content } : null
|
||||
})
|
||||
.filter((turn): turn is HistoryTurn => turn !== null)
|
||||
.slice(-HISTORY_LIMIT)
|
||||
}
|
||||
|
||||
function buildPriorRecommendations(turns: ChatTurn[]): PriorRecommendation[] {
|
||||
const latest = [...turns]
|
||||
.reverse()
|
||||
.find((turn): turn is AssistantTurn => turn.role === 'assistant' && turn.status === 'done')
|
||||
if (!latest) return []
|
||||
return latest.tracks.slice(0, PRIOR_RECOMMENDATION_LIMIT).map((event) => ({
|
||||
rank: event.rank,
|
||||
track_id: event.track.id,
|
||||
title: event.track.title,
|
||||
artists: event.track.artists,
|
||||
}))
|
||||
}
|
||||
|
||||
function reduceEvent(turn: AssistantTurn, event: StreamEvent): AssistantTurn {
|
||||
switch (event.type) {
|
||||
case 'metadata':
|
||||
return {
|
||||
...turn,
|
||||
requestId: event.request_id,
|
||||
intentSummary: event.intent_summary,
|
||||
candidateCount: event.candidate_count,
|
||||
}
|
||||
case 'track':
|
||||
return { ...turn, tracks: [...turn.tracks, event] }
|
||||
case 'warning':
|
||||
return { ...turn, warnings: [...turn.warnings, event] }
|
||||
case 'error':
|
||||
return { ...turn, status: 'error', error: event }
|
||||
case 'done':
|
||||
if (event.track_count !== turn.tracks.length) {
|
||||
return {
|
||||
...turn,
|
||||
status: 'error',
|
||||
completion: event,
|
||||
transportFailure: {
|
||||
kind: 'protocol',
|
||||
message: 'The final track count did not match the streamed results.',
|
||||
},
|
||||
}
|
||||
}
|
||||
return { ...turn, status: 'done', completion: event }
|
||||
}
|
||||
}
|
||||
|
||||
function playlistName(query: string): string {
|
||||
return `[discovery-by-llm] ${query}`.slice(0, 100).trim()
|
||||
}
|
||||
|
||||
function createTurnId(): string {
|
||||
return typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
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'),
|
||||
)
|
||||
|
||||
function updateAssistant(id: string, update: (turn: AssistantTurn) => AssistantTurn): void {
|
||||
turns.value = turns.value.map((turn) =>
|
||||
turn.role === 'assistant' && turn.id === id ? update(turn) : turn,
|
||||
)
|
||||
}
|
||||
|
||||
function addLog(type: string, detail: string): void {
|
||||
eventLog.value = [
|
||||
...eventLog.value,
|
||||
{ timestamp: new Date().toLocaleTimeString(), type, detail },
|
||||
].slice(-EVENT_LOG_LIMIT)
|
||||
}
|
||||
|
||||
function cancelActive(shouldMarkTurn: boolean): void {
|
||||
if (!activeController) return
|
||||
activeController.abort()
|
||||
if (shouldMarkTurn && activeTurnId) {
|
||||
const failure: TransportFailure = {
|
||||
kind: 'cancelled',
|
||||
message: 'This request was replaced by a newer request.',
|
||||
}
|
||||
updateAssistant(activeTurnId, (turn) => ({
|
||||
...turn,
|
||||
status: 'error',
|
||||
transportFailure: failure,
|
||||
}))
|
||||
}
|
||||
activeController = null
|
||||
activeTurnId = null
|
||||
}
|
||||
|
||||
async function send(query: string): Promise<void> {
|
||||
const text = query.trim()
|
||||
if (!text) return
|
||||
cancelActive(true)
|
||||
|
||||
const request: RecommendationRequest = {
|
||||
schema_version: 1,
|
||||
query: text,
|
||||
history: buildHistory(turns.value),
|
||||
prior_recommendations: buildPriorRecommendations(turns.value),
|
||||
}
|
||||
const userTurn: ChatTurn = { id: createTurnId(), role: 'user', text }
|
||||
const assistantTurn: AssistantTurn = {
|
||||
id: createTurnId(),
|
||||
role: 'assistant',
|
||||
query: text,
|
||||
status: 'streaming',
|
||||
requestId: null,
|
||||
intentSummary: '',
|
||||
candidateCount: null,
|
||||
tracks: [],
|
||||
warnings: [],
|
||||
error: null,
|
||||
transportFailure: null,
|
||||
completion: null,
|
||||
playlist: { ...EMPTY_PLAYLIST_STATE },
|
||||
}
|
||||
turns.value = [...turns.value, userTurn, assistantTurn]
|
||||
|
||||
const controller = new AbortController()
|
||||
activeController = controller
|
||||
activeTurnId = assistantTurn.id
|
||||
|
||||
try {
|
||||
await streamRecommendations(request, controller.signal, (event) => {
|
||||
updateAssistant(assistantTurn.id, (turn) => reduceEvent(turn, event))
|
||||
addLog(event.type, JSON.stringify(event))
|
||||
})
|
||||
} catch (error) {
|
||||
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.' }
|
||||
updateAssistant(assistantTurn.id, (turn) => ({
|
||||
...turn,
|
||||
status: 'error',
|
||||
transportFailure: failure,
|
||||
}))
|
||||
addLog('transport', `${failure.kind}: ${failure.message}`)
|
||||
} finally {
|
||||
if (activeController === controller) {
|
||||
activeController = null
|
||||
activeTurnId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function savePlaylist(turnId: string): Promise<void> {
|
||||
const turn = turns.value.find(
|
||||
(candidate): candidate is AssistantTurn =>
|
||||
candidate.role === 'assistant' && candidate.id === turnId,
|
||||
)
|
||||
if (
|
||||
!turn ||
|
||||
turn.status !== 'done' ||
|
||||
turn.tracks.length === 0 ||
|
||||
turn.playlist.status === 'saving' ||
|
||||
turn.playlist.status === 'saved'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const name = playlistName(turn.query)
|
||||
updateAssistant(turnId, (current) => ({
|
||||
...current,
|
||||
playlist: { status: 'saving', name, url: null, message: null },
|
||||
}))
|
||||
|
||||
try {
|
||||
const response = await createPlaylist({
|
||||
schema_version: 1,
|
||||
name,
|
||||
track_uris: turn.tracks.map((event) => event.track.uri),
|
||||
})
|
||||
updateAssistant(turnId, (current) => ({
|
||||
...current,
|
||||
playlist: { status: 'saved', name, url: response.url, message: null },
|
||||
}))
|
||||
} catch {
|
||||
updateAssistant(turnId, (current) => ({
|
||||
...current,
|
||||
playlist: {
|
||||
status: 'error',
|
||||
name,
|
||||
url: null,
|
||||
message: 'Spotify could not create the playlist. Nothing was retried.',
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
cancelActive(false)
|
||||
turns.value = []
|
||||
eventLog.value = []
|
||||
}
|
||||
|
||||
onUnmounted(() => cancelActive(false))
|
||||
|
||||
return {
|
||||
turns,
|
||||
eventLog,
|
||||
isStreaming,
|
||||
turnCount,
|
||||
isEmpty,
|
||||
latestAssistant,
|
||||
send,
|
||||
savePlaylist,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
67
frontend/src/lib/models.ts
Normal file
67
frontend/src/lib/models.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import type { CurrentUser, DoneEvent, ErrorEvent, TrackEvent, WarningEvent } from './types'
|
||||
|
||||
export type AssistantStatus = 'streaming' | 'done' | 'error'
|
||||
export type AppMode = 'demo' | 'live'
|
||||
export type TransportFailureKind =
|
||||
'cancelled' | 'http' | 'network' | 'parse' | 'protocol' | 'unexpected_eof'
|
||||
|
||||
export interface TransportFailure {
|
||||
kind: TransportFailureKind
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface PlaylistState {
|
||||
status: 'idle' | 'saving' | 'saved' | 'error'
|
||||
name: string | null
|
||||
url: string | null
|
||||
message: string | null
|
||||
}
|
||||
|
||||
export interface UserTurn {
|
||||
id: string
|
||||
role: 'user'
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface AssistantTurn {
|
||||
id: string
|
||||
role: 'assistant'
|
||||
query: string
|
||||
status: AssistantStatus
|
||||
requestId: string | null
|
||||
intentSummary: string
|
||||
candidateCount: number | null
|
||||
tracks: TrackEvent[]
|
||||
warnings: WarningEvent[]
|
||||
error: ErrorEvent | null
|
||||
transportFailure: TransportFailure | null
|
||||
completion: DoneEvent | null
|
||||
playlist: PlaylistState
|
||||
}
|
||||
|
||||
export type ChatTurn = UserTurn | AssistantTurn
|
||||
|
||||
export type AuthState =
|
||||
| { status: 'checking'; user: null; message: null }
|
||||
| { status: 'anonymous'; user: null; message: null }
|
||||
| { status: 'authenticated'; user: CurrentUser; message: null }
|
||||
| { status: 'logging_out'; user: CurrentUser; message: null }
|
||||
| { status: 'failed'; user: null; message: string }
|
||||
|
||||
export type HealthState =
|
||||
| { status: 'checking'; mode: null; message: null }
|
||||
| { status: 'ready'; mode: AppMode; message: null }
|
||||
| { status: 'failed'; mode: null; message: string }
|
||||
|
||||
export interface EventLogEntry {
|
||||
timestamp: string
|
||||
type: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
export const EMPTY_PLAYLIST_STATE: PlaylistState = {
|
||||
status: 'idle',
|
||||
name: null,
|
||||
url: null,
|
||||
message: null,
|
||||
}
|
||||
114
frontend/src/lib/recommendationStream.ts
Normal file
114
frontend/src/lib/recommendationStream.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import type { RecommendationRequest, StreamEvent } from './types'
|
||||
import type { TransportFailureKind } from './models'
|
||||
import { parseStreamEvent, StreamParseError } from './streamParser'
|
||||
|
||||
export class StreamTransportError extends Error {
|
||||
readonly kind: TransportFailureKind
|
||||
|
||||
constructor(kind: TransportFailureKind, message: string) {
|
||||
super(message)
|
||||
this.name = 'StreamTransportError'
|
||||
this.kind = kind
|
||||
}
|
||||
}
|
||||
|
||||
function parseLine(line: string): StreamEvent {
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(line)
|
||||
} catch {
|
||||
throw new StreamTransportError('parse', 'The response contained invalid JSON.')
|
||||
}
|
||||
|
||||
try {
|
||||
return parseStreamEvent(value)
|
||||
} catch (error) {
|
||||
if (error instanceof StreamParseError) {
|
||||
throw new StreamTransportError('parse', error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Post a recommendation request and consume its NDJSON event stream. */
|
||||
export async function streamRecommendations(
|
||||
request: RecommendationRequest,
|
||||
signal: AbortSignal,
|
||||
onEvent: (event: StreamEvent) => void,
|
||||
): Promise<void> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch('/api/recommendations', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
signal,
|
||||
})
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw error
|
||||
throw new StreamTransportError('network', 'The recommendation service could not be reached.')
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new StreamTransportError(
|
||||
'http',
|
||||
`The recommendation service returned ${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.')
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new StreamTransportError('protocol', 'The response stream was empty.')
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
buffer += decoder.decode(value, { stream: !done })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim()
|
||||
if (!line) continue
|
||||
const event = parseLine(line)
|
||||
onEvent(event)
|
||||
if (event.type === 'done' || event.type === 'error') {
|
||||
await reader.cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (done) break
|
||||
}
|
||||
|
||||
const finalLine = buffer.trim()
|
||||
if (finalLine) {
|
||||
const event = parseLine(finalLine)
|
||||
onEvent(event)
|
||||
if (event.type === 'done' || event.type === 'error') 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.',
|
||||
)
|
||||
}
|
||||
|
||||
/** Return whether a rejected stream operation was intentionally aborted. */
|
||||
export function isAbortError(error: unknown, signal: AbortSignal): boolean {
|
||||
return signal.aborted || (error instanceof DOMException && error.name === 'AbortError')
|
||||
}
|
||||
99
frontend/src/lib/streamParser.ts
Normal file
99
frontend/src/lib/streamParser.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import type { StreamEvent, TrackCard } from './types'
|
||||
|
||||
export class StreamParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'StreamParseError'
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function stringField(value: Record<string, unknown>, key: string): string {
|
||||
const field = value[key]
|
||||
if (typeof field !== 'string') throw new StreamParseError(`Invalid ${key} field.`)
|
||||
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.`)
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
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.`)
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
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.`)
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
function parseTrackCard(value: unknown): TrackCard {
|
||||
if (!isRecord(value)) throw new StreamParseError('Invalid track field.')
|
||||
return {
|
||||
id: stringField(value, 'id'),
|
||||
uri: stringField(value, 'uri'),
|
||||
title: stringField(value, 'title'),
|
||||
artists: stringArrayField(value, 'artists'),
|
||||
album_name: stringField(value, 'album_name'),
|
||||
album_art_url: nullableStringField(value, 'album_art_url'),
|
||||
external_url: nullableStringField(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.')
|
||||
}
|
||||
|
||||
switch (value.type) {
|
||||
case 'metadata':
|
||||
return {
|
||||
type: 'metadata',
|
||||
request_id: stringField(value, 'request_id'),
|
||||
intent_summary: stringField(value, 'intent_summary'),
|
||||
candidate_count: numberField(value, 'candidate_count'),
|
||||
}
|
||||
case 'track':
|
||||
return {
|
||||
type: 'track',
|
||||
rank: numberField(value, 'rank'),
|
||||
track: parseTrackCard(value.track),
|
||||
justification: stringField(value, 'justification'),
|
||||
}
|
||||
case 'warning':
|
||||
return {
|
||||
type: 'warning',
|
||||
code: stringField(value, 'code'),
|
||||
message: stringField(value, 'message'),
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
type: 'error',
|
||||
code: stringField(value, 'code'),
|
||||
message: stringField(value, 'message'),
|
||||
}
|
||||
case 'done':
|
||||
return {
|
||||
type: 'done',
|
||||
track_count: numberField(value, 'track_count'),
|
||||
total_ms: numberField(value, 'total_ms'),
|
||||
}
|
||||
default:
|
||||
throw new StreamParseError(`Unknown stream event type: ${value.type}.`)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,296 +1,160 @@
|
|||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
--c-bg: #0b0c0b;
|
||||
--c-surface: #131513;
|
||||
--c-surface-raised: #161816;
|
||||
--c-surface-user: #1e211e;
|
||||
--c-surface-hover: #171a18;
|
||||
--c-line: #262a26;
|
||||
--c-line-strong: #38403a;
|
||||
--c-line-user: #2e332e;
|
||||
--c-focus-border: #444b46;
|
||||
--c-card-border: #232722;
|
||||
--c-art-a: #1e231f;
|
||||
--c-art-b: #171a17;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
--c-text: #e8ede8;
|
||||
--c-text-muted: #8b948c;
|
||||
--c-text-dim: #7d857e;
|
||||
--c-text-faint: #666e68;
|
||||
--c-text-ghost: #4e544f;
|
||||
--c-text-chip: #bec5bf;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
--c-accent: #7fc96b;
|
||||
--c-accent-hover: #9bdb88;
|
||||
--c-accent-soft: #a6be95;
|
||||
--c-accent-line: #3b4f2e;
|
||||
--c-banner-bg: #111510;
|
||||
--c-banner-line: #26331e;
|
||||
--c-on-accent: #0b0c0b;
|
||||
--c-saved: #c9a96b;
|
||||
--c-saved-bg: #191712;
|
||||
--c-saved-line: #37301f;
|
||||
--c-error: #e0795c;
|
||||
--c-dev-bg: #14120f;
|
||||
|
||||
--f-display: 'Bricolage Grotesque', system-ui, sans-serif;
|
||||
--f-body: 'Public Sans', system-ui, sans-serif;
|
||||
--f-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
|
||||
--t-hero: 44px;
|
||||
--t-brand: 21px;
|
||||
--t-lead: 16px;
|
||||
--t-body: 15px;
|
||||
--t-title: 15.5px;
|
||||
--t-small: 13.5px;
|
||||
--t-meta: 11.5px;
|
||||
--t-micro: 11px;
|
||||
--t-caps: 10px;
|
||||
|
||||
--lh-body: 1.55;
|
||||
--lh-hero: 1.08;
|
||||
--ls-hero: -0.025em;
|
||||
--ls-caps: 0.16em;
|
||||
|
||||
--s-1: 4px;
|
||||
--s-2: 6px;
|
||||
--s-3: 8px;
|
||||
--s-4: 10px;
|
||||
--s-5: 14px;
|
||||
--s-6: 16px;
|
||||
--s-7: 18px;
|
||||
--s-8: 22px;
|
||||
--s-9: 34px;
|
||||
--s-10: 44px;
|
||||
|
||||
--gutter: 24px;
|
||||
--measure: 820px;
|
||||
--dev-width: 372px;
|
||||
--tap-min: 44px;
|
||||
|
||||
--r-sm: 2px;
|
||||
--r-md: 3px;
|
||||
--r-lg: 4px;
|
||||
--r-pill: 999px;
|
||||
|
||||
--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);
|
||||
font-family: var(--f-body);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@media (max-width: 560px) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
--gutter: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--c-bg);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
a {
|
||||
color: var(--c-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
a:hover {
|
||||
color: var(--c-accent-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
::selection {
|
||||
background: var(--c-accent);
|
||||
color: var(--c-on-accent);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--c-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-duration: 0.001s !important;
|
||||
transition-duration: 0.001s !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue