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
config/client_secret.json
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 \
&& 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
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 --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
)
""")
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()
async def get_db():
+144 -7
View File
@@ -1,4 +1,5 @@
import os
import json
import logging
from fastapi import FastAPI, BackgroundTasks, Depends, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
@@ -16,7 +17,6 @@ log = logging.getLogger(__name__)
app = FastAPI(title="T YouTube", description="Text-based YouTube Summarizer")
# Enable CORS for development
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -28,7 +28,20 @@ app.add_middleware(
class StatusUpdate(BaseModel):
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")
async def startup():
await init_db()
@@ -39,7 +52,6 @@ async def health():
@app.get("/auto_categories.json")
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")
if os.path.exists(path):
with open(path) as f:
@@ -93,7 +105,6 @@ async def trigger_summarize(video_id: str, background_tasks: BackgroundTasks, db
exists = await cursor.fetchone()
if not exists:
raise HTTPException(status_code=404, detail="Video not found")
background_tasks.add_task(summarize_video, video_id)
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"]:
raise HTTPException(status_code=400, detail="Invalid status value")
cursor = await db.execute("SELECT 1 FROM videos WHERE video_id = ?", (video_id,))
exists = await cursor.fetchone()
if not exists:
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.commit()
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")
if os.path.exists(frontend_build_path):
app.mount("/", StaticFiles(directory=frontend_build_path, html=True), name="frontend")
-1
View File
@@ -1,6 +1,5 @@
fastapi>=0.100.0
uvicorn>=0.20.0
yt-dlp>=2023.0.0
youtube-transcript-api>=0.6.0
aiosqlite>=0.18.0
httpx>=0.24.0
+269 -105
View File
@@ -8,16 +8,20 @@
let isSyncing = $state(false);
let syncMessage = $state('');
let pendingCount = $state(0);
let nextSync = $state(localStorage.getItem('t-yt-next-sync') || '');
let theme = $state(localStorage.getItem('theme') || 'dark');
let summaryPanel = $state(null);
let notes = $state(JSON.parse(localStorage.getItem('t-yt-notes') || '{}'));
let notes = $state({});
let activeNote = $state(null);
let queueToAgent = $state(false);
let showHelp = $state(false);
let channelFilter = $state('');
let allChannels = $state([]);
let editingCategories = $state(false);
// Agent queue state
let agentItems = $state([]);
let agentLoading = $state(false);
$effect(() => {
if (activeTab === 'pending' && channelFilter) {
loadVideos();
@@ -41,6 +45,8 @@
);
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() {
theme = theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', theme);
@@ -51,11 +57,11 @@
isLoading = true;
try {
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`);
if (res.ok) {
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];
else selectedVideo = null;
}
@@ -103,7 +109,6 @@
if (!silent) {
videos = videos.filter(v => v.video_id !== videoId);
if (selectedVideo?.video_id === videoId) selectedVideo = videos[0] || null;
// Reload when list gets short
if (videos.length < 20) loadVideos();
}
}
@@ -114,11 +119,78 @@
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 (selectedVideo && summaryPanel) {
tick().then(() => summaryPanel.scrollTop = 0);
// Auto-mark as read after 3s of viewing a summarized video
const vid = selectedVideo.video_id;
const hasSummary = selectedVideo.summary && !selectedVideo.summary.includes('Error') && !selectedVideo.summary.includes('📺 Short clip');
if (hasSummary && activeTab === 'pending') {
@@ -127,9 +199,17 @@
}
}
});
onMount(() => {
$effect(() => {
if (activeTab === 'agent') loadAgentQueue();
});
onMount(async () => {
document.documentElement.className = theme;
loadVideos();
await loadNotes();
// Migrate localStorage notes to server (non-blocking)
migrateLocalNotes();
await loadVideos();
loaded = true;
loadPendingCount();
updateSyncTimer();
loadChannels();
@@ -142,7 +222,7 @@
if (!el) return;
const now = new Date();
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);
const diff = next - now;
const h = Math.floor(diff / 3600000);
@@ -194,8 +274,7 @@
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeNote && selectedVideo && activeNote === selectedVideo.video_id) {
localStorage.setItem('t-yt-notes', JSON.stringify(notes));
activeNote = null;
saveNote();
} else if (selectedVideo) {
updateStatus(selectedVideo.video_id, 'read');
}
@@ -220,20 +299,6 @@
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) {
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>');
@@ -245,10 +310,19 @@
}).join('');
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>
<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">
<div class="flex items-center space-x-3">
<span class="text-lg font-black tracking-wider" style="color:var(--accent)">T YouTube</span>
@@ -272,10 +346,11 @@
</header>
<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">
<!-- Tabs -->
<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}
class="flex-1 py-1.5 text-center text-[11px] font-bold rounded uppercase transition"
style="color: {activeTab === tab ? 'var(--accent)' : 'var(--text-muted)'}">
@@ -283,86 +358,165 @@
</button>
{/each}
</div>
{#if (allChannels.length || channels.length) > 1}
<div class="px-2 py-1.5 space-y-1" style="border-bottom:1px solid var(--border)">
<select bind:value={channelFilter}
class="w-full text-[11px] p-1.5 rounded border bg-transparent"
style="color:var(--text-primary);border-color:var(--border);background:var(--bg-tertiary)">
<option value="">All Channels</option>
{#if catList.length}
<option disabled>── Categories ──</option>
{#each catList as cat}
<option value={'📁 ' + cat}>📁 {cat}</option>
{/each}
<option disabled>── Channels ──</option>
{/if}
{#each allChannels.length ? allChannels.map(c => c.channel_name) : channels as ch}
<option value={ch}>{ch}</option>
{/each}
</select>
<button onclick={() => editingCategories = !editingCategories}
class="w-full text-[10px] py-1 rounded border transition text-center"
style="color:var(--text-muted);border-color:var(--border)">
{editingCategories ? 'Done' : '✎ Edit Categories'}
</button>
<button onclick={async () => {
const res = await fetch('/auto_categories.json');
if (res.ok) {
const data = await res.json();
for (const [k, v] of Object.entries(data)) categories[k] = v;
localStorage.setItem('t-yt-categories', JSON.stringify(categories));
loadChannels();
}
}}
class="w-full text-[10px] py-1 rounded border transition text-center"
style="color:var(--text-muted);border-color:var(--border)">
↻ Reset to Auto
</button>
</div>
{/if}
{#if editingCategories}
<div class="overflow-y-auto max-h-48 p-2 space-y-1" style="border-bottom:1px solid var(--border);background:var(--bg-tertiary)">
<div class="text-[10px] pb-1" style="color:var(--text-muted)">Type category name next to each channel:</div>
<input class="w-full text-[11px] p-1 rounded border mb-1" style="background:var(--bg-primary);color:var(--text-primary);border-color:var(--border)"
placeholder="New category name..." id="new-cat-input"
onkeydown={e => { if (e.key === 'Enter') { const v = e.target.value.trim(); if (v) { categories[v] = []; localStorage.setItem('t-yt-categories', JSON.stringify(categories)); e.target.value = ''; } } }}>
{#each allChannels as ch}
<div class="flex items-center space-x-2 text-[11px]">
<span class="flex-1 truncate" style="color:var(--text-primary)">{ch.channel_name}</span>
<input class="w-20 text-[10px] p-0.5 rounded border text-center"
style="background:var(--bg-primary);color:var(--text-primary);border-color:var(--border)"
placeholder="cat"
value={categories[ch.channel_name] || ''}
oninput={e => {
categories[ch.channel_name] = e.target.value;
localStorage.setItem('t-yt-categories', JSON.stringify(categories));
}}>
<!-- 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}
<div class="px-2 py-1.5 space-y-1" style="border-bottom:1px solid var(--border)">
<select bind:value={channelFilter}
class="w-full text-[11px] p-1.5 rounded border bg-transparent"
style="color:var(--text-primary);border-color:var(--border);background:var(--bg-tertiary)">
<option value="">All Channels</option>
{#if catList.length}
<option disabled>── Categories ──</option>
{#each catList as cat}
<option value={'📁 ' + cat}>📁 {cat}</option>
{/each}
<option disabled>── Channels ──</option>
{/if}
{#each allChannels.length ? allChannels.map(c => c.channel_name) : channels as ch}
<option value={ch}>{ch}</option>
{/each}
</select>
<button onclick={() => editingCategories = !editingCategories}
class="w-full text-[10px] py-1 rounded border transition text-center"
style="color:var(--text-muted);border-color:var(--border)">
{editingCategories ? 'Done' : '✎ Edit Categories'}
</button>
<button onclick={async () => {
const res = await fetch('/auto_categories.json');
if (res.ok) {
const data = await res.json();
for (const [k, v] of Object.entries(data)) categories[k] = v;
localStorage.setItem('t-yt-categories', JSON.stringify(categories));
loadChannels();
}
}}
class="w-full text-[10px] py-1 rounded border transition text-center"
style="color:var(--text-muted);border-color:var(--border)">
↻ Reset to Auto
</button>
</div>
{/if}
{#if editingCategories}
<div class="overflow-y-auto max-h-48 p-2 space-y-1" style="border-bottom:1px solid var(--border);background:var(--bg-tertiary)">
<div class="text-[10px] pb-1" style="color:var(--text-muted)">Type category name next to each channel:</div>
<input class="w-full text-[11px] p-1 rounded border mb-1" style="background:var(--bg-primary);color:var(--text-primary);border-color:var(--border)"
placeholder="New category name..." id="new-cat-input"
onkeydown={e => { if (e.key === 'Enter') { const v = e.target.value.trim(); if (v) { categories[v] = []; localStorage.setItem('t-yt-categories', JSON.stringify(categories)); e.target.value = ''; } } }}>
{#each allChannels as ch}
<div class="flex items-center space-x-2 text-[11px]">
<span class="flex-1 truncate" style="color:var(--text-primary)">{ch.channel_name}</span>
<input class="w-20 text-[10px] p-0.5 rounded border text-center"
style="background:var(--bg-primary);color:var(--text-primary);border-color:var(--border)"
placeholder="cat"
value={categories[ch.channel_name] || ''}
oninput={e => {
categories[ch.channel_name] = e.target.value;
localStorage.setItem('t-yt-categories', JSON.stringify(categories));
}}>
</div>
{/each}
</div>
{/if}
<!-- Video list -->
<div class="flex-1 overflow-y-auto divide-y" style="border-color:var(--border)">
{#each filteredVideos as video (video.video_id)}
{@const i = filteredVideos.indexOf(video)}
<button onclick={() => { selectedVideo = video; }}
data-video-index={i}
class="w-full text-left p-3 transition flex items-start space-x-3 outline-none focus:outline-none"
style="border-left:2px solid {selectedVideo?.video_id === video.video_id ? 'var(--accent)' : 'transparent'};
background:{selectedVideo?.video_id === video.video_id ? 'var(--bg-tertiary)' : 'transparent'}">
<img src={video.thumbnail_url} alt="" class="w-20 h-12 rounded object-cover flex-shrink-0" style="background:var(--bg-tertiary)" />
<div class="min-w-0">
<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>
<span class="text-[10px] mt-0.5 block" style="color:var(--text-muted)">{video.publish_date || ''} {noteIds.has(video.video_id) ? '📝' : ''}</span>
</div>
</button>
{/each}
</div>
{/if}
<div class="flex-1 overflow-y-auto divide-y" style="border-color:var(--border)">
{#each filteredVideos as video (video.video_id)}
{@const i = filteredVideos.indexOf(video)}
<button onclick={() => { selectedVideo = video; }}
data-video-index={i}
class="w-full text-left p-3 transition flex items-start space-x-3 outline-none focus:outline-none"
style="border-left:2px solid {selectedVideo?.video_id === video.video_id ? 'var(--accent)' : 'transparent'};
background:{selectedVideo?.video_id === video.video_id ? 'var(--bg-tertiary)' : 'transparent'}">
<img src={video.thumbnail_url} alt="" class="w-20 h-12 rounded object-cover flex-shrink-0" style="background:var(--bg-tertiary)" />
<div class="min-w-0">
<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>
<span class="text-[10px] mt-0.5 block" style="color:var(--text-muted)">{video.publish_date || ''} {notes[video.video_id] ? '📝' : ''}</span>
</div>
</button>
{/each}
</div>
</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">
{#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">
<!-- Video header -->
<div class="flex items-start space-x-4">
@@ -387,16 +541,26 @@
<!-- Note editor/viewer -->
{#if activeNote === selectedVideo.video_id}
<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)"
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] || ''}
oninput={e => notes[selectedVideo.video_id] = e.target.value}
use:autoFocus
onkeydown={e => { if (e.key === 'Escape') { saveNote(); } else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(); } }}
></textarea>
<button onclick={saveNote}
class="text-[10px] px-3 py-1 text-white font-bold rounded transition" style="background:var(--accent)">Save</button>
<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}
class="text-[10px] px-3 py-1 text-white font-bold rounded transition" style="background:var(--accent)">
{queueToAgent ? 'Save + 🤖 Queue' : 'Save'}
</button>
</div>
</div>
{:else if notes[selectedVideo.video_id]}
<div class="p-2 rounded border text-xs leading-relaxed cursor-pointer"