包含从项目创建至今的全部代码首次入库: 核心框架: - TaskQueue(SQLite,6 种状态,原子 dequeue) - Executor(asyncio 并发,semaphore 限流,agent_role 路由) - Scheduler(APScheduler,三队列独立调度) - Watchdog(超时检测 + 优先级防饥饿) - Scanner(项目扫描,README/TODO/CLAUDE.md 提取) Agent 体系(20+ 角色): - 项目交付组:architect/developer/tester/productizer - 基础技术组:base-architect/validator/hw/os/kernel/lowlevel/system-tester - 算法组:algo-antishake/position/nav - 洞察组:planner/vision-analyst/media-producer - 市场组:market-pm/sport/elder/safety - 组织层:boss/group-leader/senior-dev/ops - 平台扩展:nrf-dev/esp32-dev - 三专项调研:rtp-researcher/net-researcher/kernel-analyzer 工具层: - ProjectMemory(goals/facts/history SQLite) - VersionPipeline(并行版本流水线 + 反幻觉门控) - EventBus(Redis Streams,5 consumer groups) - DailyReport(append-only 日报 + Boss 决策视图) - AgentScorer(4 维评分:完成度/质量/自主性/协作) - DeviceAgent(SSH rsync + 板端执行) - EnvCollector(本机+SSH 环境采集) - ArchVersion(快照/优化/promote/rollback) Dashboard(React + Vite + Tailwind): - 看板/项目/配置/洞察/日报/Visual 六标签页 - WebSocket 实时更新 - Markdown 渲染(react-markdown + remark-gfm) 测试:314 passed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
59 KiB
Dashboard 实时看板 Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: 构建 FastAPI + React 实时看板,通过 WebSocket 显示所有 Agent 任务状态流转,支持多项目分组、完整操作控制台,Docker 容器化部署。
Architecture: FastAPI 后端服务(port 8080)通过后台 Watcher 线程每秒轮询 SQLite,检测变更后 WebSocket 广播给所有前端连接;React 前端(Vite + Tailwind + shadcn/ui)渲染 Kanban 看板,按项目分组展示 6 种任务状态列;多阶段 Docker 构建(node 构建前端,python 运行 FastAPI),通过 volume 挂载共享 tasks.db。
Tech Stack: Python 3.10, FastAPI, uvicorn, websockets, React 18, TypeScript, Vite, Tailwind CSS v3, shadcn/ui, Zustand, React Flow, Docker multi-stage build
环境说明
- 工作目录:
/data/rockchip/agents - Python venv:
source venv/bin/activate - 现有 SQLite:
data/tasks.db(由src/rockchip_agents/core/queue.py管理) - 现有 Task 字段:
id, project, type, title, context, priority, mode, agent_role, status, created_at, started_at, completed_at, result_summary, retry_count, parent_task_id, constraint_type, awaiting_role - 现有状态:
pending, running, done, failed, waiting_approval, vetoed, adversarial_pending, waiting_confirm
Task 1: FastAPI 后端核心 + REST API
Files:
- Create:
src/rockchip_agents/dashboard/__init__.py - Create:
src/rockchip_agents/dashboard/api.py - Create:
scripts/dashboard.py - Create:
requirements-dashboard.txt - Test:
tests/test_dashboard_api.py
Step 1: 创建 requirements-dashboard.txt
fastapi>=0.110.0
uvicorn[standard]>=0.27.0
websockets>=12.0
Step 2: 安装依赖
source venv/bin/activate
pip install fastapi "uvicorn[standard]" websockets
Step 3: 写失败测试
# tests/test_dashboard_api.py
from __future__ import annotations
import pytest
from pathlib import Path
from fastapi.testclient import TestClient
from rockchip_agents.core.queue import TaskQueue, Task
def _make_app(db_path: Path):
from rockchip_agents.dashboard.api import make_app
return make_app(db_path=db_path, config_dir=None)
def test_get_tasks_empty(tmp_path):
app = _make_app(tmp_path / "t.db")
client = TestClient(app)
resp = client.get("/api/tasks")
assert resp.status_code == 200
assert resp.json() == []
def test_get_tasks_returns_all(tmp_path):
q = TaskQueue(db_path=tmp_path / "t.db")
q.enqueue(Task(project="yolo", type="fix_bug", title="修复",
priority=1, mode="auto", agent_role="developer"))
app = _make_app(tmp_path / "t.db")
client = TestClient(app)
resp = client.get("/api/tasks")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["project"] == "yolo"
assert data[0]["status"] == "pending"
def test_post_task_enqueues(tmp_path):
app = _make_app(tmp_path / "t.db")
client = TestClient(app)
resp = client.post("/api/tasks", json={
"project": "yolo", "type": "fix_bug", "title": "新任务",
"priority": 2, "mode": "auto", "agent_role": "developer",
"context": "测试上下文"
})
assert resp.status_code == 200
assert resp.json()["id"] > 0
# 验证入队成功
resp2 = client.get("/api/tasks")
assert len(resp2.json()) == 1
def test_post_task_retry_failed(tmp_path):
q = TaskQueue(db_path=tmp_path / "t.db")
tid = q.enqueue(Task(project="yolo", type="fix_bug", title="失败任务",
priority=1, mode="auto", agent_role="developer"))
q.mark_failed(tid, "执行出错")
app = _make_app(tmp_path / "t.db")
client = TestClient(app)
resp = client.post(f"/api/tasks/{tid}/retry")
assert resp.status_code == 200
# 验证重入队
resp2 = client.get("/api/tasks")
tasks = {t["id"]: t for t in resp2.json()}
assert tasks[tid]["status"] == "pending"
def test_post_task_approve(tmp_path):
q = TaskQueue(db_path=tmp_path / "t.db")
tid = q.enqueue(Task(project="yolo", type="fix_bug", title="等待审批",
priority=1, mode="auto", agent_role="developer"))
q.mark_waiting_approval(tid, awaiting_role="arch-security")
app = _make_app(tmp_path / "t.db")
client = TestClient(app)
resp = client.post(f"/api/tasks/{tid}/approve")
assert resp.status_code == 200
resp2 = client.get("/api/tasks")
tasks = {t["id"]: t for t in resp2.json()}
assert tasks[tid]["status"] == "done"
def test_post_task_reject(tmp_path):
q = TaskQueue(db_path=tmp_path / "t.db")
tid = q.enqueue(Task(project="yolo", type="fix_bug", title="等待审批",
priority=1, mode="auto", agent_role="developer"))
q.mark_waiting_approval(tid, awaiting_role="pm")
app = _make_app(tmp_path / "t.db")
client = TestClient(app)
resp = client.post(f"/api/tasks/{tid}/reject", json={"reason": "需求不明确"})
assert resp.status_code == 200
resp2 = client.get("/api/tasks")
tasks = {t["id"]: t for t in resp2.json()}
assert tasks[tid]["status"] == "pending"
assert tasks[tid]["retry_count"] == 1
def test_post_task_cancel(tmp_path):
q = TaskQueue(db_path=tmp_path / "t.db")
tid = q.enqueue(Task(project="yolo", type="fix_bug", title="运行中",
priority=1, mode="auto", agent_role="developer"))
q.dequeue() # → running
app = _make_app(tmp_path / "t.db")
client = TestClient(app)
resp = client.post(f"/api/tasks/{tid}/cancel")
assert resp.status_code == 200
resp2 = client.get("/api/tasks")
tasks = {t["id"]: t for t in resp2.json()}
assert tasks[tid]["status"] == "failed"
def test_patch_task_priority(tmp_path):
q = TaskQueue(db_path=tmp_path / "t.db")
tid = q.enqueue(Task(project="yolo", type="fix_bug", title="任务",
priority=3, mode="auto", agent_role="developer"))
app = _make_app(tmp_path / "t.db")
client = TestClient(app)
resp = client.patch(f"/api/tasks/{tid}", json={"priority": 1})
assert resp.status_code == 200
resp2 = client.get("/api/tasks")
tasks = {t["id"]: t for t in resp2.json()}
assert tasks[tid]["priority"] == 1
def test_get_projects_returns_list(tmp_path):
app = _make_app(tmp_path / "t.db")
client = TestClient(app)
resp = client.get("/api/projects")
assert resp.status_code == 200
assert isinstance(resp.json(), list)
Step 4: 运行确认失败
source venv/bin/activate
pytest tests/test_dashboard_api.py::test_get_tasks_empty -v
Expected: FAIL with ModuleNotFoundError: No module named 'rockchip_agents.dashboard'
Step 5: 创建 src/rockchip_agents/dashboard/__init__.py
(空文件)
Step 6: 创建 src/rockchip_agents/dashboard/api.py
from __future__ import annotations
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from rockchip_agents.core.queue import TaskQueue, Task
# ── Pydantic 模型 ──────────────────────────────────────────────────────
class TaskOut(BaseModel):
id: int
project: str
type: str
title: str
context: str
priority: int
mode: str
agent_role: str
status: str
created_at: str
started_at: Optional[str] = None
completed_at: Optional[str] = None
result_summary: str
retry_count: int
parent_task_id: int
constraint_type: str
awaiting_role: str
class TaskCreate(BaseModel):
project: str
type: str
title: str
priority: int = 3
mode: str = "auto"
agent_role: str = "developer"
context: str = ""
class TaskPatch(BaseModel):
priority: Optional[int] = None
class RejectBody(BaseModel):
reason: str = ""
# ── 工厂函数 ───────────────────────────────────────────────────────────
def make_app(db_path: Path | None = None, config_dir: Path | None = None) -> FastAPI:
from rockchip_agents.core.queue import DEFAULT_DB
_db_path = db_path or DEFAULT_DB
_queue = TaskQueue(db_path=_db_path)
app = FastAPI(title="Rockchip Agents Dashboard")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
def _row_to_dict(row: sqlite3.Row) -> dict:
return {
"id": row["id"],
"project": row["project"],
"type": row["type"],
"title": row["title"],
"context": row["context"] or "",
"priority": row["priority"],
"mode": row["mode"],
"agent_role": row["agent_role"],
"status": row["status"],
"created_at": row["created_at"] or "",
"started_at": row["started_at"],
"completed_at": row["completed_at"],
"result_summary": row["result_summary"] or "",
"retry_count": row["retry_count"] or 0,
"parent_task_id": row["parent_task_id"] or 0,
"constraint_type": row["constraint_type"] or "",
"awaiting_role": row["awaiting_role"] or "",
}
def _all_tasks() -> list[dict]:
with _queue._conn() as c:
rows = c.execute(
"SELECT * FROM tasks ORDER BY id DESC LIMIT 500"
).fetchall()
return [_row_to_dict(r) for r in rows]
# ── REST 路由 ──────────────────────────────────────────────────────
@app.get("/api/tasks")
def get_tasks(project: Optional[str] = None) -> list[dict]:
tasks = _all_tasks()
if project:
tasks = [t for t in tasks if t["project"] == project]
return tasks
@app.post("/api/tasks")
def create_task(body: TaskCreate) -> dict:
task = Task(
project=body.project, type=body.type, title=body.title,
priority=body.priority, mode=body.mode, agent_role=body.agent_role,
context=body.context,
)
tid = _queue.enqueue(task)
if tid == -1:
raise HTTPException(400, "同名任务已在 pending/running 中")
with _queue._conn() as c:
row = c.execute("SELECT * FROM tasks WHERE id=?", (tid,)).fetchone()
return _row_to_dict(row)
@app.patch("/api/tasks/{task_id}")
def patch_task(task_id: int, body: TaskPatch) -> dict:
with _queue._conn() as c:
row = c.execute("SELECT id FROM tasks WHERE id=?", (task_id,)).fetchone()
if not row:
raise HTTPException(404, "任务不存在")
if body.priority is not None:
c.execute("UPDATE tasks SET priority=? WHERE id=?",
(body.priority, task_id))
row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
return _row_to_dict(row)
@app.post("/api/tasks/{task_id}/retry")
def retry_task(task_id: int) -> dict:
with _queue._conn() as c:
row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
if not row:
raise HTTPException(404, "任务不存在")
if row["status"] not in ("failed", "vetoed"):
raise HTTPException(400, f"只能重试 failed/vetoed 任务,当前状态: {row['status']}")
c.execute(
"UPDATE tasks SET status='pending', retry_count=retry_count+1,"
"started_at=NULL, completed_at=NULL WHERE id=?",
(task_id,),
)
row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
return _row_to_dict(row)
@app.post("/api/tasks/{task_id}/cancel")
def cancel_task(task_id: int) -> dict:
with _queue._conn() as c:
row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
if not row:
raise HTTPException(404, "任务不存在")
if row["status"] != "running":
raise HTTPException(400, "只能取消 running 任务")
c.execute(
"UPDATE tasks SET status='failed', result_summary='手动取消',"
"completed_at=? WHERE id=?",
(datetime.utcnow().isoformat(), task_id),
)
row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
return _row_to_dict(row)
@app.post("/api/tasks/{task_id}/approve")
def approve_task(task_id: int) -> dict:
with _queue._conn() as c:
row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
if not row:
raise HTTPException(404, "任务不存在")
if row["status"] != "waiting_approval":
raise HTTPException(400, "只能审批 waiting_approval 任务")
_queue.approve_waiting(task_id)
with _queue._conn() as c:
row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
return _row_to_dict(row)
@app.post("/api/tasks/{task_id}/reject")
def reject_task(task_id: int, body: RejectBody) -> dict:
with _queue._conn() as c:
row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
if not row:
raise HTTPException(404, "任务不存在")
if row["status"] != "waiting_approval":
raise HTTPException(400, "只能拒绝 waiting_approval 任务")
_queue.mark_vetoed(task_id, reason=body.reason or "审批拒绝")
with _queue._conn() as c:
row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
return _row_to_dict(row)
@app.get("/api/projects")
def get_projects() -> list[dict]:
try:
from rockchip_agents.config import load_config
from pathlib import Path as P
cfg = load_config(P(config_dir) if config_dir else None)
return [
{"name": name, "mode": p.mode, "kind": p.kind,
"description": p.description}
for name, p in sorted(cfg.projects.items())
]
except Exception:
return []
# ── WebSocket ──────────────────────────────────────────────────────
_ws_connections: set[WebSocket] = set()
@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket) -> None:
await ws.accept()
_ws_connections.add(ws)
import json
try:
snapshot = {"type": "snapshot", "tasks": _all_tasks()}
await ws.send_text(json.dumps(snapshot, ensure_ascii=False))
while True:
await ws.receive_text() # keep-alive (ping)
except WebSocketDisconnect:
pass
finally:
_ws_connections.discard(ws)
# ── 静态文件(React 构建产物)─────────────────────────────────────
dist_path = Path(__file__).parent.parent.parent.parent / "dashboard" / "dist"
if dist_path.exists():
app.mount("/", StaticFiles(directory=dist_path, html=True), name="static")
# 挂载 WS 连接集合供 watcher 访问
app.state.ws_connections = _ws_connections
app.state.all_tasks_fn = _all_tasks
return app
Step 7: 创建 scripts/dashboard.py
#!/usr/bin/env python3
"""Rockchip Agents Dashboard 独立服务"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import os
import threading
import time
from pathlib import Path
import uvicorn
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logger = logging.getLogger("dashboard")
DB_PATH = Path(os.environ.get("DB_PATH", "data/tasks.db"))
CONFIG_DIR = os.environ.get("CONFIG_DIR", None)
PORT = int(os.environ.get("DASHBOARD_PORT", "8080"))
def _task_hash(tasks: list[dict]) -> str:
s = json.dumps(tasks, sort_keys=True, ensure_ascii=False)
return hashlib.md5(s.encode()).hexdigest()
def _watcher(app_state, loop: asyncio.AbstractEventLoop) -> None:
"""后台线程:每秒检测 SQLite 变更,有变化则 broadcast 给所有 WS 连接"""
last_hash = ""
while True:
try:
tasks = app_state.all_tasks_fn()
h = _task_hash(tasks)
if h != last_hash:
last_hash = h
payload = json.dumps(
{"type": "snapshot", "tasks": tasks},
ensure_ascii=False
)
conns = list(app_state.ws_connections)
for ws in conns:
try:
asyncio.run_coroutine_threadsafe(
ws.send_text(payload), loop
)
except Exception:
pass
except Exception as e:
logger.debug("watcher 异常(非关键): %s", e)
time.sleep(1)
def main() -> None:
from rockchip_agents.dashboard.api import make_app
app = make_app(db_path=DB_PATH, config_dir=CONFIG_DIR)
loop = asyncio.new_event_loop()
@app.on_event("startup")
async def startup() -> None:
event_loop = asyncio.get_running_loop()
t = threading.Thread(
target=_watcher, args=(app.state, event_loop),
daemon=True, name="db-watcher"
)
t.start()
logger.info("Dashboard Watcher 已启动")
logger.info("Dashboard 启动: http://localhost:%d", PORT)
uvicorn.run(app, host="0.0.0.0", port=PORT, log_level="warning")
if __name__ == "__main__":
main()
Step 8: 运行测试
pytest tests/test_dashboard_api.py -v
Expected: 8 PASSED
Step 9: 手动验证服务启动
source venv/bin/activate
timeout 5 python scripts/dashboard.py || true
Expected: 输出 "Dashboard 启动: http://localhost:8080"(5秒后超时退出)
Task 2: React 前端脚手架
Files:
- Create:
dashboard/package.json - Create:
dashboard/vite.config.ts - Create:
dashboard/tsconfig.json - Create:
dashboard/index.html - Create:
dashboard/src/main.tsx - Create:
dashboard/src/App.tsx - Create:
dashboard/tailwind.config.js - Create:
dashboard/postcss.config.js - Create:
dashboard/src/index.css
Step 1: 初始化 React + Vite 项目
cd /data/rockchip/agents/dashboard
npm create vite@latest . -- --template react-ts --yes 2>/dev/null || true
npm install
npm install -D tailwindcss@3 postcss autoprefixer
npm install zustand @xyflow/react
npm install lucide-react class-variance-authority clsx tailwind-merge
npx tailwindcss init -p
Step 2: 更新 dashboard/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
},
server: {
proxy: {
'/api': 'http://localhost:8080',
'/ws': { target: 'ws://localhost:8080', ws: true },
},
},
})
Step 3: 更新 dashboard/tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
pending: '#6b7280',
running: '#3b82f6',
done: '#22c55e',
failed: '#ef4444',
waiting: '#f97316',
vetoed: '#a855f7',
},
},
},
plugins: [],
}
Step 4: 更新 dashboard/src/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
background-color: #0f172a;
color: #e2e8f0;
font-family: 'Inter', system-ui, sans-serif;
}
@keyframes pulse-ring {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.running-pulse {
animation: pulse-ring 1.5s ease-in-out infinite;
}
Step 5: 创建 dashboard/src/types.ts
export interface Task {
id: number
project: string
type: string
title: string
context: string
priority: number
mode: string
agent_role: string
status: 'pending' | 'running' | 'done' | 'failed' | 'waiting_approval' |
'vetoed' | 'adversarial_pending' | 'waiting_confirm'
created_at: string
started_at: string | null
completed_at: string | null
result_summary: string
retry_count: number
parent_task_id: number
constraint_type: string
awaiting_role: string
}
export interface Project {
name: string
mode: string
kind: string
description: string
}
export const STATUS_COLUMNS = [
'pending', 'running', 'done', 'failed', 'waiting_approval', 'vetoed'
] as const
export const STATUS_LABELS: Record<string, string> = {
pending: '待执行',
running: '运行中',
done: '完成',
failed: '失败',
waiting_approval: '待审批',
vetoed: '已否决',
adversarial_pending: '对抗中',
waiting_confirm: '等确认',
}
export const STATUS_COLORS: Record<string, string> = {
pending: 'border-gray-500 bg-gray-500/10',
running: 'border-blue-500 bg-blue-500/10 running-pulse',
done: 'border-green-500/50 bg-green-500/5 opacity-70',
failed: 'border-red-500 bg-red-500/10',
waiting_approval: 'border-orange-500 bg-orange-500/10',
vetoed: 'border-purple-500 bg-purple-500/10',
adversarial_pending: 'border-yellow-500 bg-yellow-500/10',
waiting_confirm: 'border-cyan-500 bg-cyan-500/10',
}
export const ROLE_COLORS: Record<string, string> = {
'developer': 'bg-blue-600',
'dev-embedded': 'bg-blue-500',
'dev-kernel': 'bg-indigo-600',
'dev-lowlevel': 'bg-indigo-500',
'dev-ml': 'bg-violet-600',
'dev-backend': 'bg-cyan-600',
'dev-test': 'bg-teal-600',
'dev-refactor': 'bg-slate-600',
'hw-engineer': 'bg-amber-600',
'tester': 'bg-teal-500',
'arch-system': 'bg-rose-600',
'arch-security':'bg-red-600',
'arch-reviewer':'bg-pink-600',
'pm': 'bg-fuchsia-600',
'productizer': 'bg-emerald-600',
'architect': 'bg-rose-500',
}
Step 6: 验证前端构建(空 App)
cd /data/rockchip/agents/dashboard
npm run build
Expected: 无报错,生成 dist/ 目录
Task 3: Zustand Store + WebSocket 连接
Files:
- Create:
dashboard/src/lib/ws.ts - Create:
dashboard/src/lib/api.ts - Create:
dashboard/src/store/tasks.ts
Step 1: 创建 dashboard/src/lib/api.ts
const BASE = '/api'
export async function fetchTasks(project?: string) {
const url = project ? `${BASE}/tasks?project=${project}` : `${BASE}/tasks`
const res = await fetch(url)
if (!res.ok) throw new Error(`fetchTasks failed: ${res.status}`)
return res.json()
}
export async function fetchProjects() {
const res = await fetch(`${BASE}/projects`)
if (!res.ok) throw new Error(`fetchProjects failed: ${res.status}`)
return res.json()
}
export async function createTask(body: {
project: string; type: string; title: string;
priority?: number; mode?: string; agent_role?: string; context?: string
}) {
const res = await fetch(`${BASE}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error(await res.text())
return res.json()
}
export async function patchTask(id: number, body: { priority?: number }) {
const res = await fetch(`${BASE}/tasks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error(await res.text())
return res.json()
}
async function taskAction(id: number, action: string, body?: object) {
const res = await fetch(`${BASE}/tasks/${id}/${action}`, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) throw new Error(await res.text())
return res.json()
}
export const retryTask = (id: number) => taskAction(id, 'retry')
export const cancelTask = (id: number) => taskAction(id, 'cancel')
export const approveTask = (id: number) => taskAction(id, 'approve')
export const rejectTask = (id: number, reason: string) =>
taskAction(id, 'reject', { reason })
Step 2: 创建 dashboard/src/store/tasks.ts
import { create } from 'zustand'
import type { Task, Project } from '../types'
interface TaskStore {
tasks: Map<number, Task>
projects: Project[]
wsStatus: 'connecting' | 'connected' | 'reconnecting' | 'offline'
selectedTaskId: number | null
activeProject: string | null // null = 全览
// actions
applySnapshot: (tasks: Task[]) => void
applyUpdate: (task: Task) => void
setProjects: (projects: Project[]) => void
setWsStatus: (s: TaskStore['wsStatus']) => void
setSelectedTask: (id: number | null) => void
setActiveProject: (p: string | null) => void
// derived
tasksByProject: () => Map<string, Task[]>
tasksByStatus: (project?: string) => Map<string, Task[]>
}
export const useTaskStore = create<TaskStore>((set, get) => ({
tasks: new Map(),
projects: [],
wsStatus: 'connecting',
selectedTaskId: null,
activeProject: null,
applySnapshot: (tasks) => {
const m = new Map<number, Task>()
tasks.forEach(t => m.set(t.id, t))
set({ tasks: m })
},
applyUpdate: (task) => {
set(state => {
const m = new Map(state.tasks)
m.set(task.id, task)
return { tasks: m }
})
},
setProjects: (projects) => set({ projects }),
setWsStatus: (wsStatus) => set({ wsStatus }),
setSelectedTask: (selectedTaskId) => set({ selectedTaskId }),
setActiveProject: (activeProject) => set({ activeProject }),
tasksByProject: () => {
const result = new Map<string, Task[]>()
for (const task of get().tasks.values()) {
const arr = result.get(task.project) ?? []
arr.push(task)
result.set(task.project, arr)
}
return result
},
tasksByStatus: (project?: string) => {
const result = new Map<string, Task[]>()
for (const task of get().tasks.values()) {
if (project && task.project !== project) continue
const arr = result.get(task.status) ?? []
arr.push(task)
result.set(task.status, arr)
}
return result
},
}))
Step 3: 创建 dashboard/src/lib/ws.ts
import { useTaskStore } from '../store/tasks'
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`
let ws: WebSocket | null = null
let retryDelay = 1000
export function connectWS() {
const store = useTaskStore.getState()
ws = new WebSocket(WS_URL)
store.setWsStatus('connecting')
ws.onopen = () => {
store.setWsStatus('connected')
retryDelay = 1000
}
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data)
if (msg.type === 'snapshot') {
store.applySnapshot(msg.tasks)
} else if (msg.type === 'task_update' || msg.type === 'task_create') {
store.applyUpdate(msg.task)
}
} catch (e) {
console.warn('WS parse error', e)
}
}
ws.onclose = () => {
store.setWsStatus('reconnecting')
setTimeout(() => {
retryDelay = Math.min(retryDelay * 2, 30000)
connectWS()
}, retryDelay)
}
ws.onerror = () => {
store.setWsStatus('offline')
ws?.close()
}
}
Step 4: 验证 TypeScript 编译
cd /data/rockchip/agents/dashboard
npm run build
Expected: PASS(无 TypeScript 错误)
Task 4: KanbanBoard + TaskCard 组件
Files:
- Create:
dashboard/src/components/TaskCard.tsx - Create:
dashboard/src/components/KanbanColumn.tsx - Create:
dashboard/src/components/KanbanBoard.tsx
Step 1: 创建 dashboard/src/components/TaskCard.tsx
import type { Task } from '../types'
import { STATUS_COLORS, ROLE_COLORS, STATUS_LABELS } from '../types'
import { useTaskStore } from '../store/tasks'
interface Props { task: Task }
function elapsed(startedAt: string | null): string {
if (!startedAt) return ''
const sec = Math.floor((Date.now() - new Date(startedAt + 'Z').getTime()) / 1000)
if (sec < 60) return `${sec}s`
if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`
}
export function TaskCard({ task }: Props) {
const setSelected = useTaskStore(s => s.setSelectedTask)
const colorClass = STATUS_COLORS[task.status] ?? 'border-gray-600 bg-gray-600/10'
const roleColor = ROLE_COLORS[task.agent_role] ?? 'bg-slate-600'
return (
<div
className={`rounded-lg border p-3 cursor-pointer hover:opacity-90 transition-opacity ${colorClass}`}
onClick={() => setSelected(task.id)}
>
{/* 角色徽章 + 模式 + 优先级 */}
<div className="flex items-center gap-2 mb-2">
<span className={`text-xs px-2 py-0.5 rounded-full text-white ${roleColor}`}>
{task.agent_role}
</span>
<span className="text-xs text-gray-400">{task.mode}</span>
<span className="ml-auto text-xs text-gray-500">P:{task.priority}</span>
</div>
{/* 标题 */}
<p className="text-sm text-gray-200 font-medium line-clamp-2 mb-2">
{task.title}
</p>
{/* 底部信息 */}
<div className="flex items-center gap-2 text-xs text-gray-500">
{task.status === 'running' && task.started_at && (
<span>⏱ {elapsed(task.started_at)}</span>
)}
{task.retry_count > 0 && (
<span>🔄 ×{task.retry_count}</span>
)}
{task.parent_task_id > 0 && (
<span className="text-purple-400">↑ #{task.parent_task_id}</span>
)}
{task.awaiting_role && (
<span className="text-orange-400">⏳ {task.awaiting_role}</span>
)}
</div>
</div>
)
}
Step 2: 创建 dashboard/src/components/KanbanColumn.tsx
import type { Task } from '../types'
import { STATUS_LABELS } from '../types'
import { TaskCard } from './TaskCard'
interface Props {
status: string
tasks: Task[]
}
const COLUMN_HEADER_COLORS: Record<string, string> = {
pending: 'text-gray-400 border-gray-600',
running: 'text-blue-400 border-blue-600',
done: 'text-green-400 border-green-600',
failed: 'text-red-400 border-red-600',
waiting_approval: 'text-orange-400 border-orange-500',
vetoed: 'text-purple-400 border-purple-600',
}
export function KanbanColumn({ status, tasks }: Props) {
const label = STATUS_LABELS[status] ?? status
const headerColor = COLUMN_HEADER_COLORS[status] ?? 'text-gray-400 border-gray-600'
return (
<div className="flex flex-col min-w-[200px] w-[200px]">
{/* 列头 */}
<div className={`flex items-center justify-between mb-3 pb-2 border-b ${headerColor}`}>
<span className="text-sm font-semibold">{label}</span>
<span className="text-xs bg-gray-700 rounded-full px-2 py-0.5">{tasks.length}</span>
</div>
{/* 任务卡片列表 */}
<div className="flex flex-col gap-2 overflow-y-auto max-h-[calc(100vh-280px)]">
{tasks.length === 0 ? (
<p className="text-xs text-gray-600 text-center py-4">—</p>
) : (
tasks.map(t => <TaskCard key={t.id} task={t} />)
)}
</div>
</div>
)
}
Step 3: 创建 dashboard/src/components/KanbanBoard.tsx
import { useTaskStore } from '../store/tasks'
import { KanbanColumn } from './KanbanColumn'
import { STATUS_COLUMNS } from '../types'
import type { Task } from '../types'
interface Props {
project?: string // undefined = 全览(所有项目合并)
}
export function KanbanBoard({ project }: Props) {
const tasksByProject = useTaskStore(s => s.tasksByProject())
const allTasks = useTaskStore(s => Array.from(s.tasks.values()))
// 按项目分组显示(全览时展示所有项目各自的泳道)
const projects = project
? [project]
: Array.from(tasksByProject.keys()).sort()
const getTasksByStatus = (proj: string | undefined): Map<string, Task[]> => {
const source = proj ? (tasksByProject.get(proj) ?? []) : allTasks
const result = new Map<string, Task[]>()
STATUS_COLUMNS.forEach(s => result.set(s, []))
source.forEach(t => {
const key = STATUS_COLUMNS.includes(t.status as any) ? t.status : 'pending'
result.get(key)?.push(t)
})
return result
}
if (projects.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-gray-600">
暂无项目数据 — 等待 Agent 任务入队...
</div>
)
}
return (
<div className="flex flex-col gap-8 p-4">
{projects.map(proj => {
const byStatus = getTasksByStatus(proj)
return (
<div key={proj}>
{/* 项目标题栏 */}
<div className="flex items-center gap-3 mb-4">
<span className="text-gray-300 font-semibold text-base">▼ {proj}</span>
<span className="text-xs text-gray-500 bg-gray-800 px-2 py-1 rounded">
{byStatus.get('running')?.length ?? 0} 运行 |{' '}
{byStatus.get('waiting_approval')?.length ?? 0} 待审批 |{' '}
{byStatus.get('failed')?.length ?? 0} 失败
</span>
</div>
{/* 6 列泳道 */}
<div className="flex gap-4 overflow-x-auto pb-2">
{STATUS_COLUMNS.map(status => (
<KanbanColumn
key={status}
status={status}
tasks={byStatus.get(status) ?? []}
/>
))}
</div>
</div>
)
})}
</div>
)
}
Step 4: 验证编译
cd /data/rockchip/agents/dashboard
npm run build
Expected: PASS
Task 5: TaskDetail 滑出面板 + 操作按钮
Files:
- Create:
dashboard/src/components/TaskDetail.tsx
Step 1: 创建 dashboard/src/components/TaskDetail.tsx
import { useTaskStore } from '../store/tasks'
import { STATUS_LABELS, ROLE_COLORS } from '../types'
import { retryTask, cancelTask, approveTask, rejectTask, patchTask } from '../lib/api'
import { useState } from 'react'
export function TaskDetail() {
const tasks = useTaskStore(s => s.tasks)
const selectedId = useTaskStore(s => s.selectedTaskId)
const setSelected = useTaskStore(s => s.setSelectedTask)
const applyUpdate = useTaskStore(s => s.applyUpdate)
const [rejectReason, setRejectReason] = useState('')
const [loading, setLoading] = useState(false)
if (!selectedId) return null
const task = tasks.get(selectedId)
if (!task) return null
const roleColor = ROLE_COLORS[task.agent_role] ?? 'bg-slate-600'
async function doAction(fn: () => Promise<any>) {
setLoading(true)
try {
const updated = await fn()
applyUpdate(updated)
} catch (e) {
alert(String(e))
} finally {
setLoading(false)
}
}
// 找到所有子任务
const children = Array.from(tasks.values()).filter(
t => t.parent_task_id === task.id
)
const parent = task.parent_task_id ? tasks.get(task.parent_task_id) : null
return (
<div className="fixed inset-y-0 right-0 w-[420px] bg-gray-900 border-l border-gray-700
shadow-2xl z-50 flex flex-col">
{/* 头部 */}
<div className="flex items-start justify-between p-4 border-b border-gray-700">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs px-2 py-0.5 rounded-full text-white ${roleColor}`}>
{task.agent_role}
</span>
<span className="text-xs text-gray-400">{STATUS_LABELS[task.status]}</span>
<span className="text-xs text-gray-500">#{task.id}</span>
</div>
<h2 className="text-sm font-semibold text-gray-100">{task.title}</h2>
</div>
<button
onClick={() => setSelected(null)}
className="text-gray-500 hover:text-gray-300 ml-3 text-lg leading-none"
>×</button>
</div>
{/* 内容 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 text-sm">
{/* 基本字段 */}
<div className="grid grid-cols-2 gap-2 text-xs">
<Info label="项目" value={task.project} />
<Info label="类型" value={task.type} />
<Info label="模式" value={task.mode} />
<Info label="优先级" value={String(task.priority)} />
<Info label="重试次数" value={String(task.retry_count)} />
{task.awaiting_role && <Info label="等待角色" value={task.awaiting_role} />}
</div>
{/* 时间 */}
<div className="text-xs text-gray-500 space-y-1">
<div>创建: {task.created_at}</div>
{task.started_at && <div>开始: {task.started_at}</div>}
{task.completed_at && <div>完成: {task.completed_at}</div>}
</div>
{/* 父子关系 */}
{parent && (
<div className="bg-gray-800 rounded p-2">
<p className="text-xs text-gray-500 mb-1">父任务:</p>
<button
className="text-xs text-purple-400 hover:text-purple-300 text-left"
onClick={() => setSelected(parent.id)}
>
#{parent.id} {parent.title}
</button>
</div>
)}
{children.length > 0 && (
<div className="bg-gray-800 rounded p-2">
<p className="text-xs text-gray-500 mb-1">子任务 ({children.length}):</p>
{children.map(c => (
<button
key={c.id}
className="block text-xs text-blue-400 hover:text-blue-300 text-left"
onClick={() => setSelected(c.id)}
>
#{c.id} [{STATUS_LABELS[c.status]}] {c.title}
</button>
))}
</div>
)}
{/* 上下文 */}
{task.context && (
<div>
<p className="text-xs text-gray-500 mb-1">上下文:</p>
<pre className="text-xs text-gray-300 bg-gray-800 rounded p-2 whitespace-pre-wrap
max-h-32 overflow-y-auto">{task.context}</pre>
</div>
)}
{/* 结果摘要 */}
{task.result_summary && (
<div>
<p className="text-xs text-gray-500 mb-1">执行结果:</p>
<pre className="text-xs text-gray-300 bg-gray-800 rounded p-2 whitespace-pre-wrap
max-h-48 overflow-y-auto">{task.result_summary}</pre>
</div>
)}
{/* REJECT 原因输入 */}
{task.status === 'waiting_approval' && (
<input
className="w-full bg-gray-800 border border-gray-600 rounded px-2 py-1
text-xs text-gray-200 placeholder-gray-600"
placeholder="拒绝原因(可选)"
value={rejectReason}
onChange={e => setRejectReason(e.target.value)}
/>
)}
</div>
{/* 操作按钮 */}
<div className="p-4 border-t border-gray-700 flex flex-wrap gap-2">
{task.status === 'waiting_approval' && (
<>
<ActionBtn
label="✅ APPROVE" color="green"
onClick={() => doAction(() => approveTask(task.id))}
disabled={loading}
/>
<ActionBtn
label="❌ REJECT" color="red"
onClick={() => doAction(() => rejectTask(task.id, rejectReason))}
disabled={loading}
/>
</>
)}
{(task.status === 'failed' || task.status === 'vetoed') && (
<ActionBtn
label="🔄 重试" color="blue"
onClick={() => doAction(() => retryTask(task.id))}
disabled={loading}
/>
)}
{task.status === 'running' && (
<ActionBtn
label="⏹ 取消" color="red"
onClick={() => doAction(() => cancelTask(task.id))}
disabled={loading}
/>
)}
{task.status === 'pending' && (
<ActionBtn
label="↑ 提升优先级" color="blue"
onClick={() => doAction(() => patchTask(task.id, { priority: Math.max(1, task.priority - 1) }))}
disabled={loading || task.priority <= 1}
/>
)}
</div>
</div>
)
}
function Info({ label, value }: { label: string; value: string }) {
return (
<div>
<span className="text-gray-500">{label}: </span>
<span className="text-gray-300">{value}</span>
</div>
)
}
function ActionBtn({
label, color, onClick, disabled
}: {
label: string; color: 'green' | 'red' | 'blue'; onClick: () => void; disabled?: boolean
}) {
const colors = {
green: 'bg-green-700 hover:bg-green-600 disabled:bg-green-900',
red: 'bg-red-700 hover:bg-red-600 disabled:bg-red-900',
blue: 'bg-blue-700 hover:bg-blue-600 disabled:bg-blue-900',
}
return (
<button
className={`px-3 py-1.5 rounded text-xs text-white ${colors[color]} disabled:opacity-50 disabled:cursor-not-allowed`}
onClick={onClick}
disabled={disabled}
>
{label}
</button>
)
}
Step 2: 验证编译
cd /data/rockchip/agents/dashboard
npm run build
Expected: PASS
Task 6: NewTaskModal + 顶部导航栏 + App 组装
Files:
- Create:
dashboard/src/components/NewTaskModal.tsx - Create:
dashboard/src/components/TopBar.tsx - Modify:
dashboard/src/App.tsx
Step 1: 创建 dashboard/src/components/NewTaskModal.tsx
import { useState } from 'react'
import { createTask, fetchProjects } from '../lib/api'
import { useTaskStore } from '../store/tasks'
import { useEffect } from 'react'
interface Props { onClose: () => void }
const TASK_TYPES = ['fix_bug', 'improve_perf', 'code_review', 'new_feature', 'arch_optimize']
const MODES = ['auto', 'confirm', 'report']
const ROLES = [
'developer', 'dev-embedded', 'dev-kernel', 'dev-lowlevel', 'dev-ml',
'dev-backend', 'dev-test', 'dev-refactor', 'hw-engineer', 'tester',
'arch-system', 'arch-security', 'arch-reviewer', 'pm', 'productizer', 'architect'
]
export function NewTaskModal({ onClose }: Props) {
const projects = useTaskStore(s => s.projects)
const applyUpdate = useTaskStore(s => s.applyUpdate)
const [form, setForm] = useState({
project: projects[0]?.name ?? '',
type: 'fix_bug',
title: '',
priority: 3,
mode: 'auto',
agent_role: 'developer',
context: '',
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function submit(e: React.FormEvent) {
e.preventDefault()
if (!form.title.trim()) { setError('标题不能为空'); return }
setLoading(true)
try {
const task = await createTask(form)
applyUpdate(task)
onClose()
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
}
}
return (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-[480px] shadow-2xl">
<h2 className="text-base font-semibold text-gray-100 mb-4">手动入队新任务</h2>
<form onSubmit={submit} className="space-y-3">
<Row label="项目">
<select {...bind(form, setForm, 'project')}
className={inputCls}>
{projects.map(p => <option key={p.name} value={p.name}>{p.name}</option>)}
</select>
</Row>
<Row label="标题">
<input {...bind(form, setForm, 'title')} className={inputCls}
placeholder="任务标题" />
</Row>
<Row label="类型">
<select {...bind(form, setForm, 'type')} className={inputCls}>
{TASK_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</Row>
<Row label="角色">
<select {...bind(form, setForm, 'agent_role')} className={inputCls}>
{ROLES.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</Row>
<Row label="模式">
<select {...bind(form, setForm, 'mode')} className={inputCls}>
{MODES.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</Row>
<Row label="优先级 (1-5)">
<input type="number" min={1} max={5} {...bind(form, setForm, 'priority', Number)}
className={inputCls} />
</Row>
<Row label="上下文">
<textarea {...bind(form, setForm, 'context')} rows={3}
className={inputCls + ' resize-none'}
placeholder="任务背景/约束(可选)" />
</Row>
{error && <p className="text-xs text-red-400">{error}</p>}
<div className="flex justify-end gap-2 pt-2">
<button type="button" onClick={onClose}
className="px-4 py-1.5 text-sm text-gray-400 hover:text-gray-200">
取消
</button>
<button type="submit" disabled={loading}
className="px-4 py-1.5 text-sm bg-blue-700 hover:bg-blue-600 text-white rounded
disabled:opacity-50">
{loading ? '入队中...' : '入队'}
</button>
</div>
</form>
</div>
</div>
)
}
const inputCls = 'w-full bg-gray-800 border border-gray-600 rounded px-2 py-1.5 text-xs text-gray-200'
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[100px_1fr] items-start gap-2">
<label className="text-xs text-gray-400 pt-1.5">{label}</label>
{children}
</div>
)
}
function bind<T extends object>(
form: T, setForm: (f: T) => void, key: keyof T, transform?: (v: string) => any
) {
return {
value: form[key] as any,
onChange: (e: React.ChangeEvent<any>) =>
setForm({ ...form, [key]: transform ? transform(e.target.value) : e.target.value })
}
}
Step 2: 创建 dashboard/src/components/TopBar.tsx
import { useTaskStore } from '../store/tasks'
interface Props {
onNewTask: () => void
activeTab: 'kanban' | 'dag'
onTabChange: (t: 'kanban' | 'dag') => void
}
const WS_STATUS_DOT: Record<string, string> = {
connected: 'bg-green-400',
connecting: 'bg-yellow-400 animate-pulse',
reconnecting: 'bg-orange-400 animate-pulse',
offline: 'bg-red-400',
}
export function TopBar({ onNewTask, activeTab, onTabChange }: Props) {
const tasks = useTaskStore(s => Array.from(s.tasks.values()))
const wsStatus = useTaskStore(s => s.wsStatus)
const projects = useTaskStore(s => s.projects)
const activeProject = useTaskStore(s => s.activeProject)
const setActiveProject = useTaskStore(s => s.setActiveProject)
const running = tasks.filter(t => t.status === 'running').length
const waiting = tasks.filter(t => t.status === 'waiting_approval').length
const failed = tasks.filter(t => t.status === 'failed').length
const allProjects = Array.from(new Set([
...projects.map(p => p.name),
...tasks.map(t => t.project)
])).sort()
return (
<div className="flex flex-col border-b border-gray-700 bg-gray-900/95 backdrop-blur">
{/* 主导航栏 */}
<div className="flex items-center gap-4 px-4 py-3">
<span className="text-base font-bold text-white">🤖 Rockchip Agents</span>
{/* WS 状态 */}
<div className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${WS_STATUS_DOT[wsStatus]}`} />
<span className="text-xs text-gray-400">{wsStatus}</span>
</div>
{/* 统计 */}
<div className="flex gap-3 text-xs text-gray-400">
{running > 0 && <span className="text-blue-400">● {running} 运行中</span>}
{waiting > 0 && <span className="text-orange-400">⚠ {waiting} 待审批</span>}
{failed > 0 && <span className="text-red-400">✕ {failed} 失败</span>}
</div>
<div className="ml-auto">
<button
onClick={onNewTask}
className="px-3 py-1.5 text-xs bg-blue-700 hover:bg-blue-600 text-white
rounded-lg transition-colors"
>
+ 新任务
</button>
</div>
</div>
{/* 项目标签页 + 视图切换 */}
<div className="flex items-center justify-between px-4 pb-2">
{/* 项目标签 */}
<div className="flex gap-1 overflow-x-auto">
<Tab label="全览" active={activeProject === null} onClick={() => setActiveProject(null)} />
{allProjects.map(p => (
<Tab key={p} label={p} active={activeProject === p} onClick={() => setActiveProject(p)} />
))}
</div>
{/* 视图切换 */}
<div className="flex gap-1 ml-4 shrink-0">
<ViewTab label="Kanban" active={activeTab === 'kanban'} onClick={() => onTabChange('kanban')} />
<ViewTab label="DAG" active={activeTab === 'dag'} onClick={() => onTabChange('dag')} />
</div>
</div>
</div>
)
}
function Tab({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
className={`px-3 py-1 text-xs rounded-full transition-colors whitespace-nowrap
${active
? 'bg-blue-700 text-white'
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-800'
}`}
>
{label}
</button>
)
}
function ViewTab({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
className={`px-2 py-1 text-xs rounded transition-colors
${active ? 'bg-gray-700 text-white' : 'text-gray-500 hover:text-gray-300'}`}
>
{label}
</button>
)
}
Step 3: 修改 dashboard/src/App.tsx
import { useEffect, useState } from 'react'
import { connectWS } from './lib/ws'
import { fetchProjects } from './lib/api'
import { useTaskStore } from './store/tasks'
import { TopBar } from './components/TopBar'
import { KanbanBoard } from './components/KanbanBoard'
import { TaskDetail } from './components/TaskDetail'
import { NewTaskModal } from './components/NewTaskModal'
export default function App() {
const [tab, setTab] = useState<'kanban' | 'dag'>('kanban')
const [showNewTask, setShowNewTask] = useState(false)
const setProjects = useTaskStore(s => s.setProjects)
const activeProject = useTaskStore(s => s.activeProject)
useEffect(() => {
connectWS()
fetchProjects().then(setProjects).catch(console.warn)
}, [])
return (
<div className="min-h-screen bg-gray-950 flex flex-col">
<TopBar
onNewTask={() => setShowNewTask(true)}
activeTab={tab}
onTabChange={setTab}
/>
<main className="flex-1 overflow-auto">
{tab === 'kanban' && (
<KanbanBoard project={activeProject ?? undefined} />
)}
{tab === 'dag' && (
<div className="flex items-center justify-center h-64 text-gray-600">
DAG 视图(Task 7 实现)
</div>
)}
</main>
<TaskDetail />
{showNewTask && <NewTaskModal onClose={() => setShowNewTask(false)} />}
</div>
)
}
Step 4: 构建验证
cd /data/rockchip/agents/dashboard
npm run build
Expected: PASS,dist/ 大小约 300-500KB
Task 7: DagView(React Flow)
Files:
- Create:
dashboard/src/components/DagView.tsx
Step 1: 创建 dashboard/src/components/DagView.tsx
import { useMemo } from 'react'
import { ReactFlow, Background, Controls, MiniMap, type Node, type Edge } from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { useTaskStore } from '../store/tasks'
import { STATUS_COLORS, STATUS_LABELS } from '../types'
const STATUS_NODE_COLORS: Record<string, string> = {
pending: '#374151',
running: '#1d4ed8',
done: '#15803d',
failed: '#b91c1c',
waiting_approval: '#c2410c',
vetoed: '#7e22ce',
}
export function DagView() {
const tasks = useTaskStore(s => Array.from(s.tasks.values()))
const activeProject = useTaskStore(s => s.activeProject)
const setSelected = useTaskStore(s => s.setSelectedTask)
// 只显示有父子关系的任务
const visibleTasks = useMemo(() => {
const filtered = activeProject
? tasks.filter(t => t.project === activeProject)
: tasks
const hasRelation = new Set<number>()
filtered.forEach(t => {
if (t.parent_task_id > 0) {
hasRelation.add(t.id)
hasRelation.add(t.parent_task_id)
}
})
return filtered.filter(t => hasRelation.has(t.id))
}, [tasks, activeProject])
const nodes: Node[] = useMemo(() => {
return visibleTasks.map((task, i) => ({
id: String(task.id),
position: { x: (i % 4) * 220 + 40, y: Math.floor(i / 4) * 120 + 40 },
data: {
label: (
<div className="text-xs">
<div className="font-semibold mb-0.5 truncate max-w-[160px]">{task.title}</div>
<div className="text-gray-400">{task.agent_role}</div>
<div className="text-gray-500">{STATUS_LABELS[task.status]}</div>
</div>
)
},
style: {
background: STATUS_NODE_COLORS[task.status] ?? '#374151',
border: '1px solid #4b5563',
borderRadius: '8px',
padding: '8px 12px',
width: 180,
cursor: 'pointer',
color: '#e2e8f0',
},
}))
}, [visibleTasks])
const edges: Edge[] = useMemo(() => {
return visibleTasks
.filter(t => t.parent_task_id > 0)
.map(t => ({
id: `${t.parent_task_id}-${t.id}`,
source: String(t.parent_task_id),
target: String(t.id),
label: t.constraint_type || 'SPAWN',
style: { stroke: '#6b7280' },
labelStyle: { fontSize: 10, fill: '#9ca3af' },
animated: t.status === 'running',
}))
}, [visibleTasks])
if (visibleTasks.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-gray-600">
暂无父子关系任务(SPAWN/AWAIT 产生的子任务会显示在此)
</div>
)
}
return (
<div style={{ height: 'calc(100vh - 110px)' }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodeClick={(_, node) => setSelected(Number(node.id))}
fitView
colorMode="dark"
>
<Background color="#1f2937" />
<Controls />
<MiniMap nodeColor={n => STATUS_NODE_COLORS[n.data?.status as string] ?? '#374151'} />
</ReactFlow>
</div>
)
}
Step 2: 在 App.tsx 中导入并替换占位
将 App.tsx 中的 DAG 占位替换:
import { DagView } from './components/DagView'
// 在 tab === 'dag' 处:
{tab === 'dag' && <DagView />}
Step 3: 构建验证
cd /data/rockchip/agents/dashboard
npm run build
Expected: PASS
Task 8: Dockerfile + docker-compose
Files:
- Create:
Dockerfile.dashboard - Create:
docker-compose.yml(新增 dashboard 服务) - Create:
dashboard/.dockerignore
Step 1: 创建 dashboard/.dockerignore
node_modules
dist
.git
Step 2: 创建 Dockerfile.dashboard
# ── Stage 1: 构建 React 前端 ─────────────────────────────────────────
FROM node:20-alpine AS frontend-builder
WORKDIR /frontend
COPY dashboard/package*.json ./
RUN npm ci --prefer-offline
COPY dashboard/ ./
RUN npm run build
# ── Stage 2: Python FastAPI 运行时 ───────────────────────────────────
FROM python:3.10-slim AS runtime
WORKDIR /app
# 安装 Python 依赖
COPY requirements-dashboard.txt ./
RUN pip install --no-cache-dir -r requirements-dashboard.txt
# 复制源码
COPY src/ ./src/
COPY scripts/dashboard.py ./scripts/
# 复制前端构建产物
COPY --from=frontend-builder /frontend/dist ./dashboard/dist/
EXPOSE 8080
CMD ["python", "scripts/dashboard.py"]
Step 3: 创建 docker-compose.yml
version: "3.9"
services:
dashboard:
build:
context: .
dockerfile: Dockerfile.dashboard
image: rockchip-agents-dashboard:latest
container_name: agents-dashboard
ports:
- "8080:8080"
volumes:
- ./data:/app/data # 共享 tasks.db(SQLite WAL 模式并发安全)
- ./configs:/app/configs # 读取项目配置
environment:
- DB_PATH=/app/data/tasks.db
- CONFIG_DIR=/app/configs
- DASHBOARD_PORT=8080
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c",
"import urllib.request; urllib.request.urlopen('http://localhost:8080/api/tasks')"]
interval: 30s
timeout: 5s
retries: 3
Step 4: 确认 data/ 目录存在(SQLite WAL 模式开启)
mkdir -p /data/rockchip/agents/data
# SQLite WAL 模式在 TaskQueue._init_db 中自动开启(若尚未开启需确认)
python -c "
import sqlite3
conn = sqlite3.connect('data/tasks.db')
conn.execute('PRAGMA journal_mode=WAL')
print(conn.execute('PRAGMA journal_mode').fetchone())
conn.close()
"
Expected: 输出 ('wal',)
Task 9: 端到端验证
Step 1: 本地验证(无 Docker)
cd /data/rockchip/agents && source venv/bin/activate
# 1. 构建前端
cd dashboard && npm run build && cd ..
# 2. 插入测试数据
python -c "
from rockchip_agents.core.queue import TaskQueue, Task
q = TaskQueue()
for i in range(3):
q.enqueue(Task(project='yolo', type='fix_bug', title=f'测试任务 {i}',
priority=i+1, mode='auto', agent_role='dev-embedded'))
print('已入队 3 个任务')
"
# 3. 启动 dashboard(后台)
python scripts/dashboard.py &
sleep 2
# 4. 验证 API
curl -s http://localhost:8080/api/tasks | python -m json.tool | head -30
curl -s http://localhost:8080/api/projects | python -m json.tool
# 5. 停止后台进程
kill %1
Step 2: 运行所有后端测试
pytest tests/test_dashboard_api.py -v
pytest tests/ -q
Expected: 全部 PASS(原有 132 + 新增 8 = 140+)
Step 3: Docker 构建验证
cd /data/rockchip/agents
docker compose build dashboard
docker compose up dashboard -d
sleep 5
# 验证健康
curl -s http://localhost:8080/api/tasks
curl -s http://localhost:8080/api/projects
# 验证前端页面
curl -s http://localhost:8080/ | head -5
docker compose down
Expected: API 返回 JSON,首页返回 HTML
启动命令汇总
# 开发模式(本地)
source venv/bin/activate
cd dashboard && npm run build && cd ..
python scripts/dashboard.py
# 访问 http://localhost:8080
# Docker 生产部署
docker compose up dashboard -d
# 访问 http://localhost:8080
# 重建(前端有更新)
docker compose build dashboard && docker compose up dashboard -d