changess
This commit is contained in:
@@ -18,7 +18,7 @@ spec:
|
||||
image: git.ivanch.me/ivanch/recommender:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
- containerPort: 80
|
||||
env:
|
||||
- name: OPENAI_API_KEY
|
||||
valueFrom:
|
||||
@@ -48,7 +48,7 @@ spec:
|
||||
app: recommender
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
targetPort: 80
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -3822,6 +3822,15 @@
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/preact-router": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/preact-router/-/preact-router-4.1.2.tgz",
|
||||
"integrity": "sha512-uICUaUFYh+XQ+6vZtQn1q+X6rSqwq+zorWOCLWPF5FAsQh3EJ+RsDQ9Ee+fjk545YWQHfUxhrBAaemfxEnMOUg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"preact": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||
@@ -4466,7 +4475,8 @@
|
||||
"packages/frontend": {
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"preact": "^10.29.0"
|
||||
"preact": "^10.29.0",
|
||||
"preact-router": "^4.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.10.4",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
dotenv.config({ path: ['.env.local', '.env'] });
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/db/schema.ts',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import OpenAI from 'openai';
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
dotenv.config({ path: ['.env.local', '.env'] });
|
||||
|
||||
export const openai = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
dotenv.config({ path: ['.env.local', '.env'] });
|
||||
|
||||
const connectionString = process.env.DATABASE_URL || 'postgres://user:password@iris.haven:5432/recommender';
|
||||
export const client = postgres(connectionString);
|
||||
|
||||
@@ -3,10 +3,10 @@ import * as dotenv from 'dotenv';
|
||||
import recommendationsRoute from './routes/recommendations.js';
|
||||
import feedbackRoute from './routes/feedback.js';
|
||||
|
||||
// Load .env first, then .env.local
|
||||
// env vars set on the container take precedence over both files
|
||||
dotenv.config();
|
||||
dotenv.config({ path: '.env.local', override: true });
|
||||
// Load .env.local first, then .env.
|
||||
// dotenv 17+ supports array of paths, where the first path has the highest priority.
|
||||
// Env vars set on the container (system) will take precedence over both.
|
||||
dotenv.config({ path: ['.env.local', '.env'] });
|
||||
|
||||
const fastify = Fastify({ logger: true });
|
||||
|
||||
|
||||
@@ -7,6 +7,13 @@ import { runRanking } from '../agents/ranking.js';
|
||||
import { runCurator } from '../agents/curator.js';
|
||||
import type { CuratorOutput, SSEEvent } from '../types/agents.js';
|
||||
|
||||
/* -- Agent pipeline --
|
||||
[1] Interpreter -> gets user input, transforms into structured data
|
||||
[2] Retrieval -> gets shows from OpenAI (high temperature)
|
||||
[3] Ranking -> ranks shows based on user input
|
||||
[4] Curator -> curates shows based on user input
|
||||
*/
|
||||
|
||||
type RecommendationRecord = typeof recommendations.$inferSelect;
|
||||
|
||||
function log(recId: string, msg: string, data?: unknown) {
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"preact": "^10.29.0"
|
||||
"preact": "^10.29.0",
|
||||
"preact-router": "^4.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.10.4",
|
||||
|
||||
BIN
packages/frontend/public/wallpaper.png
Normal file
BIN
packages/frontend/public/wallpaper.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
@@ -1,5 +1,12 @@
|
||||
import { Router, Route } from 'preact-router';
|
||||
import { Home } from './pages/Home.js';
|
||||
import { Recom } from './pages/Recom.js';
|
||||
|
||||
export function App() {
|
||||
return <Home />;
|
||||
return (
|
||||
<Router>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/recom/:id" component={Recom} />
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
*, *::before, *::after {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -30,7 +32,9 @@
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
@@ -144,10 +148,21 @@ html, body, #app {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-done { color: var(--green); }
|
||||
.status-error { color: var(--red); }
|
||||
.status-running { color: var(--accent); }
|
||||
.status-pending { color: var(--text-dim); }
|
||||
.status-done {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.status-running {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.sidebar-item-title {
|
||||
overflow: hidden;
|
||||
@@ -251,10 +266,21 @@ html, body, #app {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stage-done { color: var(--green); }
|
||||
.stage-error { color: var(--red); }
|
||||
.stage-running { color: var(--accent); }
|
||||
.stage-pending { color: var(--text-dim); }
|
||||
.stage-done {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.stage-error {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.stage-running {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stage-pending {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
@@ -262,8 +288,13 @@ html, body, #app {
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.pipeline-step-label {
|
||||
@@ -309,10 +340,25 @@ html, body, #app {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge-green { background: rgba(34,197,94,0.15); color: #4ade80; }
|
||||
.badge-blue { background: rgba(59,130,246,0.15); color: #60a5fa; }
|
||||
.badge-yellow{ background: rgba(234,179,8,0.15); color: #facc15; }
|
||||
.badge-red { background: rgba(239,68,68,0.15); color: #f87171; }
|
||||
.badge-green {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.badge-blue {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.badge-yellow {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: #facc15;
|
||||
}
|
||||
|
||||
.badge-red {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
@@ -556,3 +602,124 @@ html, body, #app {
|
||||
gap: 10px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Landing page ───────────────────────────────────────── */
|
||||
|
||||
.landing-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.landing-bg {
|
||||
position: absolute;
|
||||
inset: -6px;
|
||||
/* overflow slightly to hide blur edges */
|
||||
background: url('/wallpaper.png') center / cover no-repeat;
|
||||
filter: blur(3px) brightness(0.35);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.landing-layout .sidebar {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: rgba(26, 29, 39, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.landing-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 28px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.landing-title {
|
||||
font-size: 5rem;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
letter-spacing: -2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.landing-tagline {
|
||||
font-size: 1.4rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.btn-gradient {
|
||||
padding: 18px 52px;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(135deg, #e040fb, #7c4dff, #2979ff, #00b0ff, #e040fb);
|
||||
background-size: 300% 300%;
|
||||
animation: gradient-flow 5s ease infinite;
|
||||
box-shadow: 0 4px 32px rgba(99, 102, 241, 0.4);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.btn-gradient:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 40px rgba(99, 102, 241, 0.55);
|
||||
}
|
||||
|
||||
@keyframes gradient-flow {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Recom page ─────────────────────────────────────────── */
|
||||
|
||||
.recom-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.recom-nav {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.recom-back {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.recom-back:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.recom-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 32px;
|
||||
}
|
||||
@@ -1,97 +1,14 @@
|
||||
import { useState, useCallback, useEffect } from 'preact/hooks';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { route } from 'preact-router';
|
||||
import { Sidebar } from '../components/Sidebar.js';
|
||||
import { NewRecommendationModal } from '../components/NewRecommendationModal.js';
|
||||
import { PipelineProgress } from '../components/PipelineProgress.js';
|
||||
import { RecommendationCard } from '../components/RecommendationCard.js';
|
||||
import { useRecommendations } from '../hooks/useRecommendations.js';
|
||||
import { useSSE } from '../hooks/useSSE.js';
|
||||
import { getRecommendation } from '../api/client.js';
|
||||
import type { Recommendation, SSEEvent, StageMap, PipelineStage } from '../types/index.js';
|
||||
|
||||
const DEFAULT_STAGES: StageMap = {
|
||||
interpreter: 'pending',
|
||||
retrieval: 'pending',
|
||||
ranking: 'pending',
|
||||
curator: 'pending',
|
||||
};
|
||||
|
||||
const STAGE_ORDER: (keyof StageMap)[] = ['interpreter', 'retrieval', 'ranking', 'curator'];
|
||||
|
||||
export function Home() {
|
||||
const {
|
||||
list,
|
||||
selectedId,
|
||||
feedback,
|
||||
setSelectedId,
|
||||
createNew,
|
||||
rerank,
|
||||
submitFeedback,
|
||||
updateStatus,
|
||||
refreshList,
|
||||
} = useRecommendations();
|
||||
|
||||
const { list, createNew } = useRecommendations();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [selectedRec, setSelectedRec] = useState<Recommendation | null>(null);
|
||||
const [stages, setStages] = useState<StageMap>(DEFAULT_STAGES);
|
||||
const [sseUrl, setSseUrl] = useState<string | null>(null);
|
||||
|
||||
// Load full recommendation when selected
|
||||
useEffect(() => {
|
||||
if (!selectedId) {
|
||||
setSelectedRec(null);
|
||||
return;
|
||||
}
|
||||
void getRecommendation(selectedId).then((rec) => {
|
||||
setSelectedRec(rec);
|
||||
// If already running or pending, open SSE
|
||||
if (rec.status === 'running' || rec.status === 'pending') {
|
||||
setStages(DEFAULT_STAGES);
|
||||
setSseUrl(`/api/recommendations/${selectedId}/stream`);
|
||||
}
|
||||
});
|
||||
}, [selectedId]);
|
||||
|
||||
const handleSSEEvent = useCallback(
|
||||
(event: SSEEvent) => {
|
||||
if (!selectedId) return;
|
||||
|
||||
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(selectedId, 'done');
|
||||
// Reload full recommendation to get results
|
||||
void getRecommendation(selectedId).then(setSelectedRec);
|
||||
void refreshList();
|
||||
}
|
||||
|
||||
if (event.status === 'error') {
|
||||
setSseUrl(null);
|
||||
updateStatus(selectedId, 'error');
|
||||
const stageKey = event.stage as PipelineStage;
|
||||
if (stageKey !== 'complete') {
|
||||
setStages((prev) => ({ ...prev, [stageKey as keyof StageMap]: 'error' }));
|
||||
}
|
||||
}
|
||||
},
|
||||
[selectedId, updateStatus, refreshList],
|
||||
);
|
||||
|
||||
useSSE(sseUrl, handleSSEEvent);
|
||||
|
||||
const handleSelect = (id: string) => {
|
||||
setSseUrl(null);
|
||||
setStages(DEFAULT_STAGES);
|
||||
setSelectedId(id);
|
||||
};
|
||||
const handleSelect = (id: string) => route(`/recom/${id}`);
|
||||
|
||||
const handleCreateNew = async (body: {
|
||||
main_prompt: string;
|
||||
@@ -100,87 +17,25 @@ export function Home() {
|
||||
themes: string;
|
||||
}) => {
|
||||
const id = await createNew(body);
|
||||
setStages(DEFAULT_STAGES);
|
||||
setSseUrl(`/api/recommendations/${id}/stream`);
|
||||
route(`/recom/${id}`);
|
||||
};
|
||||
|
||||
const handleRerank = async () => {
|
||||
if (!selectedId) return;
|
||||
await rerank(selectedId);
|
||||
setStages(DEFAULT_STAGES);
|
||||
setSseUrl(`/api/recommendations/${selectedId}/stream`);
|
||||
setSelectedRec((prev) => (prev ? { ...prev, status: 'pending' } : null));
|
||||
};
|
||||
|
||||
const isRunning =
|
||||
selectedRec?.status === 'running' || selectedRec?.status === 'pending' || !!sseUrl;
|
||||
|
||||
const feedbackMap = new Map(feedback.map((f) => [f.tv_show_name, f]));
|
||||
|
||||
return (
|
||||
<div class="layout">
|
||||
<div class="landing-layout">
|
||||
<div class="landing-bg" />
|
||||
<Sidebar
|
||||
list={list}
|
||||
selectedId={selectedId}
|
||||
selectedId={null}
|
||||
onSelect={handleSelect}
|
||||
onNewClick={() => setShowModal(true)}
|
||||
/>
|
||||
|
||||
<main class="main-content">
|
||||
{!selectedId && (
|
||||
<div class="empty-state">
|
||||
<h2>TV Show Recommender</h2>
|
||||
<p>Click <strong>+ New Recommendation</strong> to get started.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedId && isRunning && (
|
||||
<div class="content-area">
|
||||
<PipelineProgress stages={stages} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedId && !isRunning && selectedRec?.status === 'done' && selectedRec.recommendations && (
|
||||
<div class="content-area">
|
||||
<h2 class="rec-title">{selectedRec.title}</h2>
|
||||
|
||||
<div class="cards-grid">
|
||||
{selectedRec.recommendations.map((show) => (
|
||||
<RecommendationCard
|
||||
key={show.title}
|
||||
show={show}
|
||||
existingFeedback={feedbackMap.get(show.title)}
|
||||
onFeedback={async (name, stars, comment) => {
|
||||
await submitFeedback({ tv_show_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>
|
||||
)}
|
||||
|
||||
{selectedId && !isRunning && selectedRec?.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>
|
||||
)}
|
||||
<main class="landing-main">
|
||||
<h1 class="landing-title">Recommender</h1>
|
||||
<p class="landing-tagline">Discover your next favorite show, powered by AI.</p>
|
||||
<button class="btn-gradient" onClick={() => setShowModal(true)}>
|
||||
Get Started →
|
||||
</button>
|
||||
</main>
|
||||
|
||||
{showModal && (
|
||||
<NewRecommendationModal
|
||||
onClose={() => setShowModal(false)}
|
||||
|
||||
148
packages/frontend/src/pages/Recom.tsx
Normal file
148
packages/frontend/src/pages/Recom.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState, useCallback, useEffect } from 'preact/hooks';
|
||||
import { route } from 'preact-router';
|
||||
import { PipelineProgress } from '../components/PipelineProgress.js';
|
||||
import { RecommendationCard } from '../components/RecommendationCard.js';
|
||||
import { useRecommendations } from '../hooks/useRecommendations.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 { feedback, submitFeedback, rerank, updateStatus, refreshList } = useRecommendations();
|
||||
|
||||
const [rec, setRec] = useState<Recommendation | null>(null);
|
||||
const [stages, setStages] = useState<StageMap>(DEFAULT_STAGES);
|
||||
const [sseUrl, setSseUrl] = useState<string | null>(null);
|
||||
|
||||
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 isRunning = rec?.status === 'running' || rec?.status === 'pending' || !!sseUrl;
|
||||
const feedbackMap = new Map(feedback.map((f) => [f.tv_show_name, f]));
|
||||
|
||||
return (
|
||||
<div class="recom-page">
|
||||
<nav class="recom-nav">
|
||||
<a
|
||||
class="recom-back"
|
||||
href="/"
|
||||
onClick={(e) => { e.preventDefault(); route('/'); }}
|
||||
>
|
||||
← Recommender
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<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({ tv_show_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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user