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 для управления агентами
|
|
||||||
|
|
||||||
```
|
### Часть 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
|
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||