docs: актуальный статус реализации ACC (части 1-3,5 готовы)
0
backend/app/__init__.py
Normal file
252
backend/app/main.py
Normal 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
|
|
@ -0,0 +1,3 @@
|
|||
fastapi>=0.110.0
|
||||
uvicorn[standard]>=0.29.0
|
||||
pydantic>=2.6.0
|
||||
|
|
@ -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
|
|
@ -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
30
frontend/package.json
Normal 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
|
|
@ -0,0 +1,4 @@
|
|||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
25
frontend/src-tauri/Cargo.toml
Normal 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"
|
||||
3
frontend/src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
11
frontend/src-tauri/capabilities/default.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
]
|
||||
}
|
||||
BIN
frontend/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
frontend/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 9 KiB |
BIN
frontend/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
frontend/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
frontend/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
frontend/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
frontend/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
frontend/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
frontend/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
frontend/src-tauri/icons/icon.icns
Normal file
BIN
frontend/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
frontend/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
16
frontend/src-tauri/src/lib.rs
Normal 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");
|
||||
}
|
||||
6
frontend/src-tauri/src/main.rs
Normal 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();
|
||||
}
|
||||
38
frontend/src-tauri/tauri.conf.json
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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>,
|
||||
)
|
||||
67
frontend/src/pages/Chat.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
frontend/src/pages/Memory.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
frontend/src/pages/Runs.tsx
Normal 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
|
|
@ -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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
1
frontend/tsconfig.node.tsbuildinfo
Normal file
17
frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
})
|
||||