feat: notes + agent queue, frontend rebuild, pip mirror, gitignore sync_rotation.json

- Add video_notes table and agent_queue table for persistent notes and AI agent tasks
- Add API endpoints: POST/GET /api/notes/{id}, POST /api/notes/migrate, agent queue endpoints
- Rebuild frontend: fix duplicate loadVideos(), non-blocking localStorage migration, notes UI
- Switch pip install to Tsinghua mirror for faster downloads in China
- Add backend/sync_rotation.json to .gitignore (runtime state)
This commit is contained in:
Gan, Jimmy
2026-07-08 02:23:00 +08:00
parent 77c6a9d963
commit a154bc7978
6 changed files with 434 additions and 117 deletions
+1
View File
@@ -22,3 +22,4 @@ config/cookies.txt
token.pickle token.pickle
config/client_secret.json config/client_secret.json
config/token.pickle config/token.pickle
backend/sync_rotation.json
+1 -4
View File
@@ -16,13 +16,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \ ffmpeg \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Download latest official yt-dlp to ensure subscription scrapers do not break
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \
chmod a+rx /usr/local/bin/yt-dlp
# Install python packages # Install python packages
COPY backend/requirements.txt ./ COPY backend/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
# Copy built static frontend # Copy built static frontend
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
+19
View File
@@ -23,6 +23,25 @@ async def init_db():
created_at TEXT DEFAULT CURRENT_TIMESTAMP created_at TEXT DEFAULT CURRENT_TIMESTAMP
) )
""") """)
await db.execute("""
CREATE TABLE IF NOT EXISTS video_notes (
video_id TEXT PRIMARY KEY,
note_text TEXT NOT NULL,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (video_id) REFERENCES videos(video_id)
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS agent_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_id TEXT NOT NULL,
user_prompt TEXT NOT NULL,
status TEXT DEFAULT 'pending',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
processed_at TEXT,
FOREIGN KEY (video_id) REFERENCES videos(video_id)
)
""")
await db.commit() await db.commit()
async def get_db(): async def get_db():
+144 -7
View File
@@ -1,4 +1,5 @@
import os import os
import json
import logging import logging
from fastapi import FastAPI, BackgroundTasks, Depends, HTTPException, Query from fastapi import FastAPI, BackgroundTasks, Depends, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -16,7 +17,6 @@ log = logging.getLogger(__name__)
app = FastAPI(title="T YouTube", description="Text-based YouTube Summarizer") app = FastAPI(title="T YouTube", description="Text-based YouTube Summarizer")
# Enable CORS for development
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
@@ -28,7 +28,20 @@ app.add_middleware(
class StatusUpdate(BaseModel): class StatusUpdate(BaseModel):
status: str status: str
# Database Startup Hook class NoteUpdate(BaseModel):
note_text: str
queue_to_agent: bool = False
class NoteMigrate(BaseModel):
notes: dict
class AgentQueueItem(BaseModel):
video_id: str
user_prompt: str
class AgentQueueUpdate(BaseModel):
status: str
@app.on_event("startup") @app.on_event("startup")
async def startup(): async def startup():
await init_db() await init_db()
@@ -39,7 +52,6 @@ async def health():
@app.get("/auto_categories.json") @app.get("/auto_categories.json")
async def auto_categories(): async def auto_categories():
import json, os
path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend", "src", "auto_categories.json") path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend", "src", "auto_categories.json")
if os.path.exists(path): if os.path.exists(path):
with open(path) as f: with open(path) as f:
@@ -93,7 +105,6 @@ async def trigger_summarize(video_id: str, background_tasks: BackgroundTasks, db
exists = await cursor.fetchone() exists = await cursor.fetchone()
if not exists: if not exists:
raise HTTPException(status_code=404, detail="Video not found") raise HTTPException(status_code=404, detail="Video not found")
background_tasks.add_task(summarize_video, video_id) background_tasks.add_task(summarize_video, video_id)
return {"message": f"Summarization for video {video_id} queued."} return {"message": f"Summarization for video {video_id} queued."}
@@ -105,17 +116,143 @@ async def update_status(
): ):
if update.status not in ["pending", "read", "skipped"]: if update.status not in ["pending", "read", "skipped"]:
raise HTTPException(status_code=400, detail="Invalid status value") raise HTTPException(status_code=400, detail="Invalid status value")
cursor = await db.execute("SELECT 1 FROM videos WHERE video_id = ?", (video_id,)) cursor = await db.execute("SELECT 1 FROM videos WHERE video_id = ?", (video_id,))
exists = await cursor.fetchone() exists = await cursor.fetchone()
if not exists: if not exists:
raise HTTPException(status_code=404, detail="Video not found") raise HTTPException(status_code=404, detail="Video not found")
await db.execute("UPDATE videos SET status = ? WHERE video_id = ?", (update.status, video_id)) await db.execute("UPDATE videos SET status = ? WHERE video_id = ?", (update.status, video_id))
await db.commit() await db.commit()
return {"message": f"Video {video_id} status updated to {update.status}."} return {"message": f"Video {video_id} status updated to {update.status}."}
# Mount frontend built files for production if they exist # ---- Notes (server-side, replaces localStorage) ----
@app.get("/api/notes")
async def list_notes(db: aiosqlite.Connection = Depends(get_db)):
cursor = await db.execute("SELECT video_id, note_text, updated_at FROM video_notes ORDER BY updated_at DESC")
rows = await cursor.fetchall()
return [dict(row) for row in rows]
@app.get("/api/notes/{video_id}")
async def get_note(video_id: str, db: aiosqlite.Connection = Depends(get_db)):
cursor = await db.execute("SELECT note_text, updated_at FROM video_notes WHERE video_id = ?", (video_id,))
row = await cursor.fetchone()
if not row:
return {"note_text": "", "updated_at": None}
return dict(row)
@app.post("/api/notes/{video_id}")
async def save_note(video_id: str, update: NoteUpdate, db: aiosqlite.Connection = Depends(get_db)):
await db.execute("""
INSERT INTO video_notes (video_id, note_text, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(video_id) DO UPDATE SET
note_text = excluded.note_text,
updated_at = CURRENT_TIMESTAMP
""", (video_id, update.note_text))
await db.commit()
if update.queue_to_agent and update.note_text.strip():
await db.execute("""
INSERT INTO agent_queue (video_id, user_prompt, status)
VALUES (?, ?, 'pending')
""", (video_id, update.note_text.strip()))
await db.commit()
log.info(f"Queued video {video_id} for agent: {update.note_text[:80]}")
return {"message": "Note saved"}
@app.post("/api/notes/migrate")
async def migrate_notes(migration: NoteMigrate, db: aiosqlite.Connection = Depends(get_db)):
count = 0
for video_id, note_text in migration.notes.items():
if not note_text.strip():
continue
await db.execute("""
INSERT INTO video_notes (video_id, note_text, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(video_id) DO UPDATE SET
note_text = excluded.note_text,
updated_at = CURRENT_TIMESTAMP
""", (video_id, note_text))
count += 1
await db.commit()
return {"message": f"{count} notes migrated"}
# ---- Agent Queue ----
@app.get("/api/agent-queue")
async def list_agent_queue(
status: Optional[str] = None,
limit: int = 50,
db: aiosqlite.Connection = Depends(get_db)
):
if status:
query = """
SELECT q.id, q.video_id, q.user_prompt, q.status, q.created_at, q.processed_at,
v.title, v.channel_name, v.url, v.summary
FROM agent_queue q
LEFT JOIN videos v ON q.video_id = v.video_id
WHERE q.status = ?
ORDER BY q.created_at DESC LIMIT ?
"""
cursor = await db.execute(query, (status, limit))
else:
query = """
SELECT q.id, q.video_id, q.user_prompt, q.status, q.created_at, q.processed_at,
v.title, v.channel_name, v.url, v.summary
FROM agent_queue q
LEFT JOIN videos v ON q.video_id = v.video_id
ORDER BY q.created_at DESC LIMIT ?
"""
cursor = await db.execute(query, (limit,))
rows = await cursor.fetchall()
return [dict(row) for row in rows]
@app.post("/api/agent-queue")
async def add_to_agent_queue(item: AgentQueueItem, db: aiosqlite.Connection = Depends(get_db)):
cursor = await db.execute("SELECT 1 FROM videos WHERE video_id = ?", (item.video_id,))
exists = await cursor.fetchone()
if not exists:
raise HTTPException(status_code=404, detail="Video not found")
await db.execute("""
INSERT INTO agent_queue (video_id, user_prompt, status)
VALUES (?, ?, 'pending')
""", (item.video_id, item.user_prompt))
await db.commit()
return {"message": "Item queued for agent"}
@app.patch("/api/agent-queue/{item_id}")
async def update_agent_queue(item_id: int, update: AgentQueueUpdate, db: aiosqlite.Connection = Depends(get_db)):
valid_statuses = ["pending", "processing", "done", "failed"]
if update.status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}")
cursor = await db.execute("SELECT 1 FROM agent_queue WHERE id = ?", (item_id,))
exists = await cursor.fetchone()
if not exists:
raise HTTPException(status_code=404, detail="Queue item not found")
if update.status in ("done", "failed"):
await db.execute("""
UPDATE agent_queue SET status = ?, processed_at = CURRENT_TIMESTAMP WHERE id = ?
""", (update.status, item_id))
else:
await db.execute("UPDATE agent_queue SET status = ? WHERE id = ?", (update.status, item_id))
await db.commit()
return {"message": f"Queue item {item_id} updated to {update.status}"}
@app.delete("/api/agent-queue/{item_id}")
async def remove_from_agent_queue(item_id: int, db: aiosqlite.Connection = Depends(get_db)):
cursor = await db.execute("SELECT 1 FROM agent_queue WHERE id = ?", (item_id,))
exists = await cursor.fetchone()
if not exists:
raise HTTPException(status_code=404, detail="Queue item not found")
await db.execute("DELETE FROM agent_queue WHERE id = ?", (item_id,))
await db.commit()
return {"message": f"Queue item {item_id} removed"}
# ---- Static frontend ----
frontend_build_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend/dist") frontend_build_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend/dist")
if os.path.exists(frontend_build_path): if os.path.exists(frontend_build_path):
app.mount("/", StaticFiles(directory=frontend_build_path, html=True), name="frontend") app.mount("/", StaticFiles(directory=frontend_build_path, html=True), name="frontend")
-1
View File
@@ -1,6 +1,5 @@
fastapi>=0.100.0 fastapi>=0.100.0
uvicorn>=0.20.0 uvicorn>=0.20.0
yt-dlp>=2023.0.0
youtube-transcript-api>=0.6.0 youtube-transcript-api>=0.6.0
aiosqlite>=0.18.0 aiosqlite>=0.18.0
httpx>=0.24.0 httpx>=0.24.0
+198 -34
View File
@@ -8,16 +8,20 @@
let isSyncing = $state(false); let isSyncing = $state(false);
let syncMessage = $state(''); let syncMessage = $state('');
let pendingCount = $state(0); let pendingCount = $state(0);
let nextSync = $state(localStorage.getItem('t-yt-next-sync') || '');
let theme = $state(localStorage.getItem('theme') || 'dark'); let theme = $state(localStorage.getItem('theme') || 'dark');
let summaryPanel = $state(null); let summaryPanel = $state(null);
let notes = $state(JSON.parse(localStorage.getItem('t-yt-notes') || '{}')); let notes = $state({});
let activeNote = $state(null); let activeNote = $state(null);
let queueToAgent = $state(false);
let showHelp = $state(false); let showHelp = $state(false);
let channelFilter = $state(''); let channelFilter = $state('');
let allChannels = $state([]); let allChannels = $state([]);
let editingCategories = $state(false); let editingCategories = $state(false);
// Agent queue state
let agentItems = $state([]);
let agentLoading = $state(false);
$effect(() => { $effect(() => {
if (activeTab === 'pending' && channelFilter) { if (activeTab === 'pending' && channelFilter) {
loadVideos(); loadVideos();
@@ -41,6 +45,8 @@
); );
let catList = $derived([...new Set(Object.values(categories).flat())].sort()); let catList = $derived([...new Set(Object.values(categories).flat())].sort());
let noteIds = $derived(new Set(Object.keys(notes).filter(k => notes[k] && notes[k].trim())));
function toggleTheme() { function toggleTheme() {
theme = theme === 'dark' ? 'light' : 'dark'; theme = theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', theme); localStorage.setItem('theme', theme);
@@ -51,11 +57,11 @@
isLoading = true; isLoading = true;
try { try {
if (activeTab === 'notes') { if (activeTab === 'notes') {
// Notes tab: show all pending videos, but only those with notes // Notes tab: show only pending videos that have notes
const res = await fetch(`/api/videos?status=pending&limit=50`); const res = await fetch(`/api/videos?status=pending&limit=50`);
if (res.ok) { if (res.ok) {
const all = await res.json(); const all = await res.json();
videos = all.filter(v => notes[v.video_id]); videos = all.filter(v => noteIds.has(v.video_id));
if (videos.length > 0) selectedVideo = videos[0]; if (videos.length > 0) selectedVideo = videos[0];
else selectedVideo = null; else selectedVideo = null;
} }
@@ -103,7 +109,6 @@
if (!silent) { if (!silent) {
videos = videos.filter(v => v.video_id !== videoId); videos = videos.filter(v => v.video_id !== videoId);
if (selectedVideo?.video_id === videoId) selectedVideo = videos[0] || null; if (selectedVideo?.video_id === videoId) selectedVideo = videos[0] || null;
// Reload when list gets short
if (videos.length < 20) loadVideos(); if (videos.length < 20) loadVideos();
} }
} }
@@ -114,11 +119,78 @@
setTimeout(() => loadVideos(), 3000); setTimeout(() => loadVideos(), 3000);
} }
// ---- Server-side notes ----
async function loadNotes() {
try {
const res = await fetch('/api/notes');
if (res.ok) {
const all = await res.json();
const map = {};
for (const item of all) map[item.video_id] = item.note_text;
notes = map;
}
} catch(e) { console.error('Failed to load notes:', e); }
}
async function migrateLocalNotes() {
try {
const local = JSON.parse(localStorage.getItem('t-yt-notes') || '{}');
const keys = Object.keys(local).filter(k => local[k].trim());
if (keys.length === 0) return;
const missing = keys.filter(k => !notes[k]);
if (missing.length === 0) return;
const payload = {};
for (const k of missing) payload[k] = local[k];
await fetch('/api/notes/migrate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notes: payload })
});
await loadNotes();
} catch(e) {}
}
async function saveNote() {
const vid = selectedVideo?.video_id;
if (!vid) return;
const textarea = document.querySelector('#note-textarea');
const current = textarea ? textarea.value : (notes[vid] || '');
if (!current.trim()) return;
// Save to server
await fetch(`/api/notes/${vid}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note_text: current, queue_to_agent: queueToAgent })
});
// Update local state
notes = {...notes, [vid]: current};
activeNote = null;
queueToAgent = false;
}
// ---- Agent queue ----
async function loadAgentQueue() {
agentLoading = true;
try {
const res = await fetch('/api/agent-queue');
if (res.ok) agentItems = await res.json();
} catch(e) { console.error('Failed to load agent queue:', e); }
finally { agentLoading = false; }
}
async function removeAgentItem(id) {
await fetch(`/api/agent-queue/${id}`, { method: 'DELETE' });
agentItems = agentItems.filter(i => i.id !== id);
}
// ---- Lifecycle ----
$effect(() => { if (activeTab) loadVideos(); }); $effect(() => { if (activeTab) loadVideos(); });
$effect(() => { $effect(() => {
if (selectedVideo && summaryPanel) { if (selectedVideo && summaryPanel) {
tick().then(() => summaryPanel.scrollTop = 0); tick().then(() => summaryPanel.scrollTop = 0);
// Auto-mark as read after 3s of viewing a summarized video
const vid = selectedVideo.video_id; const vid = selectedVideo.video_id;
const hasSummary = selectedVideo.summary && !selectedVideo.summary.includes('Error') && !selectedVideo.summary.includes('📺 Short clip'); const hasSummary = selectedVideo.summary && !selectedVideo.summary.includes('Error') && !selectedVideo.summary.includes('📺 Short clip');
if (hasSummary && activeTab === 'pending') { if (hasSummary && activeTab === 'pending') {
@@ -127,9 +199,17 @@
} }
} }
}); });
onMount(() => { $effect(() => {
if (activeTab === 'agent') loadAgentQueue();
});
onMount(async () => {
document.documentElement.className = theme; document.documentElement.className = theme;
loadVideos(); await loadNotes();
// Migrate localStorage notes to server (non-blocking)
migrateLocalNotes();
await loadVideos();
loaded = true;
loadPendingCount(); loadPendingCount();
updateSyncTimer(); updateSyncTimer();
loadChannels(); loadChannels();
@@ -142,7 +222,7 @@
if (!el) return; if (!el) return;
const now = new Date(); const now = new Date();
const next = new Date(now); const next = new Date(now);
next.setHours(3, 0, 0, 0); // 3 AM daily next.setHours(3, 0, 0, 0);
if (now > next) next.setDate(next.getDate() + 1); if (now > next) next.setDate(next.getDate() + 1);
const diff = next - now; const diff = next - now;
const h = Math.floor(diff / 3600000); const h = Math.floor(diff / 3600000);
@@ -194,8 +274,7 @@
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
if (activeNote && selectedVideo && activeNote === selectedVideo.video_id) { if (activeNote && selectedVideo && activeNote === selectedVideo.video_id) {
localStorage.setItem('t-yt-notes', JSON.stringify(notes)); saveNote();
activeNote = null;
} else if (selectedVideo) { } else if (selectedVideo) {
updateStatus(selectedVideo.video_id, 'read'); updateStatus(selectedVideo.video_id, 'read');
} }
@@ -220,20 +299,6 @@
requestAnimationFrame(() => node.focus()); requestAnimationFrame(() => node.focus());
} }
function saveNote() {
const vid = selectedVideo?.video_id;
if (!vid) return;
const prev = notes[vid] || '';
const textarea = document.querySelector('#note-textarea');
const current = textarea ? textarea.value : prev;
if (!current.trim()) return;
const ts = new Date().toLocaleString();
const stamped = `[${ts}] ${current.trim()}\n${prev}`;
notes = {...notes, [vid]: stamped};
localStorage.setItem('t-yt-notes', JSON.stringify(notes));
activeNote = null;
}
function formatMarkdown(text) { function formatMarkdown(text) {
if (!text) return '<p class="opacity-50 italic">No summary yet.</p>'; if (!text) return '<p class="opacity-50 italic">No summary yet.</p>';
let html = text.replace(/\*\*(.*?)\*\*/g, '<strong class="font-bold" style="color:var(--accent)">$1</strong>'); let html = text.replace(/\*\*(.*?)\*\*/g, '<strong class="font-bold" style="color:var(--accent)">$1</strong>');
@@ -245,10 +310,19 @@
}).join(''); }).join('');
return html; return html;
} }
function noteWordCount(noteText) {
if (!noteText) return 0;
return noteText.trim().split(/\s+/).length;
}
function formatDate(d) {
if (!d) return '';
return d.slice(0, 10);
}
</script> </script>
<div style="background:var(--bg-primary);color:var(--text-primary);height:100vh;overflow:hidden;display:flex;flex-direction:column"> <div style="background:var(--bg-primary);color:var(--text-primary);height:100vh;overflow:hidden;display:flex;flex-direction:column">
<!-- Top bar -->
<header style="background:var(--bg-secondary);border-bottom:1px solid var(--border);" class="flex items-center justify-between px-5 py-2.5"> <header style="background:var(--bg-secondary);border-bottom:1px solid var(--border);" class="flex items-center justify-between px-5 py-2.5">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<span class="text-lg font-black tracking-wider" style="color:var(--accent)">T YouTube</span> <span class="text-lg font-black tracking-wider" style="color:var(--accent)">T YouTube</span>
@@ -272,10 +346,11 @@
</header> </header>
<div class="flex flex-1 overflow-hidden"> <div class="flex flex-1 overflow-hidden">
<!-- Left: video list --> <!-- Left sidebar -->
<aside style="width:280px;background:var(--bg-secondary);border-right:1px solid var(--border)" class="flex flex-col flex-shrink-0"> <aside style="width:280px;background:var(--bg-secondary);border-right:1px solid var(--border)" class="flex flex-col flex-shrink-0">
<!-- Tabs -->
<div class="flex p-1.5" style="border-bottom:1px solid var(--border)"> <div class="flex p-1.5" style="border-bottom:1px solid var(--border)">
{#each ['pending','read','skipped','notes'] as tab} {#each ['pending','read','skipped','notes','🤖 Agent'] as tab}
<button onclick={() => activeTab = tab} <button onclick={() => activeTab = tab}
class="flex-1 py-1.5 text-center text-[11px] font-bold rounded uppercase transition" class="flex-1 py-1.5 text-center text-[11px] font-bold rounded uppercase transition"
style="color: {activeTab === tab ? 'var(--accent)' : 'var(--text-muted)'}"> style="color: {activeTab === tab ? 'var(--accent)' : 'var(--text-muted)'}">
@@ -283,6 +358,46 @@
</button> </button>
{/each} {/each}
</div> </div>
<!-- Agent Queue tab content -->
{#if activeTab === '🤖 Agent'}
<div class="flex-1 overflow-y-auto" style="border-color:var(--border)">
{#if agentLoading}
<div class="flex items-center justify-center py-8 text-xs" style="color:var(--text-muted)">Loading...</div>
{:else if agentItems.length === 0}
<div class="flex items-center justify-center py-8 text-xs" style="color:var(--text-muted)">
<p>No items queued.<br>Write a note on a video and check "Send to Agent" to add one.</p>
</div>
{:else}
{#each agentItems as item}
<div class="p-3 border-b" style="border-color:var(--border)">
<div class="flex items-start space-x-2">
<div class="min-w-0 flex-1">
<p class="text-[11px] font-bold leading-tight line-clamp-2" style="color:var(--text-primary)">{item.title || 'Unknown'}</p>
<p class="text-[10px] mt-1" style="color:var(--accent)">{item.channel_name || ''}</p>
<p class="text-[10px] mt-1" style="color:var(--text-muted)">Prompt: {item.user_prompt.slice(0, 120)}{item.user_prompt.length > 120 ? '...' : ''}</p>
<div class="flex items-center space-x-2 mt-1.5">
<span class="text-[9px] px-1.5 py-0.5 rounded font-bold uppercase"
style="background:{item.status === 'done' ? '#10b981' : item.status === 'processing' ? '#f59e0b' : item.status === 'failed' ? '#ef4444' : 'var(--bg-tertiary)'};color:white">
{item.status}
</span>
<span class="text-[9px]" style="color:var(--text-muted)">{formatDate(item.created_at)}</span>
</div>
{#if item.status === 'pending'}
<button onclick={() => removeAgentItem(item.id)}
class="text-[9px] mt-1 px-2 py-0.5 rounded border"
style="color:var(--text-muted);border-color:var(--border)">
Remove
</button>
{/if}
</div>
</div>
</div>
{/each}
{/if}
</div>
{:else}
<!-- Channel filter bar -->
{#if (allChannels.length || channels.length) > 1} {#if (allChannels.length || channels.length) > 1}
<div class="px-2 py-1.5 space-y-1" style="border-bottom:1px solid var(--border)"> <div class="px-2 py-1.5 space-y-1" style="border-bottom:1px solid var(--border)">
<select bind:value={channelFilter} <select bind:value={channelFilter}
@@ -341,6 +456,7 @@
{/each} {/each}
</div> </div>
{/if} {/if}
<!-- Video list -->
<div class="flex-1 overflow-y-auto divide-y" style="border-color:var(--border)"> <div class="flex-1 overflow-y-auto divide-y" style="border-color:var(--border)">
{#each filteredVideos as video (video.video_id)} {#each filteredVideos as video (video.video_id)}
{@const i = filteredVideos.indexOf(video)} {@const i = filteredVideos.indexOf(video)}
@@ -353,16 +469,54 @@
<div class="min-w-0"> <div class="min-w-0">
<span class="block text-[10px] font-semibold truncate" style="color:var(--accent)">{video.channel_name}</span> <span class="block text-[10px] font-semibold truncate" style="color:var(--accent)">{video.channel_name}</span>
<h3 class="text-xs font-bold line-clamp-2 leading-snug" style="color:var(--text-primary)">{video.title}</h3> <h3 class="text-xs font-bold line-clamp-2 leading-snug" style="color:var(--text-primary)">{video.title}</h3>
<span class="text-[10px] mt-0.5 block" style="color:var(--text-muted)">{video.publish_date || ''} {notes[video.video_id] ? '📝' : ''}</span> <span class="text-[10px] mt-0.5 block" style="color:var(--text-muted)">{video.publish_date || ''} {noteIds.has(video.video_id) ? '📝' : ''}</span>
</div> </div>
</button> </button>
{/each} {/each}
</div> </div>
{/if}
</aside> </aside>
<!-- Right: summary PRIMARY --> <!-- Right: summary panel -->
<main bind:this={summaryPanel} id="summary-panel" class="flex-1 overflow-y-auto" style="background:var(--bg-primary);min-height:0"> <main bind:this={summaryPanel} id="summary-panel" class="flex-1 overflow-y-auto" style="background:var(--bg-primary);min-height:0">
{#if selectedVideo} {#if activeTab === '🤖 Agent'}
<div class="p-6 max-w-3xl mx-auto">
<h2 class="text-sm font-black tracking-wider mb-4" style="color:var(--accent)">🤖 Agent Queue</h2>
{#if agentItems.length === 0}
<p class="text-xs opacity-50 italic">Queue empty. Watch a video, press <kbd class="px-1 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)">n</kbd> to write a note, check "Send to Agent", and save.</p>
{:else}
<div class="space-y-3">
{#each agentItems as item}
<div class="p-4 rounded-xl border" style="background:var(--card-bg);border-color:var(--border)">
<div class="flex items-start space-x-3">
<div class="min-w-0 flex-1">
<h3 class="text-sm font-bold" style="color:var(--text-primary)">{item.title || 'Unknown'}</h3>
<p class="text-[10px] mt-0.5" style="color:var(--accent)">{item.channel_name}</p>
<div class="mt-3 p-2 rounded text-xs leading-relaxed" style="background:var(--bg-tertiary)">
<span class="font-bold text-[10px]" style="color:var(--text-muted)">PROMPT:</span>
<p class="mt-1 whitespace-pre-wrap" style="color:var(--text-primary)">{item.user_prompt}</p>
</div>
<div class="flex items-center space-x-2 mt-2">
<span class="text-[9px] px-1.5 py-0.5 rounded font-bold uppercase"
style="background:{item.status === 'done' ? '#10b981' : item.status === 'processing' ? '#f59e0b' : item.status === 'failed' ? '#ef4444' : 'var(--bg-tertiary)'};color:white">
{item.status}
</span>
<span class="text-[9px]" style="color:var(--text-muted)">{formatDate(item.created_at)}</span>
<a href={item.url} target="_blank" rel="noopener noreferrer"
class="text-[9px] px-2 py-0.5 rounded border" style="color:var(--accent);border-color:var(--accent)">▶ Watch</a>
{#if item.status === 'pending'}
<button onclick={() => removeAgentItem(item.id)}
class="text-[9px] px-2 py-0.5 rounded border" style="color:var(--text-muted);border-color:var(--border)">Remove</button>
{/if}
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{:else if selectedVideo}
<div class="p-6 max-w-3xl mx-auto space-y-4"> <div class="p-6 max-w-3xl mx-auto space-y-4">
<!-- Video header --> <!-- Video header -->
<div class="flex items-start space-x-4"> <div class="flex items-start space-x-4">
@@ -387,16 +541,26 @@
<!-- Note editor/viewer --> <!-- Note editor/viewer -->
{#if activeNote === selectedVideo.video_id} {#if activeNote === selectedVideo.video_id}
<div class="space-y-1.5"> <div class="space-y-1.5">
<textarea id="note-textarea" rows="2" class="w-full text-xs p-2 rounded border resize-none" <textarea id="note-textarea" rows="3" class="w-full text-xs p-2 rounded border resize-none"
style="background:var(--bg-tertiary);color:var(--text-primary);border-color:var(--border)" style="background:var(--bg-tertiary);color:var(--text-primary);border-color:var(--border)"
placeholder="Quick note..." placeholder="Write instructions for the agent here...&#10;e.g. 'Add this gyroid lattice workflow as a Hermes skill'"
value={notes[selectedVideo.video_id] || ''} value={notes[selectedVideo.video_id] || ''}
oninput={e => notes[selectedVideo.video_id] = e.target.value} oninput={e => notes[selectedVideo.video_id] = e.target.value}
use:autoFocus use:autoFocus
onkeydown={e => { if (e.key === 'Escape') { saveNote(); } else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(); } }} onkeydown={e => { if (e.key === 'Escape') { saveNote(); } else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(); } }}
></textarea> ></textarea>
<div class="flex items-center space-x-3">
<label class="flex items-center space-x-1.5 text-[11px] cursor-pointer" style="color:var(--text-muted)">
<input type="checkbox" bind:checked={queueToAgent}
class="w-3 h-3 rounded" style="accent-color:var(--accent)">
<span>Send to Agent</span>
</label>
<div class="flex-1"></div>
<button onclick={saveNote} <button onclick={saveNote}
class="text-[10px] px-3 py-1 text-white font-bold rounded transition" style="background:var(--accent)">Save</button> class="text-[10px] px-3 py-1 text-white font-bold rounded transition" style="background:var(--accent)">
{queueToAgent ? 'Save + 🤖 Queue' : 'Save'}
</button>
</div>
</div> </div>
{:else if notes[selectedVideo.video_id]} {:else if notes[selectedVideo.video_id]}
<div class="p-2 rounded border text-xs leading-relaxed cursor-pointer" <div class="p-2 rounded border text-xs leading-relaxed cursor-pointer"