changess
This commit is contained in:
@@ -18,7 +18,7 @@ spec:
|
|||||||
image: git.ivanch.me/ivanch/recommender:latest
|
image: git.ivanch.me/ivanch/recommender:latest
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 80
|
||||||
env:
|
env:
|
||||||
- name: OPENAI_API_KEY
|
- name: OPENAI_API_KEY
|
||||||
valueFrom:
|
valueFrom:
|
||||||
@@ -48,7 +48,7 @@ spec:
|
|||||||
app: recommender
|
app: recommender
|
||||||
ports:
|
ports:
|
||||||
- port: 80
|
- port: 80
|
||||||
targetPort: 8080
|
targetPort: 80
|
||||||
---
|
---
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: Ingress
|
kind: Ingress
|
||||||
|
|||||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -3822,6 +3822,15 @@
|
|||||||
"url": "https://opencollective.com/preact"
|
"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": {
|
"node_modules/process-warning": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||||
@@ -4466,7 +4475,8 @@
|
|||||||
"packages/frontend": {
|
"packages/frontend": {
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"preact": "^10.29.0"
|
"preact": "^10.29.0",
|
||||||
|
"preact-router": "^4.1.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@preact/preset-vite": "^2.10.4",
|
"@preact/preset-vite": "^2.10.4",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineConfig } from 'drizzle-kit';
|
import { defineConfig } from 'drizzle-kit';
|
||||||
import * as dotenv from 'dotenv';
|
import * as dotenv from 'dotenv';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config({ path: ['.env.local', '.env'] });
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
schema: './src/db/schema.ts',
|
schema: './src/db/schema.ts',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import OpenAI from 'openai';
|
import OpenAI from 'openai';
|
||||||
import * as dotenv from 'dotenv';
|
import * as dotenv from 'dotenv';
|
||||||
dotenv.config();
|
dotenv.config({ path: ['.env.local', '.env'] });
|
||||||
|
|
||||||
export const openai = new OpenAI({
|
export const openai = new OpenAI({
|
||||||
apiKey: process.env.OPENAI_API_KEY,
|
apiKey: process.env.OPENAI_API_KEY,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { drizzle } from 'drizzle-orm/postgres-js';
|
|||||||
import postgres from 'postgres';
|
import postgres from 'postgres';
|
||||||
import * as dotenv from 'dotenv';
|
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';
|
const connectionString = process.env.DATABASE_URL || 'postgres://user:password@iris.haven:5432/recommender';
|
||||||
export const client = postgres(connectionString);
|
export const client = postgres(connectionString);
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import * as dotenv from 'dotenv';
|
|||||||
import recommendationsRoute from './routes/recommendations.js';
|
import recommendationsRoute from './routes/recommendations.js';
|
||||||
import feedbackRoute from './routes/feedback.js';
|
import feedbackRoute from './routes/feedback.js';
|
||||||
|
|
||||||
// Load .env first, then .env.local
|
// Load .env.local first, then .env.
|
||||||
// env vars set on the container take precedence over both files
|
// dotenv 17+ supports array of paths, where the first path has the highest priority.
|
||||||
dotenv.config();
|
// Env vars set on the container (system) will take precedence over both.
|
||||||
dotenv.config({ path: '.env.local', override: true });
|
dotenv.config({ path: ['.env.local', '.env'] });
|
||||||
|
|
||||||
const fastify = Fastify({ logger: true });
|
const fastify = Fastify({ logger: true });
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import { runRanking } from '../agents/ranking.js';
|
|||||||
import { runCurator } from '../agents/curator.js';
|
import { runCurator } from '../agents/curator.js';
|
||||||
import type { CuratorOutput, SSEEvent } from '../types/agents.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;
|
type RecommendationRecord = typeof recommendations.$inferSelect;
|
||||||
|
|
||||||
function log(recId: string, msg: string, data?: unknown) {
|
function log(recId: string, msg: string, data?: unknown) {
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"preact": "^10.29.0"
|
"preact": "^10.29.0",
|
||||||
|
"preact-router": "^4.1.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@preact/preset-vite": "^2.10.4",
|
"@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 { Home } from './pages/Home.js';
|
||||||
|
import { Recom } from './pages/Recom.js';
|
||||||
|
|
||||||
export function App() {
|
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;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -30,7 +32,9 @@
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body, #app {
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -144,10 +148,21 @@ html, body, #app {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-done { color: var(--green); }
|
.status-done {
|
||||||
.status-error { color: var(--red); }
|
color: var(--green);
|
||||||
.status-running { color: var(--accent); }
|
}
|
||||||
.status-pending { color: var(--text-dim); }
|
|
||||||
|
.status-error {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-running {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pending {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-item-title {
|
.sidebar-item-title {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -251,10 +266,21 @@ html, body, #app {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-done { color: var(--green); }
|
.stage-done {
|
||||||
.stage-error { color: var(--red); }
|
color: var(--green);
|
||||||
.stage-running { color: var(--accent); }
|
}
|
||||||
.stage-pending { color: var(--text-dim); }
|
|
||||||
|
.stage-error {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-running {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-pending {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -262,8 +288,13 @@ html, body, #app {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
from { transform: rotate(0deg); }
|
from {
|
||||||
to { transform: rotate(360deg); }
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pipeline-step-label {
|
.pipeline-step-label {
|
||||||
@@ -309,10 +340,25 @@ html, body, #app {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-green { background: rgba(34,197,94,0.15); color: #4ade80; }
|
.badge-green {
|
||||||
.badge-blue { background: rgba(59,130,246,0.15); color: #60a5fa; }
|
background: rgba(34, 197, 94, 0.15);
|
||||||
.badge-yellow{ background: rgba(234,179,8,0.15); color: #facc15; }
|
color: #4ade80;
|
||||||
.badge-red { background: rgba(239,68,68,0.15); color: #f87171; }
|
}
|
||||||
|
|
||||||
|
.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 {
|
.card-title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -556,3 +602,124 @@ html, body, #app {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding-top: 4px;
|
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 { Sidebar } from '../components/Sidebar.js';
|
||||||
import { NewRecommendationModal } from '../components/NewRecommendationModal.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 { 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() {
|
export function Home() {
|
||||||
const {
|
const { list, createNew } = useRecommendations();
|
||||||
list,
|
|
||||||
selectedId,
|
|
||||||
feedback,
|
|
||||||
setSelectedId,
|
|
||||||
createNew,
|
|
||||||
rerank,
|
|
||||||
submitFeedback,
|
|
||||||
updateStatus,
|
|
||||||
refreshList,
|
|
||||||
} = useRecommendations();
|
|
||||||
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
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
|
const handleSelect = (id: string) => route(`/recom/${id}`);
|
||||||
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 handleCreateNew = async (body: {
|
const handleCreateNew = async (body: {
|
||||||
main_prompt: string;
|
main_prompt: string;
|
||||||
@@ -100,87 +17,25 @@ export function Home() {
|
|||||||
themes: string;
|
themes: string;
|
||||||
}) => {
|
}) => {
|
||||||
const id = await createNew(body);
|
const id = await createNew(body);
|
||||||
setStages(DEFAULT_STAGES);
|
route(`/recom/${id}`);
|
||||||
setSseUrl(`/api/recommendations/${id}/stream`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div class="layout">
|
<div class="landing-layout">
|
||||||
|
<div class="landing-bg" />
|
||||||
<Sidebar
|
<Sidebar
|
||||||
list={list}
|
list={list}
|
||||||
selectedId={selectedId}
|
selectedId={null}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onNewClick={() => setShowModal(true)}
|
onNewClick={() => setShowModal(true)}
|
||||||
/>
|
/>
|
||||||
|
<main class="landing-main">
|
||||||
<main class="main-content">
|
<h1 class="landing-title">Recommender</h1>
|
||||||
{!selectedId && (
|
<p class="landing-tagline">Discover your next favorite show, powered by AI.</p>
|
||||||
<div class="empty-state">
|
<button class="btn-gradient" onClick={() => setShowModal(true)}>
|
||||||
<h2>TV Show Recommender</h2>
|
Get Started →
|
||||||
<p>Click <strong>+ New Recommendation</strong> to get started.</p>
|
</button>
|
||||||
</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>
|
</main>
|
||||||
|
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<NewRecommendationModal
|
<NewRecommendationModal
|
||||||
onClose={() => setShowModal(false)}
|
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