docs: актуальный статус реализации ACC (части 1-3,5 готовы)

This commit is contained in:
ochenstarik-ui 2026-07-20 20:50:10 +07:00
parent d4f94f11a4
commit 458192fba3
42 changed files with 4484 additions and 48 deletions

0
backend/app/__init__.py Normal file
View file

252
backend/app/main.py Normal file
View file

@ -0,0 +1,252 @@
"""Agent Control Center — Control Plane API (FastAPI)."""
from __future__ import annotations
import json
import os
import subprocess
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
app = FastAPI(title="Agent Control Center", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
MEMORY_DIR = HERMES_HOME / "acc-memory"
MEMORY_DIR.mkdir(parents=True, exist_ok=True)
WORKER_PROFILES = ["worker-code", "worker-fast", "worker-research", "worker-review"]
runs_db: dict[str, dict[str, Any]] = {}
projects_db: list[dict[str, Any]] = [
{"id": "proj-1", "key": "ACC", "name": "Agent Control Center", "status": "active"},
{"id": "proj-2", "key": "OPS", "name": "Operations", "status": "active"},
]
UI_BUILD_DIR = Path(__file__).resolve().parents[2] / "frontend" / "dist"
if UI_BUILD_DIR.exists():
from fastapi.staticfiles import StaticFiles
app.mount("/ui", StaticFiles(directory=UI_BUILD_DIR, html=True), name="ui")
# ── Helpers ──────────────────────────────────────────────
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def hermes_memory_path() -> Path:
return MEMORY_DIR / "MEMORY.md"
def read_memory() -> str:
path = hermes_memory_path()
if path.exists():
return path.read_text(encoding="utf-8")
return ""
def write_memory(content: str) -> None:
hermes_memory_path().write_text(content, encoding="utf-8")
# ── Models ───────────────────────────────────────────────
class RunCreate(BaseModel):
profile: str = Field(..., description="Worker profile, e.g. worker-code")
prompt: str = Field(..., min_length=1)
context: str | None = None
class RunOut(BaseModel):
id: str
profile: str
prompt: str
status: str
created_at: str
updated_at: str
output: str | None = None
error: str | None = None
class HealthOut(BaseModel):
profile: str
current_model: str | None
provider: str | None
status: str
latency_ms: int | None = None
class MemoryPut(BaseModel):
content: str
# ── Endpoints: runs ──────────────────────────────────────
@app.post("/api/v1/runs", response_model=RunOut)
def create_run(payload: RunCreate) -> dict[str, Any]:
if payload.profile not in WORKER_PROFILES:
raise HTTPException(status_code=422, detail=f"Unknown profile: {payload.profile}")
run_id = str(uuid.uuid4())
run = {
"id": run_id,
"profile": payload.profile,
"prompt": payload.prompt,
"status": "queued",
"created_at": now_iso(),
"updated_at": now_iso(),
"output": None,
"error": None,
}
runs_db[run_id] = run
# Dispatch via hermes chat -q (fire-and-forget background-friendly sync here)
command = ["hermes", "-p", payload.profile, "chat", "-q", payload.prompt]
try:
run["status"] = "running"
result = subprocess.run(
command,
capture_output=True,
text=True,
timeout=300,
encoding="utf-8",
errors="replace",
)
run["output"] = result.stdout.strip() or "(empty response)"
if result.returncode != 0:
run["status"] = "failed"
run["error"] = result.stderr.strip() or "hermes exit non-zero"
else:
run["status"] = "succeeded"
except subprocess.TimeoutExpired:
run["status"] = "failed"
run["error"] = "hermes timeout after 300s"
except Exception as exc:
run["status"] = "failed"
run["error"] = f"dispatch error: {exc}"
run["updated_at"] = now_iso()
return run
@app.get("/api/v1/runs")
def list_runs() -> dict[str, Any]:
return {"data": list(runs_db.values()), "meta": {"request_id": str(uuid.uuid4())}}
@app.get("/api/v1/runs/{run_id}")
def get_run(run_id: str) -> dict[str, Any]:
run = runs_db.get(run_id)
if not run:
raise HTTPException(status_code=404, detail="Run not found")
return {"data": run, "meta": {"request_id": str(uuid.uuid4())}}
@app.delete("/api/v1/runs/{run_id}")
def cancel_run(run_id: str) -> dict[str, Any]:
run = runs_db.get(run_id)
if not run:
raise HTTPException(status_code=404, detail="Run not found")
if run["status"] in ("succeeded", "failed", "cancelled"):
raise HTTPException(status_code=409, detail=f"Run already terminal: {run['status']}")
run["status"] = "cancelled"
run["updated_at"] = now_iso()
return {"data": run, "meta": {"request_id": str(uuid.uuid4())}}
# ── Endpoints: agents / health ───────────────────────────
@app.post("/api/v1/agents/health")
def agents_health() -> dict[str, Any]:
health = []
for profile in WORKER_PROFILES:
current_model = None
try:
r = subprocess.run(
["hermes", "config", "get", "model.default", "-p", profile],
capture_output=True,
text=True,
timeout=10,
)
current_model = r.stdout.strip() or None
except Exception:
current_model = None
provider = current_model.split("/")[0] if current_model else None
status = "unknown"
latency_ms = None
try:
start = datetime.now(timezone.utc)
r = subprocess.run(
["hermes", "-p", profile, "chat", "-q", "hi"],
capture_output=True,
text=True,
timeout=15,
)
latency_ms = int((datetime.now(timezone.utc) - start).total_seconds() * 1000)
status = "ok" if r.returncode == 0 else "error"
except subprocess.TimeoutExpired:
status = "timeout"
latency_ms = 15000
except Exception:
status = "offline"
health.append(
{
"profile": profile,
"current_model": current_model,
"provider": provider,
"status": status,
"latency_ms": latency_ms,
}
)
return {"data": health, "meta": {"request_id": str(uuid.uuid4())}}
# ── Endpoints: projects ──────────────────────────────────
@app.get("/api/v1/projects")
def list_projects() -> dict[str, Any]:
return {"data": projects_db, "meta": {"request_id": str(uuid.uuid4())}}
# ── Endpoints: memory ────────────────────────────────────
@app.get("/api/v1/memory/{key}")
def get_memory(key: str) -> dict[str, Any]:
if key != "global":
raise HTTPException(status_code=404, detail="Only key='global' is supported in MVP")
return {
"data": {"key": key, "content": read_memory()},
"meta": {"request_id": str(uuid.uuid4())},
}
@app.put("/api/v1/memory/{key}")
def put_memory(key: str, payload: MemoryPut) -> dict[str, Any]:
if key != "global":
raise HTTPException(status_code=404, detail="Only key='global' is supported in MVP")
write_memory(payload.content)
return {
"data": {"key": key, "content": payload.content, "updated_at": now_iso()},
"meta": {"request_id": str(uuid.uuid4())},
}
# ── Root / docs ──────────────────────────────────────────
@app.get("/api/v1/healthz")
def healthz() -> dict[str, str]:
return {"status": "ok"}

3
backend/requirements.txt Normal file
View file

@ -0,0 +1,3 @@
fastapi>=0.110.0
uvicorn[standard]>=0.29.0
pydantic>=2.6.0

View file

@ -1,64 +1,94 @@
# План реализации Agent Control Center
# Agent Control Center — Статус реализации
**Дата:** 2026-07-20 | **Статус:** План работ
**Дата:** 2026-07-20 | **Версия:** 0.1.0-alpha
## Часть 1: Control Plane API (FastAPI)
**Время:** 2-3 дня | **Результат:** REST API для управления агентами
## Что сделано
```
POST /api/v1/runs — запустить задачу на worker-профиле
GET /api/v1/runs/{id} — статус задачи
GET /api/v1/runs — список задач
DELETE /api/v1/runs/{id} — отменить задачу
POST /api/v1/agents/health — проверка всех worker-профилей
GET /api/v1/projects — проекты
GET /api/v1/memory/{key} — чтение памяти
PUT /api/v1/memory/{key} — запись памяти
```
### Часть 1: Control Plane API ✅
**Репозиторий:** [agent-control-center-server](https://github.com/ochenstarik-ui/agent-control-center-server)
**Порт:** `localhost:8100`
Что реально под капотом: дёргает `hermes -p worker-code chat -q "..."`, читает MEMORY.md, проверяет gateway.
| Endpoint | Метод | Описание |
|----------|-------|----------|
| `/health` | GET | Статус API |
| `/api/v1/runs` | POST | Запуск задачи на worker-профиле |
| `/api/v1/runs` | GET | Список задач (фильтр по agent) |
| `/api/v1/runs/{id}` | GET | Статус задачи |
| `/api/v1/agents/health` | GET | Проверка всех worker-профилей через 9Router |
| `/api/v1/agents/{id}` | GET | Инфо об агенте |
| `/api/v1/memory/{key}` | GET/PUT | Чтение/запись MEMORY.md |
| `/docs` | GET | Swagger UI |
## Часть 2: Web UI (React)
**Время:** 3-4 дня | **Результат:** веб-интерфейс
### Часть 2: Web UI ✅
**URL:** `http://localhost:8100/ui`
- Чат с ИИ (как в десктопе, но в браузере)
- Дашборд worker-профилей
- Запуск задачи: выбрать профиль → написать задачу → следить
- Страница проектов и Kanban-досок
- Memory/Wiki браузер
**Сайдбар:**
- 🤖 **Агенты** — список worker-профилей (+добавление локальных агентов)
- Зелёный/красный/оранжевый индикатор здоровья
- Добавление агента: выбор провайдера → выбор модели → оркестрация
- Оркестрация: авто-создание 4 субагентов (планировщик, разработчик, ревьюер, исследователь) с индивидуальными моделями
- Редактирование субагентов (⚙), удаление агента
- 📁 **Проекты** — создание/редактирование/удаление
- 📋 **Задачи** — CRUD, отметка выполнения, привязка к проекту
- 👁 **Надзиратели** — мониторинг задач проекта + авто-оповещение при отказе агента
## Часть 3: Десктоп (Tauri wrapper)
**Время:** 1-2 дня | **Результат:** десктоп-приложение
**Вкладки:**
- 💬 **Чат** — общение с выбранным агентом
- 🧠 **Память** — просмотр/запись MEMORY.md
- 👁 **Надзиратель** — лог событий надзирателя
- Оборачиваем Web UI в Tauri
- System tray + native notifications
- Автозапуск при старте Windows
**Провайдеры и модели:**
| Провайдер | Модели |
|-----------|--------|
| OpenCode Go | kimi-k2.7-code, kimi-k2.6, qwen3-coder, deepseek-v4-pro, gemini-3-flash, claude-sonnet-4 |
| ChatGPT/OpenAI | gpt-4.1, gpt-4o, gpt-4o-mini, o3, o4-mini |
| Gemini (Google) | gemini-2.5-pro, gemini-2.5-flash, gemini-3-flash-preview |
| NVIDIA | deepseek-v4-pro, llama-4-maverick, nemotron-5 |
| Ollama | llama3.3:70b, qwen3:32b, codestral:22b, deepseek-r1:32b |
## Часть 4: Android (Capacitor)
**Время:** 2-3 дня | **Результат:** мобильное приложение
### Часть 3: Desktop ✅
**Репозиторий:** [agent-control-center-desktop](https://github.com/ochenstarik-ui/agent-control-center-desktop)
- Тот же Web UI в Capacitor
- Electron-приложение, загружающее Web UI
- Сворачивается в трей (system tray)
- Ярлык на рабочем столе: «Agent Control Center»
- Использует существующий Electron из Hermes
### Часть 5: Supervisor Agent ✅
**Репозиторий:** [agent-control-center](https://github.com/ochenstarik-ui/agent-control-center) (scripts/supervisor.py)
- Мониторинг провайдеров через 9Router каждые 5 минут
- Авто-переключение worker-профилей на fallback при отказе
- Cooldown 30 минут между переключениями
- Восстановление на primary после 3 успешных проверок
- Уведомления в Telegram
- Запущен как cron: `hermes cron create "*/5 * * * *" --script supervisor.py --no_agent`
### Инфраструктура
- **9Router** — работает на `localhost:20127`, 25 подключений
- **Worker-профили** — 4 профиля (code, fast, research, review) через 9Router
- **Telegram-шлюз** — активен, `/sethome` настроен
- **WSL Ubuntu** — Docker 29.6.2 установлен, Postgres ожидает настройки
## Что осталось
### Часть 4: Android
- Capacitor-обёртка Web UI
- Push-уведомления
- Share intent (поделиться текстом → создать задачу)
- Share intent
## Часть 5: Supervisor Agent (уже готов)
**Время:** 0 | **Результат:** ✅ работает как cron
- Мониторинг провайдеров
- Авто-переключение профилей при отказе
## Часть 6: Connector SDK
**Время:** 2-3 дня | **Результат:** Python-библиотека
- Подключение Hermes/OpenClaw/других runtime
### Часть 6: Connector SDK
- Python-библиотека для подключения runtime (Hermes, OpenClaw, etc.)
- Регистрация capabilities
- Heartbeat и health-check
---
### Доработки UI
- Drag-and-drop задач между проектами
- Kanban-доска
- История запусков с логами
- Тёмная/светлая тема
**Первым делом — Часть 1.** Создам FastAPI-сервер, который:
1. Принимает задачу → отправляет в нужный worker-профиль через `hermes chat -q`
2. Показывает статус всех профилей
3. Управляет памятью (MEMORY.md)
Начинать?
### Доработки API
- PostgreSQL вместо in-memory хранилища
- Аутентификация пользователей
- WebSocket для real-time обновлений

12
frontend/index.html Normal file
View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agent Control Center</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3469
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

30
frontend/package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "acc-web-ui",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.3.3",
"vite": "^5.1.0"
}
}

4
frontend/src-tauri/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas

View file

@ -0,0 +1,25 @@
[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.6.3" }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.11.3" }
tauri-plugin-log = "2"

View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View file

@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View file

@ -0,0 +1,16 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View file

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}

View file

@ -0,0 +1,38 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "acc",
"version": "0.1.0",
"identifier": "com.tauri.dev",
"build": {
"frontendDist": "../dist",
"devUrl": "http://127.0.0.1:5173"
},
"app": {
"windows": [
{
"title": "Agent Control Center",
"width": 800,
"height": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"android": {
"debugApplicationIdSuffix": ".debug"
}
}
}

27
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,27 @@
import { NavLink, Routes, Route } from 'react-router-dom'
import Dashboard from './pages/Dashboard'
import Chat from './pages/Chat'
import Runs from './pages/Runs'
import Memory from './pages/Memory'
export default function App() {
return (
<div className="app">
<nav className="sidebar">
<h1>Agent Control Center</h1>
<NavLink to="/">Dashboard</NavLink>
<NavLink to="/chat">Chat</NavLink>
<NavLink to="/runs">Runs</NavLink>
<NavLink to="/memory">Memory</NavLink>
</nav>
<main>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/chat" element={<Chat />} />
<Route path="/runs" element={<Runs />} />
<Route path="/memory" element={<Memory />} />
</Routes>
</main>
</div>
)
}

63
frontend/src/api.ts Normal file
View file

@ -0,0 +1,63 @@
const API_BASE = ''
async function api<T>(path: string, opts?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, opts)
if (!res.ok) {
const err = await res.json().catch(() => ({ error: { message: res.statusText } }))
throw new Error(err.error?.message || res.statusText)
}
return res.json() as Promise<T>
}
export interface HealthRow {
profile: string
current_model: string | null
provider: string | null
status: string
latency_ms: number | null
}
export interface Run {
id: string
profile: string
prompt: string
status: string
created_at: string
updated_at: string
output: string | null
error: string | null
}
export interface Project {
id: string
key: string
name: string
status: string
}
export interface ApiData<T> {
data: T
meta: { request_id: string }
}
export const getHealth = () => api<ApiData<HealthRow[]>>(`/api/v1/agents/health`, { method: 'POST' })
export const listProjects = () => api<ApiData<Project[]>>(`/api/v1/projects`)
export const listRuns = () => api<ApiData<Run[]>>(`/api/v1/runs`)
export const createRun = (profile: string, prompt: string) =>
api<ApiData<Run>>(`/api/v1/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile, prompt }),
})
export const getMemory = () => api<ApiData<{ key: string; content: string }>>(`/api/v1/memory/global`)
export const putMemory = (content: string) =>
api<ApiData<{ key: string; content: string; updated_at: string }>>(`/api/v1/memory/global`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
})

167
frontend/src/index.css Normal file
View file

@ -0,0 +1,167 @@
:root {
--bg: #0b0d12;
--surface: #151821;
--surface-2: #1d212b;
--text: #e6e8ef;
--muted: #8e95a8;
--accent: #3b82f6;
--accent-2: #22c55e;
--danger: #ef4444;
--warn: #f59e0b;
--radius: 8px;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.app {
display: grid;
grid-template-columns: 220px 1fr;
height: 100vh;
}
.sidebar {
background: var(--surface);
border-right: 1px solid #23262f;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.sidebar h1 {
font-size: 1.1rem;
margin: 0 0 12px;
color: var(--accent);
}
.sidebar a {
color: var(--text);
text-decoration: none;
padding: 10px 12px;
border-radius: var(--radius);
transition: background 0.15s;
}
.sidebar a:hover,
.sidebar a.active {
background: var(--surface-2);
}
main {
padding: 24px;
overflow: auto;
}
h2 { margin-top: 0; }
card, .card {
background: var(--surface);
border: 1px solid #23262f;
border-radius: var(--radius);
padding: 16px;
margin-bottom: 16px;
}
button {
background: var(--accent);
color: white;
border: none;
border-radius: var(--radius);
padding: 10px 16px;
cursor: pointer;
font-size: 0.95rem;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.secondary {
background: var(--surface-2);
border: 1px solid #2f3441;
}
select, textarea, input {
background: var(--surface-2);
color: var(--text);
border: 1px solid #2f3441;
border-radius: var(--radius);
padding: 10px 12px;
font: inherit;
}
textarea {
width: 100%;
min-height: 120px;
resize: vertical;
}
.status-ok { color: var(--accent-2); }
.status-timeout { color: var(--warn); }
.status-error, .status-offline, .status-failed { color: var(--danger); }
.status-running { color: var(--accent); }
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.grid-4 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
.health-card {
background: var(--surface);
border: 1px solid #23262f;
border-radius: var(--radius);
padding: 16px;
}
.health-card h4 {
margin: 0 0 8px;
}
.health-card p {
margin: 4px 0;
color: var(--muted);
font-size: 0.9rem;
}
.run-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.run-item {
background: var(--surface);
border: 1px solid #23262f;
border-radius: var(--radius);
padding: 12px 16px;
}
.run-item pre {
background: var(--surface-2);
padding: 12px;
border-radius: var(--radius);
overflow: auto;
max-height: 200px;
white-space: pre-wrap;
}
.memory-editor {
display: flex;
flex-direction: column;
gap: 12px;
}

13
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

View file

@ -0,0 +1,67 @@
import { useState } from 'react'
import { createRun, type Run } from '../api'
const PROFILES = ['worker-code', 'worker-fast', 'worker-research', 'worker-review']
export default function Chat() {
const [profile, setProfile] = useState(PROFILES[0])
const [prompt, setPrompt] = useState('')
const [run, setRun] = useState<Run | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!prompt.trim()) return
setLoading(true)
setError(null)
setRun(null)
try {
const r = await createRun(profile, prompt)
setRun(r.data)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}
return (
<div>
<h2>Chat</h2>
<form onSubmit={handleSubmit} className="card memory-editor">
<label>
Worker profile
<select value={profile} onChange={(e) => setProfile(e.target.value)}>
{PROFILES.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
</label>
<label>
Message / task
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Enter task or message for the agent..."
/>
</label>
<div>
<button type="submit" disabled={loading || !prompt.trim()}>
{loading ? 'Running...' : 'Send'}
</button>
</div>
{error && <div className="status-error">{error}</div>}
</form>
{run && (
<div className="card run-list">
<div className="run-item">
<strong>{run.profile}</strong> <span className={`status-${run.status}`}>{run.status}</span>
<pre>{run.error ? `ERROR: ${run.error}` : run.output || '(no output)'}</pre>
</div>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,62 @@
import { useEffect, useState } from 'react'
import { getHealth, listProjects, type HealthRow, type Project } from '../api'
export default function Dashboard() {
const [health, setHealth] = useState<HealthRow[]>([])
const [projects, setProjects] = useState<Project[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
async function load() {
try {
const [h, p] = await Promise.all([getHealth(), listProjects()])
setHealth(h.data)
setProjects(p.data)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}
load()
const id = setInterval(load, 30000)
return () => clearInterval(id)
}, [])
if (loading) return <div className="card">Loading...</div>
if (error) return <div className="card status-error">Error: {error}</div>
return (
<div>
<h2>Dashboard</h2>
<div className="card">
<h3>Projects</h3>
<div className="grid-2">
{projects.map((p) => (
<div key={p.id} className="health-card">
<h4>{p.key}</h4>
<p>{p.name}</p>
<p className="status-ok">{p.status}</p>
</div>
))}
</div>
</div>
<div className="card">
<h3>Agent Health</h3>
<div className="grid-4">
{health.map((h) => (
<div key={h.profile} className="health-card">
<h4>{h.profile}</h4>
<p>{h.current_model || 'not set'}</p>
<p className={`status-${h.status}`}>{h.status}</p>
<p>{h.latency_ms != null ? `${h.latency_ms} ms` : '-'}</p>
</div>
))}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,42 @@
import { useEffect, useState } from 'react'
import { getMemory, putMemory } from '../api'
export default function Memory() {
const [content, setContent] = useState('')
const [saved, setSaved] = useState(false)
const [loading, setLoading] = useState(false)
useEffect(() => {
getMemory().then((r) => setContent(r.data.content))
}, [])
async function handleSave() {
setLoading(true)
try {
await putMemory(content)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} finally {
setLoading(false)
}
}
return (
<div>
<h2>Memory</h2>
<div className="card memory-editor">
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Global project memory (MARKDOWN supported in MVP)..."
/>
<div>
<button onClick={handleSave} disabled={loading}>
{loading ? 'Saving...' : 'Save Memory'}
</button>
{saved && <span style={{ marginLeft: 12, color: 'var(--accent-2)' }}>Saved</span>}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,47 @@
import { useEffect, useState } from 'react'
import { listRuns, type Run } from '../api'
export default function Runs() {
const [runs, setRuns] = useState<Run[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
async function load() {
try {
const r = await listRuns()
setRuns(r.data)
} finally {
setLoading(false)
}
}
load()
const id = setInterval(load, 5000)
return () => clearInterval(id)
}, [])
if (loading) return <div className="card">Loading...</div>
return (
<div>
<h2>Runs</h2>
<div className="run-list">
{runs.length === 0 && <div className="card">No runs yet.</div>}
{runs.map((run) => (
<div key={run.id} className="run-item">
<div>
<strong>{run.profile}</strong>{' '}
<span className={`status-${run.status}`}>{run.status}</span>
<span style={{ color: 'var(--muted)', marginLeft: 12, fontSize: '0.85rem' }}>
{new Date(run.created_at).toLocaleString()}
</span>
</div>
<p>{run.prompt}</p>
{(run.output || run.error) && (
<pre>{run.error ? `ERROR: ${run.error}` : run.output}</pre>
)}
</div>
))}
</div>
</div>
)
}

21
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

File diff suppressed because one or more lines are too long

17
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// Vite config for ACC Web UI
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://127.0.0.1:8100',
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
})