diff --git a/deploy/recommender.yaml b/deploy/recommender.yaml index 9e3dc8b..c6e25f6 100644 --- a/deploy/recommender.yaml +++ b/deploy/recommender.yaml @@ -38,7 +38,7 @@ spec: - name: PROVIDER_URL value: "https://openrouter.ai/api/v1" - name: MODEL_NAME - value: "openai/gpt-5.4" + value: "z-ai/glm-5.1" - name: AI_PROVIDER value: "GENERIC" resources: diff --git a/packages/backend/src/agent.ts b/packages/backend/src/agent.ts index 8695f9e..701d7b3 100644 --- a/packages/backend/src/agent.ts +++ b/packages/backend/src/agent.ts @@ -23,13 +23,21 @@ export const miniModel = isGeneric ? (process.env.MODEL_NAME ?? 'default') : 'gp export const serviceOptions = isGeneric ? {} : { service_tier: 'flex' as const }; export const supportsWebSearch = !isGeneric; -export async function parseWithRetry(fn: () => Promise, retries = 2): Promise { +function isJsonParseError(err: unknown): boolean { + if (err instanceof SyntaxError) return true; + if (!(err instanceof Error)) return false; + + // Some providers wrap JSON parsing failures in plain Error objects. + return /not valid json|unexpected token|json/i.test(err.message); +} + +export async function parseWithRetry(fn: () => Promise, retries = 3): Promise { let lastErr: unknown; for (let attempt = 0; attempt <= retries; attempt++) { try { return await fn(); } catch (err) { - if (err instanceof SyntaxError && attempt < retries) { + if (isJsonParseError(err) && attempt < retries) { lastErr = err; continue; } diff --git a/packages/backend/src/agents/curator.ts b/packages/backend/src/agents/curator.ts index ae3a991..b65a2a8 100644 --- a/packages/backend/src/agents/curator.ts +++ b/packages/backend/src/agents/curator.ts @@ -64,7 +64,6 @@ Rules: const response = await parseWithRetry(() => openai.responses.parse({ model: defaultModel, temperature: 0.5, - max_completion_tokens: 16384, ...serviceOptions, ...(canSearch ? { tools: [{ type: 'web_search' as const }] } : {}), text: { format: zodTextFormat(CuratorSchema, "shows") }, diff --git a/packages/backend/src/agents/ranking.ts b/packages/backend/src/agents/ranking.ts index cb7cb82..1a8e395 100644 --- a/packages/backend/src/agents/ranking.ts +++ b/packages/backend/src/agents/ranking.ts @@ -47,7 +47,6 @@ export async function runRanking( const response = await parseWithRetry(() => openai.responses.parse({ model: defaultModel, temperature: 0.2, - max_completion_tokens: 16384, ...serviceOptions, text: { format: zodTextFormat(RankingSchema, "ranking") }, instructions: `You are a ${mediaLabel} ranking critic. Assign each ${mediaLabel} to exactly one of five confidence tags based on how well it matches the user's preferences. diff --git a/packages/backend/src/agents/retrieval.ts b/packages/backend/src/agents/retrieval.ts index f292104..caad2ec 100644 --- a/packages/backend/src/agents/retrieval.ts +++ b/packages/backend/src/agents/retrieval.ts @@ -25,7 +25,6 @@ export async function runRetrieval( const response = await parseWithRetry(() => openai.responses.parse({ model: defaultModel, temperature: 0.9, - max_completion_tokens: 16384, ...serviceOptions, ...(canSearch ? { tools: [{ type: 'web_search' as const }] } : {}), text: { format: zodTextFormat(RetrievalSchema, "candidates") }, diff --git a/packages/backend/src/agents/retrievalContinuous.ts b/packages/backend/src/agents/retrievalContinuous.ts new file mode 100644 index 0000000..056ac9e --- /dev/null +++ b/packages/backend/src/agents/retrievalContinuous.ts @@ -0,0 +1,116 @@ +import { openai, defaultModel, serviceOptions, supportsWebSearch, parseWithRetry } from '../agent.js'; +import type { InterpreterOutput, RetrievalCandidate, MediaType } from '../types/agents.js'; +import { z } from 'zod'; +import { zodTextFormat } from 'openai/helpers/zod'; + +const RetrievalBatchSchema = z.object({ + candidates: z.array(z.object({ + title: z.string(), + type: z.enum(['movie', 'tv']), + year: z.string(), + reason: z.string() + })) +}); + +export interface RetrievalBatchOutput { + candidates: Array<{ + title: string; + type: 'movie' | 'tv'; + year: string; + reason: string; + }>; +} + +const RetrievalCandidateSchema = z.object({ + title: z.string(), + reason: z.string() +}); + +export interface RetrievalOutput { + candidates: RetrievalCandidate[]; +} + +function buildSystemPrompt( + interpreterOutput: InterpreterOutput, + mediaType: MediaType, + useWebSearch: boolean, + hardRequirements: boolean, + seenTitles: string[] +): string { + const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show'; + const mediaLabelPlural = mediaType === 'movie' ? 'movies' : 'TV shows'; + + let prompt = `You are a ${mediaLabel} recommendation specialist. Your task is to recommend titles that match the user's taste profile. + +USER TASTE PROFILE: +- Liked ${mediaLabelPlural}: ${interpreterOutput.liked.join(', ') || '(none)'} +- Disliked ${mediaLabelPlural}: ${interpreterOutput.disliked.join(', ') || '(none)'} +- Themes: ${interpreterOutput.themes.join(', ') || '(none)'} +- Tone: ${interpreterOutput.tone.join(', ') || '(none)'} +- Requirements: ${interpreterOutput.requirements.join(', ') || '(none)'} +- Avoid: ${interpreterOutput.avoid.join(', ') || '(none)'} + +RESPONSE FORMAT: +Output exactly 10 recommendations in the following JSON format. No additional text. + +[ + { "title": "Title Name", "type": "movie"|"tv", "year": "2024", "reason": "1-sentence why it matches" }, + ... +] + +REQUIREMENTS: +- Output exactly 10 titles, no more, no less +- Each title must include: title, type (movie/tv), year, and a concise reason +- Prioritize variety across themes and decades +- Do not include titles the user already mentioned as liked/disliked +${seenTitles.length > 0 ? `- Do NOT repeat the following titles that have already been suggested: ${seenTitles.join(', ')}` : ''} +${useWebSearch ? '\n- Use web search to find recent and accurate titles, including newer releases' : ''} +${hardRequirements ? '\n- Strictly follow ALL requirements. Exclude any candidate that does not meet every stated requirement.' : ''} +- If asked for "more 10" or "more recommendations", provide 10 new recommendations following the same format`; + + return prompt; +} + +export async function runRetrievalBatch( + interpreterOutput: InterpreterOutput, + mediaType: MediaType, + useWebSearch: boolean, + hardRequirements: boolean, + seenTitles: string[], + previousResponseId: string | null +): Promise<{ candidates: RetrievalCandidate[]; responseId: string }> { + const canSearch = useWebSearch && supportsWebSearch; + const systemPrompt = buildSystemPrompt(interpreterOutput, mediaType, useWebSearch, hardRequirements, seenTitles); + + let input: string; + + if (previousResponseId === null) { + input = 'Please recommend 10 titles that match my taste profile. Output exactly 10 recommendations in the specified JSON format.'; + } else { + input = 'Give me 10 more recommendations following the same JSON format as before.'; + } + + const response = await parseWithRetry(() => openai.responses.parse({ + model: defaultModel, + ...serviceOptions, + ...(canSearch ? { tools: [{ type: 'web_search' as const }] } : {}), + ...(previousResponseId ? { previous_response_id: previousResponseId } : {}), + text: { format: zodTextFormat(RetrievalBatchSchema, "candidates") }, + instructions: systemPrompt, + input: input, + })); + + const parsed = response.output_parsed as RetrievalBatchOutput | undefined; + const responseId = response.id; + + if (!parsed || !parsed.candidates) { + return { candidates: [], responseId }; + } + + const candidates: RetrievalCandidate[] = parsed.candidates.map((c) => ({ + title: c.title, + reason: c.reason, + })); + + return { candidates, responseId }; +} \ No newline at end of file diff --git a/packages/backend/src/agents/validator.ts b/packages/backend/src/agents/validator.ts index 981a117..da042b0 100644 --- a/packages/backend/src/agents/validator.ts +++ b/packages/backend/src/agents/validator.ts @@ -30,7 +30,6 @@ async function runValidatorChunk( const response = await parseWithRetry(() => openai.responses.parse({ model: defaultModel, temperature: 0.1, - max_completion_tokens: 16384, ...serviceOptions, ...(supportsWebSearch ? { tools: [{ type: 'web_search' as const }] } : {}), text: { format: zodTextFormat(ValidatorSchema, 'validation') }, diff --git a/packages/backend/src/pipelineStreams.ts b/packages/backend/src/pipelineStreams.ts new file mode 100644 index 0000000..323b530 --- /dev/null +++ b/packages/backend/src/pipelineStreams.ts @@ -0,0 +1,93 @@ +import type { SSEEvent } from './types/agents.js'; + +type Listener = (event: SSEEvent) => void; + +interface PipelineSession { + history: SSEEvent[]; + listeners: Set; + terminal: boolean; + cleanupTimer: NodeJS.Timeout | null; +} + +const sessions = new Map(); +const CLEANUP_DELAY_MS = 10 * 60 * 1000; + +function getOrCreateSession(recId: string): PipelineSession { + const existing = sessions.get(recId); + if (existing) { + if (existing.cleanupTimer) { + clearTimeout(existing.cleanupTimer); + existing.cleanupTimer = null; + } + return existing; + } + + const session: PipelineSession = { + history: [], + listeners: new Set(), + terminal: false, + cleanupTimer: null, + }; + sessions.set(recId, session); + return session; +} + +function scheduleCleanup(recId: string, session: PipelineSession): void { + if (session.cleanupTimer) { + clearTimeout(session.cleanupTimer); + } + + session.cleanupTimer = setTimeout(() => { + const current = sessions.get(recId); + if (current === session && current.listeners.size === 0) { + sessions.delete(recId); + } + }, CLEANUP_DELAY_MS); +} + +export function resetPipelineSession(recId: string): void { + const existing = sessions.get(recId); + if (existing?.cleanupTimer) { + clearTimeout(existing.cleanupTimer); + } + sessions.delete(recId); +} + +export function publishPipelineEvent(recId: string, event: SSEEvent): void { + const session = getOrCreateSession(recId); + session.history.push(event); + + for (const listener of session.listeners) { + listener(event); + } + + if (event.stage === 'complete' || event.status === 'error') { + session.terminal = true; + if (session.listeners.size === 0) { + scheduleCleanup(recId, session); + } + } +} + +export function getPipelineHistory(recId: string): SSEEvent[] { + return sessions.get(recId)?.history ?? []; +} + +export function hasPipelineSession(recId: string): boolean { + return sessions.has(recId); +} + +export function subscribeToPipelineEvents(recId: string, listener: Listener): () => void { + const session = getOrCreateSession(recId); + session.listeners.add(listener); + + return () => { + const current = sessions.get(recId); + if (!current) return; + + current.listeners.delete(listener); + if (current.terminal && current.listeners.size === 0) { + scheduleCleanup(recId, current); + } + }; +} diff --git a/packages/backend/src/pipelines/continuous.ts b/packages/backend/src/pipelines/continuous.ts new file mode 100644 index 0000000..cdb0b3a --- /dev/null +++ b/packages/backend/src/pipelines/continuous.ts @@ -0,0 +1,300 @@ +import { eq } from 'drizzle-orm'; +import { db } from '../db.js'; +import { recommendations } from '../db/schema.js'; +import { runInterpreter } from '../agents/interpreter.js'; +import { runRetrievalBatch } from '../agents/retrievalContinuous.js'; +import { runValidator } from '../agents/validator.js'; +import { runRanking } from '../agents/ranking.js'; +import { runCurator } from '../agents/curator.js'; +import type { CuratorOutput, InterpreterOutput, MediaType, RankingOutput, RetrievalCandidate, SSEEvent } from '../types/agents.js'; +import { generateTitle } from '../agents/titleGenerator.js'; + +function log(msg: string, data?: unknown) { + const ts = new Date().toISOString(); + if (data !== undefined) { + console.log(`[continuous-pipeline] [${ts}] ${msg}`, data); + } else { + console.log(`[continuous-pipeline] [${ts}] ${msg}`); + } +} + +function deduplicateCandidates(candidates: RetrievalCandidate[], seenTitles: Set): RetrievalCandidate[] { + return candidates.filter((c) => { + const key = c.title.toLowerCase(); + if (seenTitles.has(key)) return false; + seenTitles.add(key); + return true; + }); +} + +function splitIntoBuckets(items: T[], n: number): T[][] { + const size = Math.ceil(items.length / n); + return Array.from({ length: n }, (_, i) => items.slice(i * size, (i + 1) * size)) + .filter((b) => b.length > 0); +} + +function mergeCuratorOutputs(a: CuratorOutput[], b: CuratorOutput[]): CuratorOutput[] { + const seen = new Set(a.map((x) => x.title.toLowerCase())); + return [...a, ...b.filter((x) => !seen.has(x.title.toLowerCase()))]; +} + +interface ContinuousPipelineInput { + likedShows: string; + dislikedShows?: string; + themes?: string; + requirements?: string; + avoid?: string; + totalCount: number; + useWebSearch: boolean; + validateResults: boolean; + mediaType: MediaType; +} + +export async function runContinuousPipeline( + recId: string, + input: ContinuousPipelineInput, + sseWrite: (event: SSEEvent) => void, +): Promise { + const startTime = Date.now(); + const { + likedShows, + dislikedShows = '', + themes = '', + requirements = '', + avoid = '', + totalCount, + useWebSearch, + validateResults, + mediaType, + } = input; + + const totalBatches = Math.ceil(totalCount / 10); + const allSeenTitles = new Set(); + let previousResponseId: string | null = null; + let interpreterOutput: InterpreterOutput | null = null; + const accumulatedCandidates: RetrievalCandidate[] = []; + + log(`Starting continuous pipeline: ${totalCount} titles (${totalBatches} batches), mediaType=${mediaType}, webSearch=${useWebSearch}, validate=${validateResults}`); + + try { + // --- Interpreter (runs once) --- + log('Interpreter: start'); + sseWrite({ stage: 'interpreter', status: 'start' }); + const t0 = Date.now(); + + interpreterOutput = await runInterpreter({ + main_prompt: themes || 'recommend shows based on user preferences', + liked_shows: likedShows, + disliked_shows: dislikedShows, + themes: themes, + media_type: mediaType, + }); + + log(`Interpreter: done (${Date.now() - t0}ms)`, { + liked: interpreterOutput.liked, + themes: interpreterOutput.themes, + }); + sseWrite({ stage: 'interpreter', status: 'done', data: interpreterOutput }); + + // --- Retrieval (batched, chained) --- + log(`Retrieval: start (${totalBatches} batches)`); + sseWrite({ stage: 'retrieval', status: 'start' }); + + const hardRequirements = requirements.length > 0; + + for (let batchIndex = 0; batchIndex < totalBatches; batchIndex++) { + log(`Retrieval batch ${batchIndex + 1}/${totalBatches}: start`); + sseWrite({ + stage: 'retrieval', + status: 'start', + data: { + batch: batchIndex + 1, + totalBatches, + totalCandidates: accumulatedCandidates.length, + }, + }); + + const seenTitlesArray = Array.from(allSeenTitles); + const result = await runRetrievalBatch( + interpreterOutput, + mediaType, + useWebSearch, + hardRequirements, + seenTitlesArray, + previousResponseId + ); + + previousResponseId = result.responseId; + + // Deduplicate against previously seen titles + const deduped = deduplicateCandidates(result.candidates, allSeenTitles); + accumulatedCandidates.push(...deduped); + + log(`Retrieval batch ${batchIndex + 1}/${totalBatches}: done, ${deduped.length} new candidates (total: ${accumulatedCandidates.length})`); + } + + log(`Retrieval: complete, ${accumulatedCandidates.length} total candidates`); + sseWrite({ + stage: 'retrieval', + status: 'done', + data: { + totalBatches, + totalCandidates: accumulatedCandidates.length, + }, + }); + + // --- Validator (optional, per batch already done during retrieval) --- + // Note: In continuous mode, we could run validator after each batch + // For now, we'll run it once after all batches if validateResults is true + let candidatesForRanking = accumulatedCandidates; + + if (validateResults && candidatesForRanking.length > 0) { + log(`Validator: start (${candidatesForRanking.length} candidates)`); + sseWrite({ stage: 'validator', status: 'start' }); + + const tV = Date.now(); + const validatorOutput = await runValidator(candidatesForRanking, mediaType); + const verified = validatorOutput.candidates.filter((c) => !c.isTrash); + const trashCount = validatorOutput.candidates.length - verified.length; + + candidatesForRanking = verified.map(({ title, reason }) => ({ title, reason })); + allSeenTitles.clear(); + candidatesForRanking.forEach((c) => allSeenTitles.add(c.title.toLowerCase())); + + log(`Validator: done (${Date.now() - tV}ms) — removed ${trashCount} trash entries`); + sseWrite({ stage: 'validator', status: 'done', data: { removed: trashCount, remaining: candidatesForRanking.length } }); + } else { + sseWrite({ stage: 'validator', status: 'done', data: { skipped: !validateResults } }); + } + + // --- Ranking (bucketed) --- + log(`Ranking: start (${candidatesForRanking.length} candidates)`); + sseWrite({ stage: 'ranking', status: 'start' }); + const t2 = Date.now(); + + const rankBucketCount = Math.ceil(candidatesForRanking.length / 15) || 1; + const candidateBuckets = splitIntoBuckets(candidatesForRanking, rankBucketCount); + + const rankingBuckets = await Promise.all( + candidateBuckets.map((bucket) => + runRanking(interpreterOutput!, { candidates: bucket }, mediaType, hardRequirements) + ) + ); + + const dedupTitles = (titles: string[]) => [...new Map(titles.map((t) => [t.toLowerCase(), t])).values()]; + const rankingOutput: RankingOutput = { + full_match: dedupTitles(rankingBuckets.flatMap((r) => r.full_match)), + definitely_like: dedupTitles(rankingBuckets.flatMap((r) => r.definitely_like)), + might_like: dedupTitles(rankingBuckets.flatMap((r) => r.might_like)), + questionable: dedupTitles(rankingBuckets.flatMap((r) => r.questionable)), + will_not_like: dedupTitles(rankingBuckets.flatMap((r) => r.will_not_like)), + }; + + log(`Ranking: done (${Date.now() - t2}ms)`, { + full_match: rankingOutput.full_match.length, + definitely_like: rankingOutput.definitely_like.length, + might_like: rankingOutput.might_like.length, + }); + sseWrite({ stage: 'ranking', status: 'done', data: rankingOutput }); + + // print all ranked titles for debuggings + log('Ranked titles:', { + full_match: rankingOutput.full_match, + definitely_like: rankingOutput.definitely_like, + might_like: rankingOutput.might_like, + questionable: rankingOutput.questionable, + will_not_like: rankingOutput.will_not_like, + }); + + // --- Curator (bucketed) --- + log(`Curator: start`); + sseWrite({ stage: 'curator', status: 'start' }); + const t3 = Date.now(); + + type CategorizedItem = { title: string; category: keyof RankingOutput }; + const categorizedItems: CategorizedItem[] = [ + ...rankingOutput.full_match.map((t) => ({ title: t, category: 'full_match' as const })), + ...rankingOutput.definitely_like.map((t) => ({ title: t, category: 'definitely_like' as const })), + ...rankingOutput.might_like.map((t) => ({ title: t, category: 'might_like' as const })), + ...rankingOutput.questionable.map((t) => ({ title: t, category: 'questionable' as const })), + ...rankingOutput.will_not_like.map((t) => ({ title: t, category: 'will_not_like' as const })), + ]; + + const curatorBucketCount = Math.ceil(categorizedItems.length / 15) || 1; + const curatorItemBuckets = splitIntoBuckets(categorizedItems, curatorBucketCount); + const curatorBucketRankings: RankingOutput[] = curatorItemBuckets.map((bucket) => ({ + full_match: bucket.filter((i) => i.category === 'full_match').map((i) => i.title), + definitely_like: bucket.filter((i) => i.category === 'definitely_like').map((i) => i.title), + might_like: bucket.filter((i) => i.category === 'might_like').map((i) => i.title), + questionable: bucket.filter((i) => i.category === 'questionable').map((i) => i.title), + will_not_like: bucket.filter((i) => i.category === 'will_not_like').map((i) => i.title), + })); + + const curatorBucketOutputs = await Promise.all( + curatorBucketRankings.map((ranking) => + runCurator(ranking, interpreterOutput!, mediaType, useWebSearch) + ) + ); + + const curatorOutput = curatorBucketOutputs.reduce((acc, bucket) => mergeCuratorOutputs(acc, bucket), [] as CuratorOutput[]); + log(`Curator: done (${Date.now() - t3}ms) — ${curatorOutput.length} items curated`); + sseWrite({ stage: 'curator', status: 'done', data: curatorOutput }); + + // Generate AI title + let aiTitle = 'Continuous Recommendations'; + try { + log('Title generation: start'); + aiTitle = await generateTitle(interpreterOutput, mediaType); + log(`Title generation: done — "${aiTitle}"`); + } catch (err) { + log(`Title generation failed: ${String(err)}`); + } + + // Sort by category order + const CATEGORY_ORDER: Record = { + 'Full Match': 0, + 'Definitely Like': 1, + 'Might Like': 2, + 'Questionable': 3, + 'Will Not Like': 4, + }; + curatorOutput.sort((a, b) => (CATEGORY_ORDER[a.category] ?? 99) - (CATEGORY_ORDER[b.category] ?? 99)); + + // Save to database (update existing record) + log('Saving results to DB'); + await db + .update(recommendations) + .set({ + title: aiTitle, + main_prompt: themes || 'Continuous recommendations', + liked_shows: likedShows, + disliked_shows: dislikedShows, + themes: themes, + brainstorm_count: totalCount, + media_type: mediaType, + use_web_search: useWebSearch, + use_validator: validateResults, + status: 'done', + recommendations: curatorOutput, + }) + .where(eq(recommendations.id, recId)); + + sseWrite({ stage: 'complete', status: 'done', data: { id: recId, title: aiTitle, batchCount: totalBatches } }); + + log(`Pipeline complete (total: ${Date.now() - startTime}ms), saved as ${recId}`); + return curatorOutput; + + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log(`Pipeline error: ${message}`); + + // Update status to error + await db + .update(recommendations) + .set({ status: 'error' }) + .where(eq(recommendations.id, recId)); + + sseWrite({ stage: 'curator', status: 'error', data: { message } }); + return []; + } +} diff --git a/packages/backend/src/routes/recommendations.ts b/packages/backend/src/routes/recommendations.ts index 64fe4ff..72709f8 100644 --- a/packages/backend/src/routes/recommendations.ts +++ b/packages/backend/src/routes/recommendations.ts @@ -3,8 +3,26 @@ import { eq, desc } from 'drizzle-orm'; import { db } from '../db.js'; import { recommendations, feedback } from '../db/schema.js'; import { runPipeline } from '../pipelines/recommendation.js'; +import { runContinuousPipeline } from '../pipelines/continuous.js'; import type { MediaType, SSEEvent } from '../types/agents.js'; import { supportsWebSearch } from '../agent.js'; +import { + getPipelineHistory, + hasPipelineSession, + publishPipelineEvent, + resetPipelineSession, + subscribeToPipelineEvents, +} from '../pipelineStreams.js'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isUuid(id: string): boolean { + return UUID_RE.test(id); +} + +function writeSSE(raw: typeof import('node:http').ServerResponse.prototype, event: SSEEvent): void { + raw.write(`data: ${JSON.stringify(event)}\n\n`); +} export default async function recommendationsRoute(fastify: FastifyInstance) { // POST /recommendations — create record, return { id } @@ -64,6 +82,94 @@ export default async function recommendationsRoute(fastify: FastifyInstance) { return reply.code(201).send({ id: rec?.id }); }); + // POST /recommendations/continuous — create record and run continuous pipeline with SSE + fastify.post('/recommendations/continuous', async (request, reply) => { + const body = request.body as { + liked_shows: string; + disliked_shows?: string; + themes?: string; + requirements?: string; + avoid?: string; + total_count: number; + media_type?: string; + use_web_search?: boolean; + validate_results?: boolean; + }; + + const mediaType: MediaType = body.media_type === 'movie' ? 'movie' : 'tv_show'; + const useWebSearch = body.use_web_search === true; + const validateResults = body.validate_results === true && supportsWebSearch; + const totalCount = Number.isFinite(body.total_count) ? Math.min(100, Math.max(10, body.total_count)) : 30; + + // Create record first with pending status + const title = body.themes?.trim().split(/\s+/).slice(0, 5).join(' ') || 'Continuous Recommendations'; + const [rec] = await db + .insert(recommendations) + .values({ + title, + main_prompt: body.themes ?? 'Continuous recommendations', + liked_shows: body.liked_shows, + disliked_shows: body.disliked_shows ?? '', + themes: body.themes ?? '', + brainstorm_count: totalCount, + media_type: mediaType, + use_web_search: useWebSearch, + use_validator: validateResults, + status: 'pending', + }) + .returning({ id: recommendations.id }); + + if (!rec) { + return reply.code(500).send({ error: 'Failed to create recommendation record' }); + } + + const recId = rec.id; + resetPipelineSession(recId); + + // Set SSE headers and hijack + reply.raw.setHeader('Content-Type', 'text/event-stream'); + reply.raw.setHeader('Cache-Control', 'no-cache'); + reply.raw.setHeader('Connection', 'keep-alive'); + reply.raw.setHeader('Access-Control-Allow-Origin', '*'); + reply.raw.flushHeaders(); + reply.hijack(); + + // Immediately send the record ID so frontend can navigate to it + reply.raw.write(`data: ${JSON.stringify({ type: 'created', id: recId })}\n\n`); + + const sseWrite = (event: SSEEvent) => { + publishPipelineEvent(recId, event); + try { + writeSSE(reply.raw, event); + } catch { + // Client disconnected + } + }; + + try { + // Update status to running + await db + .update(recommendations) + .set({ status: 'running' }) + .where(eq(recommendations.id, recId)); + + // Run the continuous pipeline (it will now update the existing record) + await runContinuousPipeline(recId, { + likedShows: body.liked_shows, + dislikedShows: body.disliked_shows ?? '', + themes: body.themes ?? '', + requirements: body.requirements ?? '', + avoid: body.avoid ?? '', + totalCount, + useWebSearch, + validateResults, + mediaType, + }, sseWrite); + } finally { + reply.raw.end(); + } + }); + // GET /recommendations — list all fastify.get('/recommendations', async (_request, reply) => { const rows = await db @@ -82,6 +188,7 @@ export default async function recommendationsRoute(fastify: FastifyInstance) { // GET /recommendations/:id — full record fastify.get('/recommendations/:id', async (request, reply) => { const { id } = request.params as { id: string }; + if (!isUuid(id)) return reply.code(404).send({ error: 'Not found' }); const [rec] = await db .select() .from(recommendations) @@ -94,6 +201,7 @@ export default async function recommendationsRoute(fastify: FastifyInstance) { // GET /recommendations/:id/stream — SSE pipeline stream fastify.get('/recommendations/:id/stream', async (request, reply) => { const { id } = request.params as { id: string }; + if (!isUuid(id)) return reply.code(404).send({ error: 'Not found' }); const [rec] = await db .select() .from(recommendations) @@ -113,7 +221,7 @@ export default async function recommendationsRoute(fastify: FastifyInstance) { // an in-flight pipeline that is still running server-side. const sseWrite = (event: SSEEvent) => { try { - reply.raw.write(`data: ${JSON.stringify(event)}\n\n`); + writeSSE(reply.raw, event); } catch { // Client disconnected — pipeline continues, writes are silently dropped } @@ -136,6 +244,34 @@ export default async function recommendationsRoute(fastify: FastifyInstance) { // Poll the DB until it reaches a terminal state, then report the result. // This prevents starting a duplicate pipeline run on page reload. if (rec.status === 'running') { + if (hasPipelineSession(id)) { + const history = getPipelineHistory(id); + for (const event of history) { + sseWrite(event); + } + + const lastEvent = history[history.length - 1]; + if (lastEvent && (lastEvent.stage === 'complete' || lastEvent.status === 'error')) { + return; + } + + await new Promise((resolve) => { + const unsubscribe = subscribeToPipelineEvents(id, (event) => { + sseWrite(event); + if (event.stage === 'complete' || event.status === 'error') { + unsubscribe(); + resolve(); + } + }); + + request.raw.on('close', () => { + unsubscribe(); + resolve(); + }); + }); + return; + } + const POLL_INTERVAL_MS = 2000; const TIMEOUT_MS = 20 * 60 * 1000; // 20 minutes hard ceiling const start = Date.now(); @@ -161,6 +297,7 @@ export default async function recommendationsRoute(fastify: FastifyInstance) { } // status === 'pending' — start the pipeline normally. + resetPipelineSession(id); // Load all feedback to potentially inject as context const feedbackRows = await db.select().from(feedback); @@ -175,7 +312,10 @@ export default async function recommendationsRoute(fastify: FastifyInstance) { .join('\n') : undefined; - await runPipeline(rec, sseWrite, feedbackContext); + await runPipeline(rec, (event) => { + publishPipelineEvent(id, event); + sseWrite(event); + }, feedbackContext); } finally { reply.raw.end(); } @@ -184,6 +324,7 @@ export default async function recommendationsRoute(fastify: FastifyInstance) { // DELETE /recommendations/:id — delete a recommendation fastify.delete('/recommendations/:id', async (request, reply) => { const { id } = request.params as { id: string }; + if (!isUuid(id)) return reply.code(404).send({ error: 'Not found' }); const [rec] = await db .select({ id: recommendations.id }) .from(recommendations) @@ -199,6 +340,7 @@ export default async function recommendationsRoute(fastify: FastifyInstance) { // POST /recommendations/:id/rerank — reset status so client can re-open SSE stream fastify.post('/recommendations/:id/rerank', async (request, reply) => { const { id } = request.params as { id: string }; + if (!isUuid(id)) return reply.code(404).send({ error: 'Not found' }); const [rec] = await db .select({ id: recommendations.id }) .from(recommendations) @@ -210,6 +352,7 @@ export default async function recommendationsRoute(fastify: FastifyInstance) { .update(recommendations) .set({ status: 'pending' }) .where(eq(recommendations.id, id)); + resetPipelineSession(id); return reply.send({ ok: true }); }); diff --git a/packages/backend/src/types/agents.ts b/packages/backend/src/types/agents.ts index 25e1bfd..63ca1f3 100644 --- a/packages/backend/src/types/agents.ts +++ b/packages/backend/src/types/agents.ts @@ -67,3 +67,28 @@ export interface SSEEvent { status: SSEStatus; data?: unknown; } + +export interface ContinuousSession { + sessionId: string; + mediaType: MediaType; + interpreterOutput: InterpreterOutput; + accumulatedCandidates: RetrievalCandidate[]; + previousResponseId: string | null; + batchCount: number; + totalCount: number; + useWebSearch: boolean; + validateResults: boolean; + allSeenTitles: Set; +} + +export interface ContinuousStartRequest { + mediaType: 'tv_show' | 'movie'; + likedShows: string; + dislikedShows?: string; + themes?: string; + requirements?: string; + avoid?: string; + totalCount: number; + useWebSearch: boolean; + validateResults: boolean; +} diff --git a/packages/frontend/src/api/client.ts b/packages/frontend/src/api/client.ts index a2738df..f100603 100644 --- a/packages/frontend/src/api/client.ts +++ b/packages/frontend/src/api/client.ts @@ -66,3 +66,81 @@ export function getFeedback(): Promise { export function deleteRecommendation(id: string): Promise<{ ok: boolean }> { return request(`/recommendations/${id}`, { method: 'DELETE' }); } + +export function createContinuousRecommendation(body: { + liked_shows: string; + disliked_shows?: string; + themes?: string; + requirements?: string; + avoid?: string; + total_count: number; + media_type: MediaType; + use_web_search?: boolean; + validate_results?: boolean; +}): Promise<{ id: string }> { + return (async () => { + const res = await fetch(`${BASE}/recommendations/continuous`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`HTTP ${res.status}: ${text}`); + } + + if (!res.body) { + throw new Error('Missing response stream for continuous recommendation'); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { value, done } = await reader.read(); + + if (value) { + buffer += decoder.decode(value, { stream: !done }); + } + + let boundary = buffer.indexOf('\n\n'); + while (boundary !== -1) { + const rawEvent = buffer.slice(0, boundary).trim(); + buffer = buffer.slice(boundary + 2); + + if (rawEvent) { + const dataLine = rawEvent + .split('\n') + .find((line) => line.startsWith('data:')); + + if (dataLine) { + const payload = dataLine.slice(5).trim(); + const event = JSON.parse(payload) as { type?: string; id?: string }; + + if (event.type === 'created' && event.id) { + await reader.cancel(); + return { id: event.id }; + } + } + } + + boundary = buffer.indexOf('\n\n'); + } + + if (done) { + break; + } + } + } finally { + reader.releaseLock(); + } + + throw new Error('Continuous recommendation stream ended before returning an id'); + })(); +} diff --git a/packages/frontend/src/components/Modal.css b/packages/frontend/src/components/Modal.css index 27f6c73..e537d9a 100644 --- a/packages/frontend/src/components/Modal.css +++ b/packages/frontend/src/components/Modal.css @@ -1,307 +1,577 @@ -/* ── Modal ──────────────────────────────────────────────── */ - .modal-backdrop { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.6); + z-index: 100; display: flex; align-items: center; justify-content: center; - z-index: 100; - backdrop-filter: blur(2px); + padding: 24px; + background: + radial-gradient(circle at top, rgba(56, 189, 248, 0.12), transparent 32%), + radial-gradient(circle at bottom right, rgba(251, 191, 36, 0.12), transparent 28%), + rgba(6, 8, 12, 0.76); + backdrop-filter: blur(12px); } .modal { - background: var(--bg-surface); - border: 1px solid var(--border); - border-radius: 12px; - width: 650px; - max-width: 96vw; - max-height: 90vh; + width: min(900px, 100%); + max-height: min(92vh, 980px); overflow-y: auto; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 28px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 18%), + linear-gradient(145deg, rgba(15, 23, 42, 0.97), rgba(17, 24, 39, 0.96)); + box-shadow: + 0 32px 90px rgba(0, 0, 0, 0.45), + inset 0 1px 0 rgba(255, 255, 255, 0.05); } +.modal-hero, .modal-header { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; - padding: 20px 20px 0; + gap: 24px; + padding: 28px 30px 0; } +.modal-hero h2, .modal-header h2 { - font-size: 18px; + font-size: clamp(1.8rem, 3vw, 2.35rem); + line-height: 1.05; + letter-spacing: -0.03em; + color: #f8fafc; +} + +.modal-hero-copy { + max-width: 560px; + margin-top: 12px; + color: rgba(226, 232, 240, 0.72); + font-size: 1rem; + line-height: 1.6; +} + +.modal-eyebrow { + display: inline-flex; + align-items: center; + margin-bottom: 14px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(56, 189, 248, 0.14); + color: #7dd3fc; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.modal-close, +.modal-back { + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + background: rgba(255, 255, 255, 0.03); + color: var(--text-muted); + cursor: pointer; + transition: transform 0.2s ease, border-color 0.2s ease, color 0.2s ease, background 0.2s ease; +} + +.modal-close:hover:not(:disabled), +.modal-back:hover:not(:disabled) { + transform: translateY(-1px); + color: #f8fafc; + border-color: rgba(125, 211, 252, 0.34); + background: rgba(56, 189, 248, 0.08); +} + +.modal-close:disabled, +.modal-back:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.modal-header-left { + display: flex; + align-items: flex-start; + gap: 16px; +} + +.modal-type-select, +.modal-form { + padding: 28px 30px 30px; +} + +.modal-type-select { + display: flex; + flex-direction: column; + gap: 24px; +} + +.modal-section { + padding: 22px; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 24px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 100%), + rgba(15, 23, 42, 0.58); +} + +.modal-section-header { + display: flex; + gap: 14px; + align-items: flex-start; + margin-bottom: 18px; +} + +.modal-section-header h3, +.settings-card-header h3 { + font-size: 1.08rem; + color: #f8fafc; +} + +.modal-section-header p, +.settings-card-header p { + margin-top: 4px; + color: var(--text-muted); + line-height: 1.5; +} + +.modal-section-step { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: linear-gradient(135deg, rgba(56, 189, 248, 0.18), rgba(251, 191, 36, 0.16)); + color: #f8fafc; font-weight: 700; } -.modal-close { - background: none; - border: none; - font-size: 22px; - color: var(--text-muted); - cursor: pointer; - line-height: 1; - padding: 0; +.modal-type-cards, +.mode-cards, +.modal-form-grid { + display: grid; + gap: 16px; } -.modal-close:hover { - color: var(--text); +.modal-type-cards, +.mode-cards { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.type-card, +.mode-card { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + padding: 20px 20px 18px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 22px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 100%), + rgba(30, 41, 59, 0.68); + color: inherit; + text-align: left; + cursor: pointer; + transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; +} + +.type-card:hover, +.mode-card:hover { + transform: translateY(-2px); + border-color: rgba(125, 211, 252, 0.4); + box-shadow: 0 18px 32px rgba(2, 8, 23, 0.25); +} + +.type-card--selected, +.mode-card--active { + border-color: rgba(125, 211, 252, 0.55); + background: + linear-gradient(180deg, rgba(56, 189, 248, 0.12), rgba(251, 191, 36, 0.06)), + rgba(17, 24, 39, 0.92); + box-shadow: inset 0 0 0 1px rgba(125, 211, 252, 0.14); +} + +.type-card-icon { + display: inline-block; + font-size: 1.55rem; + line-height: 1; + margin: 0 2px 0 0; + transform: translateY(1px); + filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.18)); +} + +.type-card-label, +.mode-card-label { + color: #f8fafc; + font-size: 1.04rem; + font-weight: 700; +} + +.type-card-title-row { + display: flex; + align-items: center; + gap: 8px; +} + +.type-card-desc, +.mode-card-desc { + color: rgba(226, 232, 240, 0.72); + line-height: 1.55; + font-size: 0.92rem; +} + +.mode-card-badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 999px; + background: rgba(251, 191, 36, 0.14); + color: #fcd34d; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; +} + +.modal-type-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; +} + +.modal-selection-summary, +.modal-summary-strip { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.summary-pill { + display: inline-flex; + align-items: center; + padding: 8px 12px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + color: #e2e8f0; + font-size: 0.8rem; + font-weight: 600; +} + +.summary-pill--accent { + background: rgba(56, 189, 248, 0.12); + border-color: rgba(125, 211, 252, 0.22); + color: #7dd3fc; +} + +.summary-caption { + color: var(--text-muted); + font-size: 0.9rem; +} + +.modal-continue { + padding-inline: 22px; } .modal-form { - padding: 20px; display: flex; flex-direction: column; - gap: 16px; + gap: 22px; +} + +.modal-form-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); } .form-group { display: flex; flex-direction: column; - gap: 6px; - flex: 1; + gap: 8px; +} + +.form-group--full { + grid-column: 1 / -1; } .form-group label { - font-size: 13px; - font-weight: 500; - color: var(--text-muted); + color: #e2e8f0; + font-size: 0.92rem; + font-weight: 600; +} + +.form-help { + color: var(--text-dim); + font-size: 0.8rem; + line-height: 1.5; } .form-input, .form-textarea { - background: var(--bg-surface-2); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--text); - font-size: 14px; - padding: 10px 12px; - outline: none; - transition: border-color 0.15s; - font-family: inherit; width: 100%; + padding: 13px 14px; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 16px; + background: rgba(15, 23, 42, 0.66); + color: #f8fafc; + font: inherit; + outline: none; + transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; } -.form-input[type="range"] { - padding: 0; - cursor: pointer; +.form-input::placeholder, +.form-textarea::placeholder { + color: #64748b; } .form-input:focus, .form-textarea:focus { - border-color: var(--accent); + border-color: rgba(125, 211, 252, 0.52); + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.12); + background: rgba(15, 23, 42, 0.86); +} + +.form-input[type='range'] { + padding: 0; + border: none; + background: transparent; + box-shadow: none; + cursor: pointer; } .form-textarea { resize: vertical; - min-height: 80px; + min-height: 120px; } -.modal-actions { - display: flex; - justify-content: flex-end; - gap: 10px; - padding-top: 4px; +.form-textarea--hero { + min-height: 138px; } -/* ── Type selection step ─────────────────────────────────── */ - -.modal-type-select { - padding: 16px 20px 28px; +.settings-card { display: flex; flex-direction: column; - gap: 20px; + gap: 16px; + padding: 22px; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 24px; + background: + radial-gradient(circle at top right, rgba(56, 189, 248, 0.07), transparent 28%), + rgba(15, 23, 42, 0.58); } -.modal-type-hint { - font-size: 14px; +.slider-card { + padding: 16px 18px; + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(15, 23, 42, 0.72); +} + +.slider-card--compact { + padding: 14px 16px; +} + +.slider-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + margin-bottom: 12px; +} + +.slider-label { + display: block; + color: #f8fafc; + font-weight: 600; +} + +.slider-copy { + display: block; + margin-top: 4px; color: var(--text-muted); - margin: 0; + font-size: 0.82rem; + line-height: 1.5; } -.modal-type-cards { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 14px; -} - -.type-card { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - padding: 4rem 2rem; - background: var(--bg-surface-2); - border: 1px solid var(--border); - border-radius: 10px; - cursor: pointer; - transition: border-color 0.15s, background 0.15s; +.slider-value { + min-width: 48px; + padding: 8px 10px; + border-radius: 12px; + background: rgba(56, 189, 248, 0.12); + color: #7dd3fc; + font-weight: 700; text-align: center; } -.type-card:hover { - border-color: var(--accent); - background: var(--bg-surface); -} - -.type-card-icon { - font-size: 32px; - line-height: 1; -} - -.type-card-label { - font-size: 16px; - font-weight: 700; - color: var(--text); -} - -.type-card-desc { - font-size: 12px; - color: var(--text-muted); -} - -/* ── Modal header back button ────────────────────────────── */ - -.modal-header-left { +.toggle-stack { display: flex; - align-items: center; - gap: 8px; + flex-direction: column; + gap: 12px; } -.modal-back { - background: none; - border: none; - font-size: 18px; - color: var(--text-muted); - cursor: pointer; - line-height: 1; - padding: 0 4px 0 0; +.toggle-card { + padding: 15px 16px; + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.07); + background: rgba(15, 23, 42, 0.6); } -.modal-back:hover { - color: var(--text); -} - -/* ── Web search toggle ───────────────────────────────────── */ - -.form-group-toggle { - padding: 12px 0 4px; - border-top: 1px solid var(--border); +.toggle-card--disabled { + opacity: 0.55; } .toggle-label { display: flex; align-items: center; justify-content: space-between; - cursor: pointer; gap: 16px; } .toggle-text { display: flex; flex-direction: column; - gap: 3px; + gap: 4px; } .toggle-title { - font-size: 14px; - font-weight: 500; - color: var(--text); + color: #f8fafc; + font-weight: 600; } .toggle-desc { - font-size: 12px; color: var(--text-muted); + font-size: 0.84rem; + line-height: 1.5; } .toggle-switch { - flex-shrink: 0; - width: 40px; - height: 22px; - background: var(--bg-surface-2); - border: 1px solid var(--border); - border-radius: 11px; position: relative; + flex-shrink: 0; + width: 52px; + height: 30px; + border: 1px solid rgba(255, 255, 255, 0.11); + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); cursor: pointer; - transition: background 0.2s, border-color 0.2s; + transition: background 0.2s ease, border-color 0.2s ease; } .toggle-switch.on { - background: var(--accent); - border-color: var(--accent); -} - -.toggle-knob { - position: absolute; - top: 2px; - left: 2px; - width: 16px; - height: 16px; - background: #fff; - border-radius: 50%; - transition: transform 0.2s; -} - -.toggle-switch.on .toggle-knob { - transform: translateX(18px); -} - -/* ── Disabled toggle ─────────────────────────────────────── */ - -.toggle-disabled { - opacity: 0.45; - cursor: not-allowed; + background: linear-gradient(135deg, rgba(56, 189, 248, 0.95), rgba(14, 165, 233, 0.85)); + border-color: rgba(125, 211, 252, 0.4); } .toggle-switch-disabled { cursor: not-allowed; } -/* ── Self Expansive options ──────────────────────────────── */ +.toggle-knob { + position: absolute; + top: 3px; + left: 3px; + width: 22px; + height: 22px; + border-radius: 50%; + background: #fff; + transition: transform 0.2s ease; +} + +.toggle-switch.on .toggle-knob { + transform: translateX(22px); +} .expansive-options { - padding: 12px 14px; - background: var(--bg-surface-2); - border: 1px solid var(--border); - border-radius: var(--radius-sm); display: flex; flex-direction: column; gap: 14px; - margin-top: -8px; + padding: 16px; + border-radius: 18px; + border: 1px solid rgba(125, 211, 252, 0.14); + background: rgba(8, 47, 73, 0.15); } -.mode-buttons { - display: flex; - gap: 8px; +.segmented-control { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; } -.mode-btn { - flex: 1; - padding: 8px 0; - background: var(--bg-surface); - border: 1px solid var(--border); - border-radius: var(--radius-sm); +.segmented-control-btn { + padding: 12px 14px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(15, 23, 42, 0.66); color: var(--text-muted); - font-size: 13px; - font-weight: 500; cursor: pointer; - transition: background 0.15s, border-color 0.15s, color 0.15s; - font-family: inherit; + font: inherit; + font-weight: 600; + transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease; } -.mode-btn--active { - background: var(--accent-dim); - border-color: var(--accent); - color: var(--accent); +.segmented-control-btn--active { + background: rgba(56, 189, 248, 0.12); + border-color: rgba(125, 211, 252, 0.34); + color: #7dd3fc; } -.mode-btn:hover:not(.mode-btn--active) { - background: var(--bg-surface-3); - border-color: var(--text-dim); - color: var(--text); +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + padding-top: 2px; } -.mode-desc { - display: block; - margin-top: 4px; -} \ No newline at end of file +@media (max-width: 860px) { + .modal { + width: 100%; + border-radius: 24px; + } + + .modal-hero, + .modal-header, + .modal-type-select, + .modal-form { + padding-inline: 22px; + } +} + +@media (max-width: 720px) { + .modal-backdrop { + padding: 12px; + } + + .modal-hero, + .modal-header { + flex-direction: column; + } + + .modal-close { + align-self: flex-end; + } + + .modal-type-cards, + .mode-cards, + .modal-form-grid { + grid-template-columns: 1fr; + } + + .modal-type-footer, + .modal-actions { + flex-direction: column; + align-items: stretch; + } + + .toggle-label, + .slider-card-header { + align-items: flex-start; + } +} diff --git a/packages/frontend/src/components/NewRecommendationModal.tsx b/packages/frontend/src/components/NewRecommendationModal.tsx index a47c086..dde92a1 100644 --- a/packages/frontend/src/components/NewRecommendationModal.tsx +++ b/packages/frontend/src/components/NewRecommendationModal.tsx @@ -1,7 +1,9 @@ -import { useState } from 'preact/hooks'; +import { useEffect, useState } from 'preact/hooks'; import type { MediaType } from '../types/index.js'; import './Modal.css'; +type GenerationMode = 'brainstorm' | 'continuous'; + interface NewRecommendationModalProps { onClose: () => void; onSubmit: (body: { @@ -9,6 +11,8 @@ interface NewRecommendationModalProps { liked_shows: string; disliked_shows: string; themes: string; + requirements?: string; + avoid?: string; brainstorm_count?: number; media_type: MediaType; use_web_search?: boolean; @@ -17,58 +21,140 @@ interface NewRecommendationModalProps { self_expansive?: boolean; expansive_passes?: number; expansive_mode?: 'soft' | 'extreme'; + generation_mode?: GenerationMode; + total_count?: number; + validate_results?: boolean; }) => Promise; } +const MEDIA_OPTIONS: Array<{ + type: MediaType; + icon: string; + label: string; + description: string; +}> = [ + { + type: 'tv_show', + icon: '📺', + label: 'TV Shows', + description: 'Serialized stories, limited series, and long-form comfort watches.', + }, + { + type: 'movie', + icon: '🎬', + label: 'Movies', + description: 'Feature films, prestige cinema, and one-night picks.', + }, +]; + +const MODE_OPTIONS: Array<{ + mode: GenerationMode; + label: string; + badge: string; + description: string; +}> = [ + { + mode: 'brainstorm', + label: 'Brainstorm', + badge: 'Best for variety', + description: 'Explore a broad pool of options, then rank and curate the strongest fits.', + }, + { + mode: 'continuous', + label: 'Continuous', + badge: 'Best for deep search', + description: 'Generate recommendations in chained batches for a steadier, longer-running hunt.', + }, +]; + export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationModalProps) { - const [step, setStep] = useState<'type' | 'form'>('type'); + const [step, setStep] = useState<'type' | 'mode' | 'form'>('type'); const [mediaType, setMediaType] = useState('tv_show'); + const [generationMode, setGenerationMode] = useState('brainstorm'); const [mainPrompt, setMainPrompt] = useState(''); const [likedShows, setLikedShows] = useState(''); const [dislikedShows, setDislikedShows] = useState(''); const [themes, setThemes] = useState(''); + const [requirements, setRequirements] = useState(''); + const [avoid, setAvoid] = useState(''); const [brainstormCount, setBrainstormCount] = useState(100); + const [totalCount, setTotalCount] = useState(30); const [useWebSearch, setUseWebSearch] = useState(false); const [useValidator, setUseValidator] = useState(false); const [useHardRequirements, setUseHardRequirements] = useState(false); const [selfExpansive, setSelfExpansive] = useState(false); const [expansivePasses, setExpansivePasses] = useState(2); const [expansiveMode, setExpansiveMode] = useState<'soft' | 'extreme'>('soft'); + const [validateResults, setValidateResults] = useState(false); const [loading, setLoading] = useState(false); + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !loading) { + onClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [loading, onClose]); + const mediaLabel = mediaType === 'movie' ? 'Movie' : 'TV Show'; const mediaPluralLabel = mediaType === 'movie' ? 'movies' : 'shows'; const handleSelectType = (type: MediaType) => { setMediaType(type); - setStep('form'); + setStep('mode'); }; const handleWebSearchToggle = () => { const next = !useWebSearch; setUseWebSearch(next); - if (!next) setUseValidator(false); + if (!next) { + setUseValidator(false); + setValidateResults(false); + } }; const handleSubmit = async (e: Event) => { e.preventDefault(); - if (!mainPrompt.trim()) return; + if (generationMode === 'brainstorm' && !mainPrompt.trim()) return; + if (!likedShows.trim()) return; + setLoading(true); try { - await onSubmit({ - main_prompt: mainPrompt.trim(), - liked_shows: likedShows.trim(), - disliked_shows: dislikedShows.trim(), - themes: themes.trim(), - brainstorm_count: brainstormCount, - media_type: mediaType, - use_web_search: useWebSearch, - use_validator: useValidator, - hard_requirements: useHardRequirements, - self_expansive: selfExpansive, - expansive_passes: selfExpansive ? expansivePasses : 1, - expansive_mode: expansiveMode, - }); + if (generationMode === 'brainstorm') { + await onSubmit({ + main_prompt: mainPrompt.trim(), + liked_shows: likedShows.trim(), + disliked_shows: dislikedShows.trim(), + themes: themes.trim(), + brainstorm_count: brainstormCount, + media_type: mediaType, + use_web_search: useWebSearch, + use_validator: useValidator, + hard_requirements: useHardRequirements, + self_expansive: selfExpansive, + expansive_passes: selfExpansive ? expansivePasses : 1, + expansive_mode: expansiveMode, + generation_mode: 'brainstorm', + }); + } else { + await onSubmit({ + main_prompt: '', + liked_shows: likedShows.trim(), + disliked_shows: dislikedShows.trim(), + themes: themes.trim(), + requirements: requirements.trim(), + avoid: avoid.trim(), + media_type: mediaType, + use_web_search: useWebSearch, + validate_results: validateResults, + generation_mode: 'continuous', + total_count: totalCount, + }); + } + onClose(); } finally { setLoading(false); @@ -76,33 +162,105 @@ export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationM }; const handleBackdropClick = (e: MouseEvent) => { - if ((e.target as HTMLElement).classList.contains('modal-backdrop')) { + if ((e.target as HTMLElement).classList.contains('modal-backdrop') && !loading) { onClose(); } }; + const selectedMode = MODE_OPTIONS.find((option) => option.mode === generationMode); + return (