diff --git a/desktop b/desktop new file mode 160000 index 0000000..2ecfa2a --- /dev/null +++ b/desktop @@ -0,0 +1 @@ +Subproject commit 2ecfa2aa1cd794554163f9b16314d8651ba0f4e0 diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/api/__init__.py b/server/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/api/agents.py b/server/api/agents.py new file mode 100644 index 0000000..ea4487e --- /dev/null +++ b/server/api/agents.py @@ -0,0 +1,44 @@ +"""Health-check и статус worker-профилей""" +from __future__ import annotations +import subprocess, json +from fastapi import APIRouter + +router = APIRouter(prefix="/api/v1/agents", tags=["agents"]) + +PROFILES = ["worker-code", "worker-fast", "worker-research", "worker-review"] +API_KEY = "sk-a345af809e8a26f0693b9405344edc8adc5b5a96" +HEALTH_URL = "http://localhost:20127/v1/chat/completions" + +MODEL_MAP = { + "worker-code": "opencode-go/kimi-k2.7-code", + "worker-fast": "opencode-go/kimi-k2.7-code", + "worker-research": "opencode-go/kimi-k2.7-code", + "worker-review": "opencode-go/kimi-k2.7-code", +} + +def _check_provider(model: str) -> str: + try: + import urllib.request, urllib.error + body = json.dumps({"model": model, "messages": [{"role": "user", "content": "Hi"}], "max_tokens": 1, "stream": False}).encode() + req = urllib.request.Request(HEALTH_URL, data=body, headers={"Content-Type": "application/json", "Authorization": f"Bearer {API_KEY}"}) + urllib.request.urlopen(req, timeout=10) + return "ok" + except urllib.error.HTTPError as e: + return f"http_{e.code}" + except Exception: + return "timeout" + +@router.get("/health") +def agents_health(): + result = {} + for profile in PROFILES: + model = MODEL_MAP[profile] + status = _check_provider(model) + result[profile] = {"model": model, "provider_status": status, "healthy": status == "ok"} + return result + +@router.get("/{agent_id}") +def agent_info(agent_id: str): + if agent_id not in PROFILES: + return {"error": "unknown agent"} + return {"agent_id": agent_id, "model": MODEL_MAP[agent_id], "status": _check_provider(MODEL_MAP[agent_id])} diff --git a/server/api/connectors.py b/server/api/connectors.py new file mode 100644 index 0000000..09ecc2d --- /dev/null +++ b/server/api/connectors.py @@ -0,0 +1,97 @@ +"""OAuth коннекторы — GitHub, Gmail, Google Drive, Yandex Disk, Google Calendar""" +from __future__ import annotations +import secrets, json +from fastapi import APIRouter +from pydantic import BaseModel + +router = APIRouter(prefix="/api/v1/connectors", tags=["connectors"]) + +# In-memory OAuth state store (заменить на БД в production) +_pending: dict[str, dict] = {} +_connections: dict[str, list[dict]] = {} + +OAUTH_CONFIG = { + "github": { + "name": "GitHub", + "auth_url": "https://github.com/login/oauth/authorize", + "scope": "repo,user", + }, + "gmail": { + "name": "Gmail", + "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", + "scope": "https://www.googleapis.com/auth/gmail.readonly", + }, + "gdrive": { + "name": "Google Drive", + "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", + "scope": "https://www.googleapis.com/auth/drive.readonly", + }, + "gcal": { + "name": "Google Calendar", + "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", + "scope": "https://www.googleapis.com/auth/calendar.readonly", + }, + "ydisk": { + "name": "Yandex Disk", + "auth_url": "https://oauth.yandex.ru/authorize", + "scope": "cloud_api:disk.read", + }, +} + +class OAuthInitRequest(BaseModel): + provider: str + project_id: str + +@router.post("/oauth/init") +def oauth_init(req: OAuthInitRequest): + if req.provider not in OAUTH_CONFIG: + return {"error": "unknown_provider"} + + cfg = OAUTH_CONFIG[req.provider] + state = secrets.token_urlsafe(16) + _pending[state] = {"provider": req.provider, "project_id": req.project_id} + + # В production здесь формируется полный URL с client_id и redirect_uri + client_id = "ACC_CLIENT_ID_PLACEHOLDER" + redirect_uri = "http://localhost:8100/api/v1/connectors/oauth/callback" + auth_url = f"{cfg['auth_url']}?client_id={client_id}&redirect_uri={redirect_uri}&scope={cfg['scope']}&state={state}&response_type=code" + + return {"auth_url": auth_url, "state": state, "provider": req.provider} + +@router.get("/oauth/callback") +def oauth_callback(code: str, state: str): + """OAuth callback — обработка кода авторизации""" + if state not in _pending: + return {"error": "invalid_state"} + + pending = _pending.pop(state) + provider = pending["provider"] + + # В production: обменять code на access_token через POST к token endpoint + connection = { + "provider": provider, + "account": f"{provider}_user", + "label": OAUTH_CONFIG[provider]["name"], + "token_valid": True, + "state": state, + } + + if pending["project_id"] not in _connections: + _connections[pending["project_id"]] = [] + _connections[pending["project_id"]].append(connection) + + return {"connected": True, "provider": provider, **connection} + +@router.get("/oauth/check") +def oauth_check(state: str): + """Проверка статуса OAuth-авторизации (polling)""" + if state in _pending: + return {"connected": False, "status": "pending"} + + # Найти connection по state + for conns in _connections.values(): + for c in conns: + if c.get("state") == state: + return {"connected": True, **c} + + return {"connected": False, "status": "expired"} diff --git a/server/api/memory.py b/server/api/memory.py new file mode 100644 index 0000000..2577f21 --- /dev/null +++ b/server/api/memory.py @@ -0,0 +1,43 @@ +"""API для работы с памятью (MEMORY.md)""" +from __future__ import annotations +import os +from pathlib import Path +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +router = APIRouter(prefix="/api/v1/memory", tags=["memory"]) + +HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) + +class MemoryEntry(BaseModel): + key: str + value: str + +@router.get("/{key}") +def get_memory(key: str): + path = HERMES_HOME / "memories" / "MEMORY.md" + if not path.exists(): + return {"key": key, "value": None, "found": False} + content = path.read_text(encoding="utf-8") + # Поиск секции ключа + for line in content.split("\n"): + if key.lower() in line.lower(): + return {"key": key, "value": line.strip(), "found": True, "source": str(path)} + return {"key": key, "value": None, "found": False} + +@router.put("/{key}") +def set_memory(key: str, entry: MemoryEntry): + path = HERMES_HOME / "memories" / "MEMORY.md" + path.parent.mkdir(parents=True, exist_ok=True) + content = path.read_text(encoding="utf-8") if path.exists() else "" + content += f"\n{entry.value}" + path.write_text(content, encoding="utf-8") + return {"status": "saved"} + +@router.get("") +def list_memory(limit: int = 20): + path = HERMES_HOME / "memories" / "MEMORY.md" + if not path.exists(): + return {"entries": []} + lines = [l.strip() for l in path.read_text(encoding="utf-8").split("\n") if l.strip() and not l.startswith("#")] + return {"entries": [{"line": l} for l in lines[:limit]], "total": len(lines)} diff --git a/server/api/runs.py b/server/api/runs.py new file mode 100644 index 0000000..5412a3c --- /dev/null +++ b/server/api/runs.py @@ -0,0 +1,67 @@ +"""API для запуска задач на worker-профилях""" +from __future__ import annotations +import subprocess, uuid, json, os +from datetime import datetime, timezone +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +router = APIRouter(prefix="/api/v1/runs", tags=["runs"]) + +# In-memory storage (до БД в следующих частях) +_runs: dict[str, dict] = {} + +HERMES = os.environ.get("HERMES_BIN", "hermes") + +class RunRequest(BaseModel): + agent: str = Field(description="worker-code | worker-fast | worker-research | worker-review") + goal: str = Field(min_length=3, max_length=2000) + model: str | None = None + +class RunResponse(BaseModel): + run_id: str + agent: str + goal: str + status: str + created_at: str + +@router.post("", response_model=RunResponse) +def create_run(req: RunRequest): + if req.agent not in ("worker-code", "worker-fast", "worker-research", "worker-review"): + raise HTTPException(400, f"Неизвестный агент: {req.agent}") + + run_id = uuid.uuid4().hex[:12] + created = datetime.now(timezone.utc).isoformat() + cmd = [HERMES, "-p", req.agent, "chat", "-q", req.goal, "-Q"] + if req.model: + cmd.extend(["-m", req.model]) + + # Запуск в фоне + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + _runs[run_id] = { + "run_id": run_id, "agent": req.agent, "goal": req.goal, + "status": "running", "created_at": created, "pid": process.pid, + } + return RunResponse(run_id=run_id, agent=req.agent, goal=req.goal, status="running", created_at=created) + +@router.get("") +def list_runs(agent: str | None = None, limit: int = 20): + result = list(_runs.values()) + if agent: + result = [r for r in result if r["agent"] == agent] + # Check status for running processes + for r in result: + if r["status"] == "running": + try: + pid = r.get("pid") + if pid: + os.kill(pid, 0) # check if alive + except (OSError, ProcessLookupError): + r["status"] = "completed" + return {"runs": sorted(result, key=lambda r: r["created_at"], reverse=True)[:limit]} + +@router.get("/{run_id}") +def get_run(run_id: str): + if run_id not in _runs: + raise HTTPException(404, "Задача не найдена") + return _runs[run_id] diff --git a/server/api/skills.py b/server/api/skills.py new file mode 100644 index 0000000..69db8eb --- /dev/null +++ b/server/api/skills.py @@ -0,0 +1,29 @@ +"""API для скиллов""" +from __future__ import annotations +import os, json +from pathlib import Path +from fastapi import APIRouter + +router = APIRouter(prefix="/api/v1/skills", tags=["skills"]) + +HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) + +@router.get("") +def list_skills(): + skills = [] + skills_dir = HERMES_HOME / "skills" + if not skills_dir.exists(): + return {"skills": []} + for d in sorted(skills_dir.iterdir()): + if d.is_dir(): + md = d / "SKILL.md" + if md.exists(): + content = md.read_text(encoding="utf-8", errors="replace") + name = d.name + category = "" + for line in content.split("\n")[:10]: + if line.startswith("category:"): + category = line.split(":", 1)[1].strip() + break + skills.append({"name": name, "category": category, "path": str(d)}) + return {"skills": skills} diff --git a/server/app/__init__.py b/server/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/api/__init__.py b/server/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/api/agents.py b/server/app/api/agents.py new file mode 100644 index 0000000..ea4487e --- /dev/null +++ b/server/app/api/agents.py @@ -0,0 +1,44 @@ +"""Health-check и статус worker-профилей""" +from __future__ import annotations +import subprocess, json +from fastapi import APIRouter + +router = APIRouter(prefix="/api/v1/agents", tags=["agents"]) + +PROFILES = ["worker-code", "worker-fast", "worker-research", "worker-review"] +API_KEY = "sk-a345af809e8a26f0693b9405344edc8adc5b5a96" +HEALTH_URL = "http://localhost:20127/v1/chat/completions" + +MODEL_MAP = { + "worker-code": "opencode-go/kimi-k2.7-code", + "worker-fast": "opencode-go/kimi-k2.7-code", + "worker-research": "opencode-go/kimi-k2.7-code", + "worker-review": "opencode-go/kimi-k2.7-code", +} + +def _check_provider(model: str) -> str: + try: + import urllib.request, urllib.error + body = json.dumps({"model": model, "messages": [{"role": "user", "content": "Hi"}], "max_tokens": 1, "stream": False}).encode() + req = urllib.request.Request(HEALTH_URL, data=body, headers={"Content-Type": "application/json", "Authorization": f"Bearer {API_KEY}"}) + urllib.request.urlopen(req, timeout=10) + return "ok" + except urllib.error.HTTPError as e: + return f"http_{e.code}" + except Exception: + return "timeout" + +@router.get("/health") +def agents_health(): + result = {} + for profile in PROFILES: + model = MODEL_MAP[profile] + status = _check_provider(model) + result[profile] = {"model": model, "provider_status": status, "healthy": status == "ok"} + return result + +@router.get("/{agent_id}") +def agent_info(agent_id: str): + if agent_id not in PROFILES: + return {"error": "unknown agent"} + return {"agent_id": agent_id, "model": MODEL_MAP[agent_id], "status": _check_provider(MODEL_MAP[agent_id])} diff --git a/server/app/api/connectors.py b/server/app/api/connectors.py new file mode 100644 index 0000000..09ecc2d --- /dev/null +++ b/server/app/api/connectors.py @@ -0,0 +1,97 @@ +"""OAuth коннекторы — GitHub, Gmail, Google Drive, Yandex Disk, Google Calendar""" +from __future__ import annotations +import secrets, json +from fastapi import APIRouter +from pydantic import BaseModel + +router = APIRouter(prefix="/api/v1/connectors", tags=["connectors"]) + +# In-memory OAuth state store (заменить на БД в production) +_pending: dict[str, dict] = {} +_connections: dict[str, list[dict]] = {} + +OAUTH_CONFIG = { + "github": { + "name": "GitHub", + "auth_url": "https://github.com/login/oauth/authorize", + "scope": "repo,user", + }, + "gmail": { + "name": "Gmail", + "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", + "scope": "https://www.googleapis.com/auth/gmail.readonly", + }, + "gdrive": { + "name": "Google Drive", + "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", + "scope": "https://www.googleapis.com/auth/drive.readonly", + }, + "gcal": { + "name": "Google Calendar", + "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", + "scope": "https://www.googleapis.com/auth/calendar.readonly", + }, + "ydisk": { + "name": "Yandex Disk", + "auth_url": "https://oauth.yandex.ru/authorize", + "scope": "cloud_api:disk.read", + }, +} + +class OAuthInitRequest(BaseModel): + provider: str + project_id: str + +@router.post("/oauth/init") +def oauth_init(req: OAuthInitRequest): + if req.provider not in OAUTH_CONFIG: + return {"error": "unknown_provider"} + + cfg = OAUTH_CONFIG[req.provider] + state = secrets.token_urlsafe(16) + _pending[state] = {"provider": req.provider, "project_id": req.project_id} + + # В production здесь формируется полный URL с client_id и redirect_uri + client_id = "ACC_CLIENT_ID_PLACEHOLDER" + redirect_uri = "http://localhost:8100/api/v1/connectors/oauth/callback" + auth_url = f"{cfg['auth_url']}?client_id={client_id}&redirect_uri={redirect_uri}&scope={cfg['scope']}&state={state}&response_type=code" + + return {"auth_url": auth_url, "state": state, "provider": req.provider} + +@router.get("/oauth/callback") +def oauth_callback(code: str, state: str): + """OAuth callback — обработка кода авторизации""" + if state not in _pending: + return {"error": "invalid_state"} + + pending = _pending.pop(state) + provider = pending["provider"] + + # В production: обменять code на access_token через POST к token endpoint + connection = { + "provider": provider, + "account": f"{provider}_user", + "label": OAUTH_CONFIG[provider]["name"], + "token_valid": True, + "state": state, + } + + if pending["project_id"] not in _connections: + _connections[pending["project_id"]] = [] + _connections[pending["project_id"]].append(connection) + + return {"connected": True, "provider": provider, **connection} + +@router.get("/oauth/check") +def oauth_check(state: str): + """Проверка статуса OAuth-авторизации (polling)""" + if state in _pending: + return {"connected": False, "status": "pending"} + + # Найти connection по state + for conns in _connections.values(): + for c in conns: + if c.get("state") == state: + return {"connected": True, **c} + + return {"connected": False, "status": "expired"} diff --git a/server/app/api/memory.py b/server/app/api/memory.py new file mode 100644 index 0000000..2577f21 --- /dev/null +++ b/server/app/api/memory.py @@ -0,0 +1,43 @@ +"""API для работы с памятью (MEMORY.md)""" +from __future__ import annotations +import os +from pathlib import Path +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +router = APIRouter(prefix="/api/v1/memory", tags=["memory"]) + +HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) + +class MemoryEntry(BaseModel): + key: str + value: str + +@router.get("/{key}") +def get_memory(key: str): + path = HERMES_HOME / "memories" / "MEMORY.md" + if not path.exists(): + return {"key": key, "value": None, "found": False} + content = path.read_text(encoding="utf-8") + # Поиск секции ключа + for line in content.split("\n"): + if key.lower() in line.lower(): + return {"key": key, "value": line.strip(), "found": True, "source": str(path)} + return {"key": key, "value": None, "found": False} + +@router.put("/{key}") +def set_memory(key: str, entry: MemoryEntry): + path = HERMES_HOME / "memories" / "MEMORY.md" + path.parent.mkdir(parents=True, exist_ok=True) + content = path.read_text(encoding="utf-8") if path.exists() else "" + content += f"\n{entry.value}" + path.write_text(content, encoding="utf-8") + return {"status": "saved"} + +@router.get("") +def list_memory(limit: int = 20): + path = HERMES_HOME / "memories" / "MEMORY.md" + if not path.exists(): + return {"entries": []} + lines = [l.strip() for l in path.read_text(encoding="utf-8").split("\n") if l.strip() and not l.startswith("#")] + return {"entries": [{"line": l} for l in lines[:limit]], "total": len(lines)} diff --git a/server/app/api/runs.py b/server/app/api/runs.py new file mode 100644 index 0000000..5412a3c --- /dev/null +++ b/server/app/api/runs.py @@ -0,0 +1,67 @@ +"""API для запуска задач на worker-профилях""" +from __future__ import annotations +import subprocess, uuid, json, os +from datetime import datetime, timezone +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +router = APIRouter(prefix="/api/v1/runs", tags=["runs"]) + +# In-memory storage (до БД в следующих частях) +_runs: dict[str, dict] = {} + +HERMES = os.environ.get("HERMES_BIN", "hermes") + +class RunRequest(BaseModel): + agent: str = Field(description="worker-code | worker-fast | worker-research | worker-review") + goal: str = Field(min_length=3, max_length=2000) + model: str | None = None + +class RunResponse(BaseModel): + run_id: str + agent: str + goal: str + status: str + created_at: str + +@router.post("", response_model=RunResponse) +def create_run(req: RunRequest): + if req.agent not in ("worker-code", "worker-fast", "worker-research", "worker-review"): + raise HTTPException(400, f"Неизвестный агент: {req.agent}") + + run_id = uuid.uuid4().hex[:12] + created = datetime.now(timezone.utc).isoformat() + cmd = [HERMES, "-p", req.agent, "chat", "-q", req.goal, "-Q"] + if req.model: + cmd.extend(["-m", req.model]) + + # Запуск в фоне + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + _runs[run_id] = { + "run_id": run_id, "agent": req.agent, "goal": req.goal, + "status": "running", "created_at": created, "pid": process.pid, + } + return RunResponse(run_id=run_id, agent=req.agent, goal=req.goal, status="running", created_at=created) + +@router.get("") +def list_runs(agent: str | None = None, limit: int = 20): + result = list(_runs.values()) + if agent: + result = [r for r in result if r["agent"] == agent] + # Check status for running processes + for r in result: + if r["status"] == "running": + try: + pid = r.get("pid") + if pid: + os.kill(pid, 0) # check if alive + except (OSError, ProcessLookupError): + r["status"] = "completed" + return {"runs": sorted(result, key=lambda r: r["created_at"], reverse=True)[:limit]} + +@router.get("/{run_id}") +def get_run(run_id: str): + if run_id not in _runs: + raise HTTPException(404, "Задача не найдена") + return _runs[run_id] diff --git a/server/app/api/skills.py b/server/app/api/skills.py new file mode 100644 index 0000000..69db8eb --- /dev/null +++ b/server/app/api/skills.py @@ -0,0 +1,29 @@ +"""API для скиллов""" +from __future__ import annotations +import os, json +from pathlib import Path +from fastapi import APIRouter + +router = APIRouter(prefix="/api/v1/skills", tags=["skills"]) + +HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) + +@router.get("") +def list_skills(): + skills = [] + skills_dir = HERMES_HOME / "skills" + if not skills_dir.exists(): + return {"skills": []} + for d in sorted(skills_dir.iterdir()): + if d.is_dir(): + md = d / "SKILL.md" + if md.exists(): + content = md.read_text(encoding="utf-8", errors="replace") + name = d.name + category = "" + for line in content.split("\n")[:10]: + if line.startswith("category:"): + category = line.split(":", 1)[1].strip() + break + skills.append({"name": name, "category": category, "path": str(d)}) + return {"skills": skills} diff --git a/server/app/main.py b/server/app/main.py new file mode 100644 index 0000000..5aa6820 --- /dev/null +++ b/server/app/main.py @@ -0,0 +1,32 @@ +""" +Agent Control Center — Control Plane API +Часть 1: управление агентами, задачами, памятью +Часть 2: Web UI +""" +from __future__ import annotations +from pathlib import Path +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from app.api import runs, agents, memory, skills, connectors + +app = FastAPI( + title="Agent Control Center", + version="0.1.0", + description="Control Plane для управления Hermes-агентами", +) +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) +app.include_router(runs.router) +app.include_router(agents.router) +app.include_router(memory.router) +app.include_router(skills.router) +app.include_router(connectors.router) + +# Web UI (доступен на /ui) +static = Path(__file__).parent / "static" +static.mkdir(exist_ok=True) +app.mount("/ui", StaticFiles(directory=str(static), html=True), name="static") + +@app.get("/health") +def health(): + return {"status": "ok", "version": app.version} diff --git a/server/app/static/index.html b/server/app/static/index.html new file mode 100644 index 0000000..0b341dc --- /dev/null +++ b/server/app/static/index.html @@ -0,0 +1,578 @@ + + +
+ + +