feat(vibe): reconcile mutable session previews in playback

This commit is contained in:
kami
2026-08-01 23:47:02 +04:00
parent 51ef7c84db
commit 57df1cfe9f
20 changed files with 1311 additions and 312 deletions
+75 -62
View File
@@ -1,88 +1,101 @@
import api from './api';
import axios from 'axios';
import type { Track } from '../types';
// A candidate from the v2 recommendation plan. The plan is stored server-side
// in Redis; the frontend only needs trackId + explanation for display.
// These types intentionally mirror the durable session API. Tracks are not
// embedded in a plan revision: the client resolves ids through the normal
// library endpoint so a deleted/hidden track can never become playable merely
// because an older plan mentioned it.
export interface VibePlanItem {
trackId: string;
generatorId: string;
explanation: unknown[];
relevance: number;
plan_version_id: string;
ordinal: number;
track_id: string;
slot_role: string | null;
candidate_source: string;
score: number;
score_breakdown: Record<string, unknown>;
explanation: unknown;
committed: boolean;
}
export interface VibeStartResponse {
export interface DurableVibeSessionResponse {
sessionId: string;
plan: VibePlanItem[];
planVersion: number | null;
now: VibePlanItem | null;
preview: VibePlanItem[];
state: Record<string, unknown>;
replanned: boolean;
replanReason: string | null;
}
export interface VibeNextResponse {
track: Track;
explanation: unknown[] | null;
planRemaining: number;
export type VibeEventType =
| 'playback_started'
| 'progress'
| 'completed'
| 'skipped'
| 'disliked'
| 'kept';
export interface VibeEventInput {
eventId: string;
type: VibeEventType;
trackId?: string;
occurredAt: string;
positionMs?: number;
durationMs?: number;
payload?: Record<string, unknown>;
}
export type VibeBatchStatus = 'complete' | 'exhausted' | 'failed';
export interface VibeBatchResult {
tracks: Track[];
status: VibeBatchStatus;
export interface VibeEventResponse extends DurableVibeSessionResponse {
event: { id: string; client_event_id: string | null; type: string };
idempotent: boolean;
}
export type VibeFeedbackAction = 'completed' | 'skipped' | 'promoted' | 'disliked';
/** A durable, idempotent advancement past a plan item the player cannot load. */
export interface VibeUnplayableItemInput {
eventId: string;
planVersionId: string;
ordinal: number;
trackId: string;
}
// V2 recommendation engine. The backend stores the plan in Redis (2h TTL) and
// serves tracks one at a time via GET /next. Feedback triggers replanning.
export const vibeService = {
// POST /api/v2/vibe/start { seedTrackId? } -> { sessionId, plan }
async start(seedTrackId?: string): Promise<VibeStartResponse> {
const res = await api.post<VibeStartResponse>('/v2/vibe/start', { seedTrackId });
async start(seedTrackId?: string): Promise<DurableVibeSessionResponse> {
const res = await api.post<DurableVibeSessionResponse>('/v2/vibe/sessions', { seedTrackId });
return res.data;
},
// GET /api/v2/vibe/next -> { track, explanation, planRemaining }
// Returns one track at a time, shifting the server-side plan.
// 404 if no active plan — caller should handle gracefully.
async next(sessionId: string): Promise<VibeNextResponse> {
const res = await api.get<VibeNextResponse>('/v2/vibe/next', { params: { sessionId } });
async getPlan(sessionId: string, version?: number): Promise<DurableVibeSessionResponse> {
const res = await api.get<DurableVibeSessionResponse>(`/v2/vibe/sessions/${sessionId}/plans`, {
params: version === undefined ? undefined : { version },
});
return res.data;
},
// POST /api/v2/vibe/feedback { trackId, action } -> { status, planRemaining }
// Action 'promoted' also calls addFavorite; 'disliked' also calls dislikeTrack.
// Triggers replan of the remaining plan.
async feedback(trackId: string, action: VibeFeedbackAction, sessionId?: string): Promise<{ status: string; planRemaining: number }> {
const res = await api.post<{ status: string; planRemaining: number }>('/v2/vibe/feedback', { trackId, action, sessionId });
async next(sessionId: string, expectedPlanVersion: number): Promise<DurableVibeSessionResponse> {
const res = await api.post<DurableVibeSessionResponse>(`/v2/vibe/sessions/${sessionId}/next`, {
expectedPlanVersion,
});
return res.data;
},
// GET /api/v2/vibe/plan -> { sessionId, planRemaining, plan }
// Debug endpoint — returns the full remaining plan.
async getPlan(): Promise<{ sessionId: string; planRemaining: number; plan: VibePlanItem[] }> {
const res = await api.get('/v2/vibe/plan');
async advancePastUnplayable(
sessionId: string,
expectedPlanVersion: number,
unplayable: VibeUnplayableItemInput,
): Promise<DurableVibeSessionResponse> {
const res = await api.post<DurableVibeSessionResponse>(`/v2/vibe/sessions/${sessionId}/next`, {
expectedPlanVersion,
unplayable,
});
return res.data;
},
async event(sessionId: string, event: VibeEventInput): Promise<VibeEventResponse> {
const res = await api.post<VibeEventResponse>(`/v2/vibe/sessions/${sessionId}/events`, event);
return res.data;
},
async end(sessionId: string): Promise<DurableVibeSessionResponse> {
const res = await api.post<DurableVibeSessionResponse>(`/v2/vibe/sessions/${sessionId}/end`);
return res.data;
},
};
// Fetch N tracks from the v2 plan sequentially. Each call to /next shifts the
// server-side plan, so calls must be sequential (not parallel). Stops early on
// 409/VIBE_PLAN_EXHAUSTED is a normal terminal condition. A missing/replaced
// session is intentionally reported as a failure so callers can preserve the
// current playback state rather than pretending the plan completed cleanly.
export async function fetchNextBatch(count: number, sessionId: string): Promise<VibeBatchResult> {
const tracks: Track[] = [];
for (let i = 0; i < count; i++) {
try {
const { track } = await vibeService.next(sessionId);
tracks.push(track);
} catch (error) {
return {
tracks,
status: axios.isAxiosError(error) && error.response?.status === 409 &&
(error.response.data as { code?: string } | undefined)?.code === 'VIBE_PLAN_EXHAUSTED'
? 'exhausted' : 'failed',
};
}
}
return { tracks, status: 'complete' };
}