Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "busk-map",
"version": "1.1.0",
"version": "1.1.1",
"private": true,
"scripts": {
"dev": "node scripts/copy-maplibre-worker.mjs && next dev",
Expand Down
4 changes: 2 additions & 2 deletions src/app/api/playlist/research/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { ensurePlaylist, guardMutation, isResponse, readSession } from "@/server
import { LEARN_MAX_PENDING, LEARN_WINDOW_MINUTES } from "@/server/playlist/config";
import { executeResearchRun } from "@/server/research/run";
import { learnRunGate, refundLearnRun } from "@/server/rate-limit";
import { adapterContextFor, clientIp, FAULTS_HEADER, learnLimitFaultRequested, qlooBudgetFaultRequested, researchStepDelayMs } from "@/server/request";
import { adapterContextFor, clientIp, FAULTS_HEADER, learnLimitFaultRequested, qlooBudgetFaultRequested, researchCutRequested, researchStepDelayMs } from "@/server/request";

/** A run takes well under a minute; the summary call times out at 30 s, then the data's words are used. */
export const maxDuration = 120;
Expand Down Expand Up @@ -86,7 +86,7 @@ export async function POST(request: Request) {
registerLearnRun(run);
const ip = clientIp(request.headers);
const exempt = limitsExempt();
void executeResearchRun({ learn, playlists: session.store, playlist, spotifyId, mode: body.mode, adapters: adapterContextFor(faults), record: started.run, run, stepDelayMs: researchStepDelayMs(faults), budgetFault: qlooBudgetFaultRequested(faults), ...(body.mode === "full" && body.qlooChoice ? { qlooChoice: body.qlooChoice } : {}), ...(body.mode === "full" && body.genre ? { genre: body.genre } : {}) }).then(() => {
void executeResearchRun({ learn, playlists: session.store, playlist, spotifyId, mode: body.mode, adapters: adapterContextFor(faults), record: started.run, run, stepDelayMs: researchStepDelayMs(faults), ...(researchCutRequested(faults) ? { cutAt: "songs" as const } : {}), budgetFault: qlooBudgetFaultRequested(faults), ...(body.mode === "full" && body.qlooChoice ? { qlooChoice: body.qlooChoice } : {}), ...(body.mode === "full" && body.genre ? { genre: body.genre } : {}) }).then(() => {
// A run that ended with Qloo or Spotify down didn't count: its network hit is given back.
const end = run.events.at(-1);
if (!exempt && end?.type === "end") refundLearnRun(end.code, ip);
Expand Down
10 changes: 8 additions & 2 deletions src/app/busks/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export default function BusksPage({ searchParams }: PageProps<"/busks">) {
);
}

/**
* The form's key: a Repertoire saved since the form was drawn (research adding sound-alikes, say) starts it afresh. Cache
* Components keeps this page mounted while hidden (React Activity), and new props alone never reset the form's state.
*/
const formKey = (base: string, repertoire: { revision: number } | null) => `${base}:${repertoire?.revision ?? 0}`;

/**
* `?again=<plan id>` (from a plan whose slot has passed): the form pre-filled with that act, on the next same weekday (an
* any-time plan opens on Any time, on the next date a window can still start). Without it, the form defaults to Any time
Expand Down Expand Up @@ -64,7 +70,7 @@ async function OwnAct({ searchParams }: Pick<PageProps<"/busks">, "searchParams"
))}
</ul>
</section>
<PerformerForm defaultDate={nextAnyTimeDate(today, hour)} today={today} repertoire={repertoire} />
<PerformerForm key={formKey("act", repertoire)} defaultDate={nextAnyTimeDate(today, hour)} today={today} repertoire={repertoire} />
</>
);
}
Expand All @@ -80,7 +86,7 @@ async function OwnAct({ searchParams }: Pick<PageProps<"/busks">, "searchParams"
Planning again for {performer.name}. Your act, artists, songs and permit come from your earlier busk: pick a new date and time.
</Banner>
<PerformerForm
key={stored.id}
key={formKey(stored.id, repertoire)}
defaultDate={when.defaultDate}
today={today}
prefill={when.prefill}
Expand Down
31 changes: 25 additions & 6 deletions src/components/landing/performer-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { planSearchParams } from "@/lib/plan-url";
import { PLAYLIST_COPY } from "@/lib/playlist-copy";
import { type PlaylistView, type SpotifyProfile } from "@/lib/playlist-schema";
import { choiceForHours, hoursOf, type TimeChoice } from "@/lib/time-slots";
import { ArtistPicker, typedArtists, type PickedArtist } from "./artist-picker";
import { ArtistPicker, MAX_PICKED_ARTISTS, typedArtists, type PickedArtist } from "./artist-picker";

// "|" and "~" separate values in the plan URL.
const clean = (s: string) => s.replace(/[|~]/g, " ").trim();
Expand Down Expand Up @@ -67,15 +67,32 @@ const prefillSongs = (prefill: FormPrefill | undefined): PickedSong[] =>
own: song.own,
}));

/** The Repertoire as the form's fields (docs/playlist-architecture.md §3.1): name, sounds-like artists, songs. */
/** The Repertoire as the form's fields (docs/playlist-architecture.md §3.1): name and songs (its sound-alikes: startingArtists). */
const repertoireFields = (view: PlaylistView) => ({
name: view.actName ?? "",
artists: view.soundsLike.map((a): PickedArtist => ({ name: a.name, spotifyId: a.spotifyId ?? null, qlooId: a.qlooId ?? null, image: a.image ?? null })),
picked: view.songs.map(
(song): PickedSong => ({ key: `rep-${song.id}`, title: song.title, artist: song.artist, durationMs: song.durationMs, own: song.own }),
),
});

/**
* The artists the form starts with: planning again, the plan's artists, then the Repertoire's sound-alikes it lacks (by name);
* otherwise the Repertoire's. Never fewer than the Repertoire has (up to the picker's maximum), so an act Qloo doesn't know
* still starts with the sound-alikes its research saved.
*/
export function startingArtists(prefill: FormPrefill | undefined, repertoire: Pick<PlaylistView, "soundsLike"> | null): PickedArtist[] {
const fromRep = repertoire ? repertoire.soundsLike.map((a): PickedArtist => ({ name: a.name, spotifyId: a.spotifyId ?? null, qlooId: a.qlooId ?? null, image: a.image ?? null })) : [];
const out = prefillArtists(prefill);
for (const a of fromRep) {
if (out.length >= MAX_PICKED_ARTISTS) break;
const at = out.findIndex((b) => b.name.toLowerCase() === a.name.toLowerCase());
// The plan's copy keeps its place; the Repertoire fills in ids and the photo it lacks.
if (at >= 0) out[at] = { ...out[at], spotifyId: out[at].spotifyId ?? a.spotifyId, qlooId: out[at].qlooId ?? a.qlooId ?? null, image: out[at].image ?? a.image };
else out.push(a);
}
return out;
}

/** The Repertoire's saved Spotify profile as ArtistProfile draws it (read-only here: no audience tags, no refresh). */
const profileOf = (profile: SpotifyProfile) => ({ name: profile.name, image: profilePhoto(profile.images), genres: profile.genres, audience: [], followers: profile.followers ?? null });

Expand Down Expand Up @@ -124,7 +141,8 @@ export function PerformerForm({ defaultDate, today, prefill, repertoire = null }
const rep = repertoire;
const repAct = rep && !prefill ? repertoireFields(rep) : null;
const [name, setName] = useState(repAct?.name ?? prefill?.performer.name ?? "");
const [artists, setArtists] = useState<PickedArtist[]>(() => repAct?.artists ?? prefillArtists(prefill));
// The artists start from the plan (planning again) and the Repertoire's sound-alikes together, never one wiping the other.
const [artists, setArtists] = useState<PickedArtist[]>(() => startingArtists(prefill, rep));
const [artistQuery, setArtistQuery] = useState("");
// The songs the busk carries: the Repertoire's (or, planning again, the last busk's).
const songs = useMemo<PickedSong[]>(() => (prefill ? prefillSongs(prefill) : rep ? repertoireFields(rep).picked : []), [prefill, rep]);
Expand Down Expand Up @@ -163,7 +181,8 @@ export function PerformerForm({ defaultDate, today, prefill, repertoire = null }
/** What the form holds now, as the request will carry it. */
function currentFields() {
const parsedSongs = songs.map((p) => ({ title: p.title, artist: p.artist, own: p.own, ...(p.durationMs ? { durationMs: p.durationMs } : {}) }));
const chosen = [...artists];
// An empty picker never fails the busk while the Repertoire has sound-alikes: they are the act's answer to "who you sound like".
const chosen = artists.length > 0 ? [...artists] : startingArtists(undefined, rep);
for (const typed of typedArtists(artistQuery)) {
if (!chosen.some((a) => a.name.toLowerCase() === typed.toLowerCase())) chosen.push({ name: typed, spotifyId: null, image: null });
}
Expand Down Expand Up @@ -274,7 +293,7 @@ export function PerformerForm({ defaultDate, today, prefill, repertoire = null }
</div>
<SoundsLikeSuggestionsList
enabled={Boolean(rep?.spotifyArtistId)}
refreshKey={`${rep?.qlooArtistId ?? ""}|${rep?.spotifyProfile?.fetchedAt ?? ""}`}
refreshKey={`${rep?.qlooArtistId ?? ""}|${rep?.spotifyProfile?.fetchedAt ?? ""}|${rep?.qlooArtistId ? "" : (rep?.soundsLike ?? []).map((a) => a.qlooId ?? "").join(",")}`}
picked={currentFields().chosen.map((a) => a.name)}
onAdd={(add) => setArtists((current) => withSuggested(current, add))}
/>
Expand Down
22 changes: 16 additions & 6 deletions src/components/plan/agent-steps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { type CSSProperties, type ReactNode } from "react";
import { cn } from "cn";
import { HexCell } from "@/components/honeycomb/hex-cell";
import { Icon } from "@/components/honeycomb/icon";
import { RichText } from "@/components/honeycomb/mention";
import { Spinner } from "@/components/ui/spinner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
Expand Down Expand Up @@ -58,7 +59,9 @@ function ToolCard({ step, model, when, pins, fresh, renderWaiting }: { step: Age
const say = step.notes.filter((n) => n.kind === "text").map((n) => n.text);
const title = running ? `${ui?.doing(step) ?? step.label}…` : (ui?.title(step) ?? step.label);
const out = step.output !== null && typeof step.output === "object" ? (step.output as Record<string, unknown>) : {};
const foot = (text: string) => <p className="m-0 text-[13px] leading-[18px] text-muted-foreground">{text}</p>;
const foot = (text: ReactNode) => <p className="m-0 text-[13px] leading-[18px] text-muted-foreground">{text}</p>;
// One-line results sit right under the title, beside the step cell (no gap under a one-line title); only wide content goes below.
let lead: ReactNode = null;
let body: ReactNode = null;
if (running)
body = (
Expand All @@ -68,11 +71,17 @@ function ToolCard({ step, model, when, pins, fresh, renderWaiting }: { step: Age
</div>
);
else if (rejected) body = ui?.render(out, step, { when, pins, model });
else if (bad) body = foot("This check didn't come back, so the plan goes on without it.");
else if (cut) body = foot("Stopped before it finished.");
else if (waiting) body = renderWaiting ? renderWaiting(step) : step.summary ? foot(step.summary) : null;
else if (step.output !== undefined && ui) body = ui.render(out, step, { when, pins, model });
else if (step.summary) body = foot(step.summary);
else if (bad) lead = foot("This check didn't come back, so the plan goes on without it.");
else if (cut) lead = foot("Stopped before it finished.");
else if (waiting) {
if (renderWaiting) body = renderWaiting(step);
else if (step.summary) lead = foot(step.summary);
} else if (step.output !== undefined && ui) {
const line = ui.line?.(out);
if (line !== undefined) {
if (line.length) lead = foot(<RichText text={line} />);
} else body = ui.render(out, step, { when, pins, model });
} else if (step.summary) lead = foot(step.summary);
return (
<li
data-testid="agent-step"
Expand Down Expand Up @@ -103,6 +112,7 @@ function ToolCard({ step, model, when, pins, fresh, renderWaiting }: { step: Age
{say.join(" ")}
</Say>
) : null}
{lead}
</div>
<span className="sr-only">{rejected ? "rejected" : waiting ? "waiting for you" : step.status}</span>
</div>
Expand Down
14 changes: 11 additions & 3 deletions src/components/plan/tool-results.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,13 @@ const Section = ({ title, children }: { title: string; children: ReactNode }) =>
/** `pins`: the busk's ranked pitches, once the plan exists, so a card's mini map can show them. */
export type MiniPin = { id: string; name: string; lat: number; lon: number; rank: number };
type Ctx = { when?: string; pins?: MiniPin[]; model?: ExplanationsModel | null };
type ToolUi = { doing: (s: AgentStep) => string; title: (s: AgentStep) => string; render: (out: Rec, s: AgentStep, ctx: Ctx) => ReactNode };
type ToolUi = {
doing: (s: AgentStep) => string;
title: (s: AgentStep) => string;
render: (out: Rec, s: AgentStep, ctx: Ctx) => ReactNode;
/** A tool whose whole result is one line: the feed sets it right under the title, beside the step cell. */
line?: (out: Rec) => RichTextData | string;
};

/** "Gillian Welch 0.31; Paul Kelly 0.2" → the because chips, each with its explained weight (0–100). */
const becauseArtists = (s: string) =>
Expand All @@ -193,6 +199,7 @@ const becauseArtists = (s: string) =>
const researchUi = (line: (o: Rec) => RichTextData | string): ToolUi => ({
doing: (s) => s.label,
title: (s) => s.label,
line,
render: (o) => (
<Foot>
<RichText text={line(o)} />
Expand All @@ -210,10 +217,11 @@ const RESEARCH_UI: Record<string, ToolUi> = {
research_songs: researchUi((o) => {
const songs = arr(o.songs).filter((x) => str(x.title));
if (!songs.length) return str(o.summary) ?? "";
const from = str(o.source) === "spotify" || str(o.source) === "lastfm" ? [" from ", mentionSource(str(o.source) as "spotify" | "lastfm")] : [];
// The source's logo is an image: its name follows in words, so the line never reads "from ,".
const from = str(o.source) === "spotify" || str(o.source) === "lastfm" ? [" from ", mentionSource(str(o.source) as "spotify" | "lastfm"), str(o.source) === "spotify" ? " Spotify" : " Last.fm"] : [];
// Spotify gave no catalogue (rate-limited or down): say so plainly, then what was added from Last.fm.
if (o.spotifyDown === "limited" || o.spotifyDown === "down") {
return [o.spotifyDown === "limited" ? "Spotify isn't answering right now, so " : "Spotify didn't answer, so ", `${num(o.added) ?? songs.length} of your songs come from `, mentionSource("lastfm"), ", including ", ...listOf(songs.slice(0, 3).map((x) => mentionSong(str(x.title) as string, { artist: str(x.artist), image: str(x.image), href: str(x.href) })))];
return [o.spotifyDown === "limited" ? "Spotify isn't answering right now, so " : "Spotify didn't answer, so ", `${num(o.added) ?? songs.length} of your songs come from `, mentionSource("lastfm"), " Last.fm, including ", ...listOf(songs.slice(0, 3).map((x) => mentionSong(str(x.title) as string, { artist: str(x.artist), image: str(x.image), href: str(x.href) })))];
}
return [
`Added ${num(o.added) ?? songs.length} of your songs`,
Expand Down
Loading
Loading