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 для управления агентами
``` ### Часть 1: Control Plane API ✅
POST /api/v1/runs — запустить задачу на worker-профиле **Репозиторий:** [agent-control-center-server](https://github.com/ochenstarik-ui/agent-control-center-server)
GET /api/v1/runs/{id} — статус задачи **Порт:** `localhost:8100`
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} — запись памяти
```
Что реально под капотом: дёргает `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) ### Часть 2: Web UI ✅
**Время:** 3-4 дня | **Результат:** веб-интерфейс **URL:** `http://localhost:8100/ui`
- Чат с ИИ (как в десктопе, но в браузере) **Сайдбар:**
- Дашборд worker-профилей - 🤖 **Агенты** — список worker-профилей (+добавление локальных агентов)
- Запуск задачи: выбрать профиль → написать задачу → следить - Зелёный/красный/оранжевый индикатор здоровья
- Страница проектов и Kanban-досок - Добавление агента: выбор провайдера → выбор модели → оркестрация
- Memory/Wiki браузер - Оркестрация: авто-создание 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) ### Часть 3: Desktop ✅
**Время:** 2-3 дня | **Результат:** мобильное приложение **Репозиторий:** [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-уведомления - Push-уведомления
- Share intent (поделиться текстом → создать задачу) - Share intent
## Часть 5: Supervisor Agent (уже готов) ### Часть 6: Connector SDK
**Время:** 0 | **Результат:** ✅ работает как cron - Python-библиотека для подключения runtime (Hermes, OpenClaw, etc.)
- Мониторинг провайдеров
- Авто-переключение профилей при отказе
## Часть 6: Connector SDK
**Время:** 2-3 дня | **Результат:** Python-библиотека
- Подключение Hermes/OpenClaw/других runtime
- Регистрация capabilities - Регистрация capabilities
- Heartbeat и health-check - Heartbeat и health-check
--- ### Доработки UI
- Drag-and-drop задач между проектами
- Kanban-доска
- История запусков с логами
- Тёмная/светлая тема
**Первым делом — Часть 1.** Создам FastAPI-сервер, который: ### Доработки API
1. Принимает задачу → отправляет в нужный worker-профиль через `hermes chat -q` - PostgreSQL вместо in-memory хранилища
2. Показывает статус всех профилей - Аутентификация пользователей
3. Управляет памятью (MEMORY.md) - 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,
},
})