172 lines
5.6 KiB
TypeScript
172 lines
5.6 KiB
TypeScript
import { useState, useCallback, useEffect } from 'preact/hooks';
|
|
import './Recom.css';
|
|
import { route } from 'preact-router';
|
|
import { PipelineProgress } from '../components/PipelineProgress.js';
|
|
import { RecommendationCard } from '../components/RecommendationCard.js';
|
|
import { Sidebar } from '../components/Sidebar.js';
|
|
import { NewRecommendationModal } from '../components/NewRecommendationModal.js';
|
|
import { useRecommendationsContext } from '../context/RecommendationsContext.js';
|
|
import { useSSE } from '../hooks/useSSE.js';
|
|
import { getRecommendation } from '../api/client.js';
|
|
import type { Recommendation, SSEEvent, StageMap, PipelineStage } from '../types/index.js';
|
|
|
|
interface RecomProps {
|
|
id: string;
|
|
path?: string;
|
|
}
|
|
|
|
const DEFAULT_STAGES: StageMap = {
|
|
interpreter: 'pending',
|
|
retrieval: 'pending',
|
|
ranking: 'pending',
|
|
curator: 'pending',
|
|
};
|
|
|
|
const STAGE_ORDER: (keyof StageMap)[] = ['interpreter', 'retrieval', 'ranking', 'curator'];
|
|
|
|
export function Recom({ id }: RecomProps) {
|
|
const { list, feedback, submitFeedback, rerank, updateStatus, refreshList, createNew } = useRecommendationsContext();
|
|
|
|
const [rec, setRec] = useState<Recommendation | null>(null);
|
|
const [stages, setStages] = useState<StageMap>(DEFAULT_STAGES);
|
|
const [sseUrl, setSseUrl] = useState<string | null>(null);
|
|
const [showModal, setShowModal] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setRec(null);
|
|
setStages(DEFAULT_STAGES);
|
|
setSseUrl(null);
|
|
getRecommendation(id)
|
|
.then((data) => {
|
|
setRec(data);
|
|
if (data.status === 'running' || data.status === 'pending') {
|
|
setStages(DEFAULT_STAGES);
|
|
setSseUrl(`/api/recommendations/${id}/stream`);
|
|
}
|
|
})
|
|
.catch(() => route('/'));
|
|
}, [id]);
|
|
|
|
const handleSSEEvent = useCallback(
|
|
(event: SSEEvent) => {
|
|
if (event.stage !== 'complete') {
|
|
const stageKey = event.stage as keyof StageMap;
|
|
if (STAGE_ORDER.includes(stageKey)) {
|
|
setStages((prev) => ({
|
|
...prev,
|
|
[stageKey]: event.status === 'start' ? 'running' : event.status === 'done' ? 'done' : 'error',
|
|
}));
|
|
}
|
|
}
|
|
|
|
if (event.stage === 'complete' && event.status === 'done') {
|
|
setSseUrl(null);
|
|
updateStatus(id, 'done');
|
|
void getRecommendation(id).then(setRec);
|
|
void refreshList();
|
|
}
|
|
|
|
if (event.status === 'error') {
|
|
setSseUrl(null);
|
|
updateStatus(id, 'error');
|
|
const stageKey = event.stage as PipelineStage;
|
|
if (stageKey !== 'complete') {
|
|
setStages((prev) => ({ ...prev, [stageKey as keyof StageMap]: 'error' }));
|
|
}
|
|
}
|
|
},
|
|
[id, updateStatus, refreshList],
|
|
);
|
|
|
|
useSSE(sseUrl, handleSSEEvent);
|
|
|
|
const handleRerank = async () => {
|
|
await rerank(id);
|
|
setStages(DEFAULT_STAGES);
|
|
setSseUrl(`/api/recommendations/${id}/stream`);
|
|
setRec((prev) => (prev ? { ...prev, status: 'pending' } : null));
|
|
};
|
|
|
|
const handleCreateNew = async (body: {
|
|
main_prompt: string;
|
|
liked_shows: string;
|
|
disliked_shows: string;
|
|
themes: string;
|
|
brainstorm_count?: number;
|
|
media_type: import('../types/index.js').MediaType;
|
|
use_web_search?: boolean;
|
|
}) => {
|
|
const newId = await createNew(body);
|
|
route(`/recom/${newId}`);
|
|
};
|
|
|
|
const isRunning = rec?.status === 'running' || rec?.status === 'pending' || !!sseUrl;
|
|
const feedbackMap = new Map(feedback.map((f) => [f.item_name, f]));
|
|
|
|
return (
|
|
<div class="layout">
|
|
<Sidebar
|
|
list={list}
|
|
selectedId={id}
|
|
onSelect={(sid) => route(`/recom/${sid}`)}
|
|
onNewClick={() => setShowModal(true)}
|
|
/>
|
|
|
|
<main class="main-content">
|
|
<div class="recom-content">
|
|
{isRunning && (
|
|
<div class="content-area">
|
|
<PipelineProgress stages={stages} />
|
|
</div>
|
|
)}
|
|
|
|
{!isRunning && rec?.status === 'done' && rec.recommendations && (
|
|
<div class="content-area">
|
|
<h2 class="rec-title">{rec.title}</h2>
|
|
<div class="cards-grid">
|
|
{rec.recommendations.map((show) => (
|
|
<RecommendationCard
|
|
key={show.title}
|
|
show={show}
|
|
existingFeedback={feedbackMap.get(show.title)}
|
|
onFeedback={async (name, stars, comment) => {
|
|
await submitFeedback({ item_name: name, stars, feedback: comment });
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div class="rerank-section">
|
|
<button
|
|
class="btn-rerank"
|
|
onClick={handleRerank}
|
|
disabled={feedback.length === 0}
|
|
title={feedback.length === 0 ? 'Rate at least one show to enable re-ranking' : 'Re-rank based on your feedback'}
|
|
>
|
|
Re-rank with Feedback {feedback.length > 0 ? `(${feedback.length} rated)` : ''}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!isRunning && rec?.status === 'error' && (
|
|
<div class="content-area error-state">
|
|
<h2>Something went wrong</h2>
|
|
<p>The pipeline encountered an error. You can try again by clicking Re-rank.</p>
|
|
<button class="btn-primary" onClick={handleRerank}>
|
|
Try Again
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
|
|
{showModal && (
|
|
<NewRecommendationModal
|
|
onClose={() => setShowModal(false)}
|
|
onSubmit={handleCreateNew}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|