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:
@@ -22,3 +22,4 @@ config/cookies.txt
|
||||
token.pickle
|
||||
config/client_secret.json
|
||||
config/token.pickle
|
||||
backend/sync_rotation.json
|
||||
|
||||
+1
-4
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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,6 +358,46 @@
|
||||
</button>
|
||||
{/each}
|
||||
</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}
|
||||
<div class="px-2 py-1.5 space-y-1" style="border-bottom:1px solid var(--border)">
|
||||
<select bind:value={channelFilter}
|
||||
@@ -341,6 +456,7 @@
|
||||
{/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)}
|
||||
@@ -353,16 +469,54 @@
|
||||
<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>
|
||||
<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}
|
||||
</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... 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>
|
||||
<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)">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>
|
||||
{:else if notes[selectedVideo.video_id]}
|
||||
<div class="p-2 rounded border text-xs leading-relaxed cursor-pointer"
|
||||
|
||||
Reference in New Issue
Block a user