import { useEffect, useState, useDeferredValue, startTransition } from "react"; import { useRef } from "react"; import { fetchJson, fetchJsonCached, invalidateJsonCache, primeJsonCache, uploadFile } from "./api/client.js"; import LogsPanel from "./components/LogsPanel.jsx"; import OverviewPanel from "./components/OverviewPanel.jsx"; import SettingsPanel from "./components/SettingsPanel.jsx"; import TaskTable from "./components/TaskTable.jsx"; import TaskDetailCard from "./components/TaskDetailCard.jsx"; import { attentionLabel, currentStepLabel, summarizeAttention, summarizeDelivery, taskDisplayStatus, taskPrimaryActionLabel, } from "./lib/format.js"; const NAV_ITEMS = ["Overview", "Tasks", "Settings", "Logs"]; function buildTasksUrl(query) { const params = new URLSearchParams(); params.set("limit", String(query.limit || 24)); params.set("offset", String(query.offset || 0)); params.set("sort", String(query.sort || "updated_desc")); if (query.status) params.set("status", query.status); if (query.search) params.set("search", query.search); if (query.attention) params.set("attention", query.attention); if (query.delivery) params.set("delivery", query.delivery); return `/tasks?${params.toString()}`; } function parseHashState() { const raw = window.location.hash.replace(/^#/, ""); const [viewPart, queryPart = ""] = raw.split("?"); const params = new URLSearchParams(queryPart); return { view: NAV_ITEMS.includes(viewPart) ? viewPart : "Tasks", taskId: params.get("task") || "", }; } function syncHashState(view, taskId) { const params = new URLSearchParams(); if (taskId && view === "Tasks") params.set("task", taskId); const suffix = params.toString() ? `?${params.toString()}` : ""; window.history.replaceState(null, "", `#${view}${suffix}`); } function FocusQueue({ tasks, selectedTaskId, onSelectTask, onRunTask }) { const focusItems = tasks .filter((task) => ["manual_now", "retry_now", "waiting_retry"].includes(summarizeAttention(task))) .sort((a, b) => { const score = { manual_now: 0, retry_now: 1, waiting_retry: 2 }; const diff = score[summarizeAttention(a)] - score[summarizeAttention(b)]; if (diff !== 0) return diff; return String(b.updated_at).localeCompare(String(a.updated_at)); }) .slice(0, 6); if (!focusItems.length) return null; return (

Priority Queue

需要优先处理的任务

{focusItems.length} tasks
{focusItems.map((task) => (
))}
); } function TasksView({ tasks, taskTotal, taskQuery, selectedTaskId, onSelectTask, onRunTask, taskDetail, session, loading, detailLoading, actionBusy, selectedStepName, onSelectStep, onRetryStep, onResetStep, onBindFullVideo, onOpenSessionTask, onSessionMerge, onSessionRebind, onTaskQueryChange, }) { const deferredSearch = useDeferredValue(taskQuery.search); const filtered = tasks.filter((task) => { const haystack = `${task.id} ${task.title}`.toLowerCase(); if (deferredSearch && !haystack.includes(deferredSearch.toLowerCase())) return false; return true; }); const pageStart = taskTotal ? taskQuery.offset + 1 : 0; const pageEnd = taskQuery.offset + tasks.length; const canPrev = taskQuery.offset > 0; const canNext = taskQuery.offset + taskQuery.limit < taskTotal; return (

Tasks Workspace

Task Table

{loading ? "syncing..." : `${pageStart}-${pageEnd} / ${taskTotal}`}
onTaskQueryChange({ search: event.target.value, offset: 0 })} placeholder="搜索任务标题或 task id" />
); } export default function App() { const initialLocation = parseHashState(); const [view, setView] = useState(initialLocation.view); const [health, setHealth] = useState(false); const [doctorOk, setDoctorOk] = useState(false); const [tasks, setTasks] = useState([]); const [taskTotal, setTaskTotal] = useState(0); const [taskQuery, setTaskQuery] = useState({ search: "", status: "", attention: "", delivery: "", sort: "updated_desc", limit: 24, offset: 0, }); const [services, setServices] = useState({ items: [] }); const [scheduler, setScheduler] = useState(null); const [history, setHistory] = useState({ items: [] }); const [logs, setLogs] = useState({ items: [] }); const [selectedLogName, setSelectedLogName] = useState(""); const [logContent, setLogContent] = useState(null); const [filterCurrentTaskLogs, setFilterCurrentTaskLogs] = useState(false); const [autoRefreshLogs, setAutoRefreshLogs] = useState(false); const [settings, setSettings] = useState({}); const [settingsSchema, setSettingsSchema] = useState(null); const [selectedTaskId, setSelectedTaskId] = useState(initialLocation.taskId); const [selectedStepName, setSelectedStepName] = useState(""); const [taskDetail, setTaskDetail] = useState(null); const [currentSession, setCurrentSession] = useState(null); const [loading, setLoading] = useState(true); const [detailLoading, setDetailLoading] = useState(false); const [overviewLoading, setOverviewLoading] = useState(false); const [logLoading, setLogLoading] = useState(false); const [settingsLoading, setSettingsLoading] = useState(false); const [actionBusy, setActionBusy] = useState(""); const [panelBusy, setPanelBusy] = useState(""); const [toasts, setToasts] = useState([]); const detailCacheRef = useRef(new Map()); function pushToast(kind, text) { const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; setToasts((current) => [...current, { id, kind, text }]); } function removeToast(id) { setToasts((current) => current.filter((item) => item.id !== id)); } async function loadOverviewPanels() { const [servicesPayload, schedulerPayload, historyPayload] = await Promise.all([ fetchJsonCached("/runtime/services"), fetchJsonCached("/scheduler/preview"), fetchJsonCached("/history?limit=20"), ]); setServices(servicesPayload); setScheduler(schedulerPayload); setHistory(historyPayload); } async function loadShell() { setLoading(true); try { const [healthPayload, doctorPayload, taskPayload] = await Promise.all([ fetchJsonCached("/health"), fetchJsonCached("/doctor"), fetchJson(buildTasksUrl(taskQuery)), ]); setHealth(Boolean(healthPayload.ok)); setDoctorOk(Boolean(doctorPayload.ok)); setTasks(taskPayload.items || []); setTaskTotal(taskPayload.total || 0); startTransition(() => { if (!selectedTaskId && taskPayload.items?.length) { setSelectedTaskId(taskPayload.items[0].id); } }); } finally { setLoading(false); } } async function loadTasksOnly(query = taskQuery) { const url = buildTasksUrl(query); const taskPayload = await fetchJson(url); primeJsonCache(url, taskPayload); setTasks(taskPayload.items || []); setTaskTotal(taskPayload.total || 0); return taskPayload.items || []; } async function loadSessionDetail(sessionKey) { if (!sessionKey) { setCurrentSession(null); return null; } const payload = await fetchJson(`/sessions/${encodeURIComponent(sessionKey)}`); primeJsonCache(`/sessions/${encodeURIComponent(sessionKey)}`, payload); setCurrentSession(payload); return payload; } async function loadTaskDetail(taskId) { const cached = detailCacheRef.current.get(taskId); if (cached) { setTaskDetail(cached); void loadSessionDetail(cached.context?.session_key); setDetailLoading(false); } setDetailLoading(true); try { const [task, steps, artifacts, history, timeline, context] = await Promise.all([ fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}`), fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/steps`), fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/artifacts`), fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/history`), fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/timeline`), fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/context`), ]); const payload = { task, steps, artifacts, history, timeline, context }; detailCacheRef.current.set(taskId, payload); primeJsonCache(`/tasks/${encodeURIComponent(taskId)}`, task); primeJsonCache(`/tasks/${encodeURIComponent(taskId)}/steps`, steps); primeJsonCache(`/tasks/${encodeURIComponent(taskId)}/artifacts`, artifacts); primeJsonCache(`/tasks/${encodeURIComponent(taskId)}/history`, history); primeJsonCache(`/tasks/${encodeURIComponent(taskId)}/timeline`, timeline); primeJsonCache(`/tasks/${encodeURIComponent(taskId)}/context`, context); setTaskDetail(payload); await loadSessionDetail(context?.session_key); if (!selectedStepName) { const suggested = steps.items?.find((step) => ["failed_retryable", "failed_manual", "running"].includes(step.status))?.step_name || steps.items?.find((step) => step.status !== "succeeded")?.step_name || ""; setSelectedStepName(suggested); } } finally { setDetailLoading(false); } } async function refreshSelectedTask(taskId = selectedTaskId, { refreshTasks = true } = {}) { if (refreshTasks) { const refreshedTasks = await loadTasksOnly(); if (!taskId && refreshedTasks.length) { taskId = refreshedTasks[0].id; } } if (!taskId) { setTaskDetail(null); setCurrentSession(null); return; } await loadTaskDetail(taskId); } function invalidateTaskCaches(taskId) { invalidateJsonCache("/tasks?"); if (taskId) { detailCacheRef.current.delete(taskId); invalidateJsonCache(`/tasks/${encodeURIComponent(taskId)}`); } } function invalidateSessionCaches(sessionKey) { if (!sessionKey) return; invalidateJsonCache(`/sessions/${encodeURIComponent(sessionKey)}`); } async function prefetchTaskDetail(taskId) { if (!taskId || detailCacheRef.current.has(taskId)) return; try { const [task, steps, artifacts, history, timeline, context] = await Promise.all([ fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}`), fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/steps`), fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/artifacts`), fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/history`), fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/timeline`), fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/context`), ]); detailCacheRef.current.set(taskId, { task, steps, artifacts, history, timeline, context }); } catch { // Ignore prefetch failures; normal navigation will surface the actual error. } } useEffect(() => { let cancelled = false; loadShell().catch((error) => { if (!cancelled) pushToast("hot", `初始化失败: ${error}`); }); return () => { cancelled = true; }; }, []); useEffect(() => { syncHashState(view, selectedTaskId); }, [view, selectedTaskId]); useEffect(() => { if (view !== "Tasks") return; loadTasksOnly(taskQuery).catch((error) => pushToast("hot", `任务列表加载失败: ${error}`)); }, [taskQuery, view]); useEffect(() => { if (!toasts.length) return undefined; const timer = window.setTimeout(() => setToasts((current) => current.slice(1)), 3200); return () => window.clearTimeout(timer); }, [toasts]); useEffect(() => { function handleHashChange() { const next = parseHashState(); setView(next.view); if (next.taskId) { setSelectedTaskId(next.taskId); } } window.addEventListener("hashchange", handleHashChange); return () => window.removeEventListener("hashchange", handleHashChange); }, []); useEffect(() => { if (view !== "Overview") return; let cancelled = false; async function loadOverviewView() { setOverviewLoading(true); try { const [servicesPayload, schedulerPayload, historyPayload] = await Promise.all([ fetchJsonCached("/runtime/services"), fetchJsonCached("/scheduler/preview"), fetchJsonCached("/history?limit=20"), ]); if (cancelled) return; setServices(servicesPayload); setScheduler(schedulerPayload); setHistory(historyPayload); } finally { if (!cancelled) setOverviewLoading(false); } } loadOverviewView(); return () => { cancelled = true; }; }, [view]); useEffect(() => { if (!selectedTaskId) return; let cancelled = false; loadTaskDetail(selectedTaskId).catch((error) => { if (!cancelled) pushToast("hot", `任务详情加载失败: ${error}`); }); return () => { cancelled = true; }; }, [selectedTaskId]); async function loadCurrentLogContent(logName = selectedLogName) { if (!logName) return; setLogLoading(true); try { const currentTask = tasks.find((item) => item.id === selectedTaskId); let url = `/logs?name=${encodeURIComponent(logName)}&lines=200`; if (filterCurrentTaskLogs && currentTask?.title) { url += `&contains=${encodeURIComponent(currentTask.title)}`; } const payload = await fetchJson(url); setLogContent(payload); } finally { setLogLoading(false); } } useEffect(() => { if (view !== "Logs") return; let cancelled = false; async function loadLogsIndex() { setLogLoading(true); try { const logsPayload = await fetchJson("/logs"); if (cancelled) return; setLogs(logsPayload); if (!selectedLogName && logsPayload.items?.length) { startTransition(() => setSelectedLogName(logsPayload.items[0].name)); } } finally { if (!cancelled) setLogLoading(false); } } loadLogsIndex(); return () => { cancelled = true; }; }, [view]); useEffect(() => { if (view !== "Logs" || !selectedLogName) return; let cancelled = false; loadCurrentLogContent(selectedLogName).catch((error) => { if (!cancelled) pushToast("hot", `日志加载失败: ${error}`); }); return () => { cancelled = true; }; }, [view, selectedLogName, filterCurrentTaskLogs, selectedTaskId, tasks]); useEffect(() => { if (view !== "Logs" || !selectedLogName || !autoRefreshLogs) return; const timer = window.setInterval(() => { loadCurrentLogContent(selectedLogName).catch(() => {}); }, 5000); return () => window.clearInterval(timer); }, [view, selectedLogName, autoRefreshLogs, filterCurrentTaskLogs, selectedTaskId, tasks]); useEffect(() => { if (view !== "Settings") return; let cancelled = false; async function loadSettingsView() { setSettingsLoading(true); try { const [settingsPayload, schemaPayload] = await Promise.all([ fetchJsonCached("/settings"), fetchJsonCached("/settings/schema"), ]); if (cancelled) return; setSettings(settingsPayload); setSettingsSchema(schemaPayload); } finally { if (!cancelled) setSettingsLoading(false); } } loadSettingsView(); return () => { cancelled = true; }; }, [view]); const currentView = (() => { if (view === "Overview") { return ( { setPanelBusy("refresh_scheduler"); try { const payload = await fetchJson("/scheduler/preview"); setScheduler(payload); pushToast("good", "Scheduler 已刷新"); } finally { setPanelBusy(""); } }} onRefreshHistory={async () => { setPanelBusy("refresh_history"); try { const payload = await fetchJson("/history?limit=20"); setHistory(payload); pushToast("good", "Recent Actions 已刷新"); } finally { setPanelBusy(""); } }} onStageImport={async (sourcePath) => { setPanelBusy("stage_import"); try { const result = await fetchJson("/stage/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ source_path: sourcePath }), }); await loadTasksOnly(); pushToast("good", `已导入到 stage: ${result.target_path}`); } finally { setPanelBusy(""); } }} onStageUpload={async (file) => { setPanelBusy("stage_upload"); try { const result = await uploadFile("/stage/upload", file); await loadTasksOnly(); pushToast("good", `已上传到 stage: ${result.target_path}`); } finally { setPanelBusy(""); } }} onRunOnce={async () => { setPanelBusy("run_once"); try { await fetchJson("/worker/run-once", { method: "POST" }); invalidateJsonCache("/tasks?"); await loadTasksOnly(); if (selectedTaskId) await refreshSelectedTask(selectedTaskId, { refreshTasks: false }); pushToast("good", "Worker 已执行一轮"); } finally { setPanelBusy(""); } }} onServiceAction={async (serviceId, action) => { const busyKey = `service:${serviceId}:${action}`; setPanelBusy(busyKey); try { await fetchJson(`/runtime/services/${serviceId}/${action}`, { method: "POST" }); invalidateJsonCache("/runtime/services"); await loadShell(); if (view === "Overview") { await loadOverviewPanels(); } pushToast("good", `${serviceId} ${action} 完成`); } finally { setPanelBusy(""); } }} busy={panelBusy} /> ); } if (view === "Tasks") { return ( { if (options.prefetch) { prefetchTaskDetail(taskId); return; } startTransition(() => { setSelectedTaskId(taskId); setSelectedStepName(""); }); }} onRunTask={async (taskId) => { setActionBusy("run"); try { const result = await fetchJson(`/tasks/${encodeURIComponent(taskId)}/actions/run`, { method: "POST" }); invalidateTaskCaches(taskId); invalidateSessionCaches(taskDetail?.context?.session_key); await refreshSelectedTask(taskId); pushToast("good", `任务已推进: ${taskId} / processed=${result.processed.length}`); } finally { setActionBusy(""); } }} taskDetail={taskDetail} session={currentSession} loading={loading} detailLoading={detailLoading} actionBusy={actionBusy} selectedStepName={selectedStepName} onSelectStep={setSelectedStepName} onRetryStep={async (stepName) => { if (!selectedTaskId || !stepName) return; setActionBusy("retry"); try { const result = await fetchJson(`/tasks/${encodeURIComponent(selectedTaskId)}/actions/retry-step`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ step_name: stepName }), }); invalidateTaskCaches(selectedTaskId); invalidateSessionCaches(taskDetail?.context?.session_key); await refreshSelectedTask(selectedTaskId); pushToast("good", `已重试 ${stepName} / processed=${result.processed.length}`); } finally { setActionBusy(""); } }} onResetStep={async (stepName) => { if (!selectedTaskId || !stepName) return; if (!window.confirm(`确认重置到 step=${stepName} 并清理其后的产物吗?`)) return; setActionBusy("reset"); try { const result = await fetchJson(`/tasks/${encodeURIComponent(selectedTaskId)}/actions/reset-to-step`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ step_name: stepName }), }); invalidateTaskCaches(selectedTaskId); invalidateSessionCaches(taskDetail?.context?.session_key); await refreshSelectedTask(selectedTaskId); pushToast("good", `已重置到 ${stepName} / processed=${result.run.processed.length}`); } finally { setActionBusy(""); } }} onBindFullVideo={async (fullVideoBvid) => { if (!selectedTaskId || !fullVideoBvid) return; setActionBusy("bind_full_video"); try { await fetchJson(`/tasks/${encodeURIComponent(selectedTaskId)}/bind-full-video`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ full_video_bvid: fullVideoBvid }), }); invalidateTaskCaches(selectedTaskId); invalidateSessionCaches(taskDetail?.context?.session_key); await refreshSelectedTask(selectedTaskId); pushToast("good", `已绑定完整版 BV: ${fullVideoBvid}`); } finally { setActionBusy(""); } }} onOpenSessionTask={(taskId) => { startTransition(() => { setSelectedTaskId(taskId); setSelectedStepName(""); }); }} onSessionMerge={async (rawTaskIds) => { const sessionKey = currentSession?.session_key || taskDetail?.context?.session_key; const taskIds = String(rawTaskIds) .split(",") .map((item) => item.trim()) .filter(Boolean); if (!sessionKey || !taskIds.length) return; setActionBusy("session_merge"); try { await fetchJson(`/sessions/${encodeURIComponent(sessionKey)}/merge`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ task_ids: taskIds }), }); invalidateJsonCache("/tasks?"); invalidateSessionCaches(sessionKey); taskIds.forEach((taskId) => invalidateTaskCaches(taskId)); await refreshSelectedTask(selectedTaskId); pushToast("good", `已合并 ${taskIds.length} 个任务到 session ${sessionKey}`); } finally { setActionBusy(""); } }} onSessionRebind={async (fullVideoBvid) => { const sessionKey = currentSession?.session_key || taskDetail?.context?.session_key; if (!sessionKey || !fullVideoBvid) return; setActionBusy("session_rebind"); try { await fetchJson(`/sessions/${encodeURIComponent(sessionKey)}/rebind`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ full_video_bvid: fullVideoBvid }), }); invalidateSessionCaches(sessionKey); if (selectedTaskId) invalidateTaskCaches(selectedTaskId); await refreshSelectedTask(selectedTaskId); pushToast("good", `已为 session ${sessionKey} 绑定完整版 BV`); } finally { setActionBusy(""); } }} onTaskQueryChange={(patch) => { setTaskQuery((current) => ({ ...current, ...patch })); }} /> ); } if (view === "Settings") { return ( { await fetchJson("/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); invalidateJsonCache("/settings"); invalidateJsonCache("/settings/schema"); const refreshed = await fetchJson("/settings"); setSettings(refreshed); pushToast("good", "Settings 已保存并刷新"); return refreshed; }} /> ); } return ( startTransition(() => setSelectedLogName(name))} logContent={logContent} loading={logLoading} currentTaskTitle={tasks.find((item) => item.id === selectedTaskId)?.title || ""} filterCurrentTask={filterCurrentTaskLogs} onToggleFilterCurrentTask={setFilterCurrentTaskLogs} autoRefresh={autoRefreshLogs} onToggleAutoRefresh={setAutoRefreshLogs} onRefreshLog={async () => { if (!selectedLogName) return; setPanelBusy("refresh_log"); try { await loadCurrentLogContent(selectedLogName); pushToast("good", "日志已刷新"); } finally { setPanelBusy(""); } }} busy={panelBusy === "refresh_log"} /> ); })(); return (

Migration Workspace

{view}

API {health ? "ok" : "down"} Doctor {doctorOk ? "ready" : "warn"} {taskTotal} tasks
{toasts.length ? (
{toasts.map((toast) => (
{toast.text}
))}
) : null} {currentView}
); }