feat: add web frontend shells (Home panel, Project panel, API client)

- Add research_web/index.html as entry point with navigation
- Add research_web/js/api_client.js with all research API endpoints
- Add research_web/js/app.js with Home and Project panel renderers
- Add research_web/css/research.css with dark theme styling
- Add server.py static route for /research endpoint
- Add placeholder .gitkeep files for empty subdirectories
This commit is contained in:
诺斯费拉图 2026-04-12 17:16:34 +08:00
parent be9241aa0f
commit abf2f0e601
7 changed files with 423 additions and 0 deletions

View File

@ -0,0 +1,110 @@
/* Research Workbench Styles */
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1e1e1e;
color: #d4d4d4;
}
#app { min-height: 100vh; display: flex; flex-direction: column; }
.app-header {
background: #252526;
border-bottom: 1px solid #3c3c3c;
padding: 0.5rem 1rem;
display: flex;
align-items: center;
gap: 2rem;
}
.app-header h1 { font-size: 1.2rem; color: #fff; }
.app-nav { display: flex; gap: 0.25rem; }
.nav-btn {
background: transparent;
border: none;
color: #d4d4d4;
padding: 0.5rem 1rem;
cursor: pointer;
border-radius: 4px;
}
.nav-btn:hover { background: #3c3c3c; }
.nav-btn.active { background: #37373d; color: #fff; }
#main-content { flex: 1; padding: 1rem; overflow: auto; }
.home-panel, .project-panel { max-width: 1200px; margin: 0 auto; }
/* Top Summary */
.top-summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 2rem; }
.summary-card { background: #252526; border-radius: 8px; padding: 1.5rem; text-align: center; }
.summary-card h3 { font-size: 2rem; color: #fff; }
.summary-card p { color: #858585; font-size: 0.85rem; }
/* Sections */
.today-feed, .project-focus, .quick-entry { margin-bottom: 2rem; }
.today-feed h2, .project-focus h2, .quick-entry h2 { margin-bottom: 1rem; font-size: 1.1rem; color: #fff; }
/* Feed Cards */
.feed-card {
background: #252526;
border-radius: 6px;
padding: 1rem;
margin-bottom: 0.75rem;
}
.feed-card h4 { color: #fff; margin-bottom: 0.25rem; }
.feed-meta { color: #858585; font-size: 0.8rem; margin-bottom: 0.5rem; }
.feed-abstract { font-size: 0.9rem; color: #ccc; margin-bottom: 0.75rem; }
.feed-actions { display: flex; gap: 0.5rem; }
.btn-save {
background: #37373d; border: none; color: #d4d4d4;
padding: 0.4rem 0.8rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem;
}
.btn-save:hover { background: #4f4f4f; }
/* Project Cards */
.project-card {
background: #252526; border-radius: 6px; padding: 1rem;
margin-bottom: 0.75rem; cursor: pointer;
}
.project-card:hover { background: #2d2d30; }
.project-card h4 { color: #fff; }
.status-badge {
display: inline-block; font-size: 0.75rem; padding: 0.2rem 0.5rem;
border-radius: 3px; margin-top: 0.5rem;
}
.status-badge.active { background: #2ea043; color: #fff; }
.status-badge.paused { background: #d29922; color: #000; }
.status-badge.completed { background: #388bfd; color: #fff; }
/* Buttons */
.btn-primary {
background: #0e639c; border: none; color: #fff;
padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer;
}
.btn-primary:hover { background: #1177bb; }
.project-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
.project-header h2 { color: #fff; }
.project-item {
background: #252526; border-radius: 6px; padding: 1rem;
margin-bottom: 0.75rem; display: flex; justify-content: space-between; align-items: center;
}
.project-item h3 { color: #fff; font-size: 1rem; }
.project-item p { color: #858585; font-size: 0.85rem; }
.project-actions { display: flex; gap: 0.5rem; }
.btn-open-canvas {
background: #37373d; border: none; color: #d4d4d4;
padding: 0.4rem 0.8rem; border-radius: 4px; cursor: pointer;
}
.btn-open-canvas:hover { background: #4f4f4f; }
.empty-state { color: #858585; font-style: italic; padding: 1rem; }
.error { color: #f48771; padding: 1rem; }

26
research_web/index.html Normal file
View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Research Workbench</title>
<link rel="stylesheet" href="./css/research.css">
</head>
<body>
<div id="app">
<header class="app-header">
<h1>Research Workbench</h1>
<nav class="app-nav">
<button id="nav-home" class="nav-btn active">Home</button>
<button id="nav-projects" class="nav-btn">Projects</button>
<button id="nav-assets" class="nav-btn">Assets</button>
<button id="nav-canvas" class="nav-btn">Canvas</button>
</nav>
</header>
<main id="main-content">
<!-- Panels rendered here -->
</main>
</div>
<script type="module" src="./js/app.js"></script>
</body>
</html>

View File

@ -0,0 +1,121 @@
/** Research API client - talks to ComfyUI's built-in research routes via aiohttp. */
const API_BASE = "/research";
export async function listProjects() {
const res = await fetch(`${API_BASE}/projects/`);
return res.json();
}
export async function createProject(data) {
const res = await fetch(`${API_BASE}/projects/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
return res.json();
}
export async function getProject(projectId) {
const res = await fetch(`${API_BASE}/projects/${projectId}`);
return res.json();
}
export async function updateProject(projectId, data) {
const res = await fetch(`${API_BASE}/projects/${projectId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
return res.json();
}
export async function listIntents(projectId) {
const res = await fetch(`${API_BASE}/projects/${projectId}/intents`);
return res.json();
}
export async function createIntent(projectId, data) {
const res = await fetch(`${API_BASE}/projects/${projectId}/intents`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
return res.json();
}
export async function updateIntent(intentId, data) {
const res = await fetch(`${API_BASE}/projects/intents/${intentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
return res.json();
}
export async function listPapers(params = {}) {
const qs = new URLSearchParams(params).toString();
const res = await fetch(`${API_BASE}/papers/${qs ? "?" + qs : ""}`);
return res.json();
}
export async function createPaper(data) {
const res = await fetch(`${API_BASE}/papers/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
return res.json();
}
export async function updatePaper(paperId, data) {
const res = await fetch(`${API_BASE}/papers/${paperId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
return res.json();
}
export async function listClaims(params = {}) {
const qs = new URLSearchParams(params).toString();
const res = await fetch(`${API_BASE}/claims/${qs ? "?" + qs : ""}`);
return res.json();
}
export async function createClaim(data) {
const res = await fetch(`${API_BASE}/claims/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
return res.json();
}
export async function updateClaim(claimId, data) {
const res = await fetch(`${API_BASE}/claims/${claimId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
return res.json();
}
export async function getTodayFeed() {
const res = await fetch(`${API_BASE}/feed/today`);
return res.json();
}
export async function listSources() {
const res = await fetch(`${API_BASE}/sources/`);
return res.json();
}
export async function createSource(data) {
const res = await fetch(`${API_BASE}/sources/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
return res.json();
}

161
research_web/js/app.js Normal file
View File

@ -0,0 +1,161 @@
/** Main Research Workbench app - Home and Project Workspace panels. */
import * as api from "./api_client.js";
const mainContent = document.getElementById("main-content");
const navButtons = document.querySelectorAll(".nav-btn");
let currentPanel = "home";
let currentProjectId = null;
function navigateTo(panel, projectId = null) {
currentPanel = panel;
currentProjectId = projectId;
navButtons.forEach(btn => btn.classList.remove("active"));
document.getElementById(`nav-${panel}`)?.classList.add("active");
render();
}
async function render() {
if (currentPanel === "home") {
try {
const [projects, feedItems, papers] = await Promise.all([
api.listProjects(),
api.getTodayFeed(),
api.listPapers({ library_status: "library" }),
]);
mainContent.innerHTML = "";
renderHomePanel(mainContent, { projects, feedItems, papers }, navigateTo);
} catch (e) {
mainContent.innerHTML = `<p class="error">Failed to load: ${e.message}</p>`;
}
} else if (currentPanel === "projects") {
try {
const projects = await api.listProjects();
mainContent.innerHTML = "";
renderProjectPanel(mainContent, { projects }, navigateTo);
} catch (e) {
mainContent.innerHTML = `<p class="error">Failed to load: ${e.message}</p>`;
}
} else if (currentPanel === "canvas") {
window.location.href = "/";
}
}
function renderHomePanel(container, { projects, feedItems, papers }, navigateTo) {
const projectCount = projects.length || 0;
const activeProjects = projects.filter(p => p.status === "active").length;
container.innerHTML = `
<div class="home-panel">
<section class="top-summary">
<div class="summary-card">
<h3>${projectCount}</h3>
<p>Total Projects</p>
</div>
<div class="summary-card">
<h3>${activeProjects}</h3>
<p>Active</p>
</div>
<div class="summary-card">
<h3>${papers.length}</h3>
<p>Papers in Library</p>
</div>
<div class="summary-card">
<h3>${feedItems.length}</h3>
<p>Today's Feed</p>
</div>
</section>
<section class="today-feed">
<h2>Today Feed</h2>
${feedItems.length === 0 ? '<p class="empty-state">No papers in feed yet.</p>' :
feedItems.map(item => `
<div class="feed-card">
<h4>${item.title || "Untitled"}</h4>
<p class="feed-meta">${item.authors_text || ""} · ${item.published_at || ""}</p>
<p class="feed-abstract">${item.abstract || ""}</p>
<div class="feed-actions">
<button class="btn-save" data-id="${item.id}">Save to Library</button>
</div>
</div>
`).join("")}
</section>
<section class="project-focus">
<h2>Project Focus</h2>
${projects.length === 0 ? '<p class="empty-state">No projects yet.</p>' :
projects.slice(0, 5).map(p => `
<div class="project-card" data-id="${p.id}">
<h4>${p.title}</h4>
<p>${p.goal || "No goal set"}</p>
<span class="status-badge ${p.status}">${p.status}</span>
</div>
`).join("")}
</section>
<section class="quick-entry">
<h2>Quick Entry</h2>
<button id="btn-new-project" class="btn-primary">+ New Project</button>
</section>
</div>
`;
document.getElementById("btn-new-project")?.addEventListener("click", async () => {
const title = prompt("Project title:");
if (title) {
await api.createProject({ title, goal: "" });
navigateTo("projects");
}
});
container.querySelectorAll(".project-card").forEach(card => {
card.addEventListener("click", () => {
navigateTo("projects");
});
});
}
function renderProjectPanel(container, { projects }, navigateTo) {
container.innerHTML = `
<div class="project-panel">
<div class="project-header">
<h2>Projects</h2>
<button id="btn-create-project" class="btn-primary">+ New Project</button>
</div>
<div class="project-list">
${projects.length === 0 ? '<p class="empty-state">No projects. Create your first project.</p>' :
projects.map(p => `
<div class="project-item" data-id="${p.id}">
<div class="project-info">
<h3>${p.title}</h3>
<p>${p.goal || "No goal set"}</p>
<span class="status-badge ${p.status}">${p.status}</span>
</div>
<div class="project-actions">
<button class="btn-open-canvas" data-id="${p.id}">Open Canvas</button>
</div>
</div>
`).join("")}
</div>
</div>
`;
document.getElementById("btn-create-project")?.addEventListener("click", async () => {
const title = prompt("Project title:");
if (title) {
await api.createProject({ title, goal: "" });
const projects = await api.listProjects();
renderProjectPanel(container, { projects }, navigateTo);
}
});
}
// Wire up navigation
document.getElementById("nav-home").addEventListener("click", () => navigateTo("home"));
document.getElementById("nav-projects").addEventListener("click", () => navigateTo("projects"));
document.getElementById("nav-assets").addEventListener("click", () => navigateTo("assets"));
document.getElementById("nav-canvas").addEventListener("click", () => navigateTo("canvas"));
// Initial render
render();

View File

View File

View File

@ -1104,6 +1104,11 @@ class PromptServer():
web.static('/docs', embedded_docs_path)
])
# Serve research workbench web frontend
research_web_path = os.path.join(os.path.dirname(__file__), "research_web")
if os.path.exists(research_web_path):
self.app.add_routes([web.static('/research', research_web_path)])
self.app.add_routes([
web.static('/', self.web_root),
])