fix: чистая структура server/ + desktop/

This commit is contained in:
ochenstarik-ui 2026-07-20 21:36:40 +07:00
parent b42edf1e41
commit 44936cb5b2
9 changed files with 0 additions and 890 deletions

View file

View file

View file

@ -1,44 +0,0 @@
"""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])}

View file

@ -1,97 +0,0 @@
"""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"}

View file

@ -1,43 +0,0 @@
"""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)}

View file

@ -1,67 +0,0 @@
"""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]

View file

@ -1,29 +0,0 @@
"""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}

View file

@ -1,32 +0,0 @@
"""
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}

View file

@ -1,578 +0,0 @@
<!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>
<style>
:root, .dark {
--bg: #0d1117; --card: #161b22; --border: #30363d;
--text: #e6edf3; --muted: #8b949e; --accent: #58a6ff;
--green: #3fb950; --red: #f85149; --orange: #d2991d;
--radius: 8px;
}
.light {
--bg: #fff; --card: #f6f8fa; --border: #d0d7de;
--text: #1f2328; --muted: #656d76; --accent: #0969da;
--green: #1a7f37; --red: #cf222e; --orange: #9a6700;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); height: 100vh; display: flex; flex-direction: column; font-size: var(--font-size, 14px); }
.header { background: var(--card); border-bottom: 1px solid var(--border); padding: 8px 16px; display: flex; align-items: center; gap: 10px; flex-shrink: 0; min-height: 42px; }
.header h1 { font-size: 15px; font-weight: 600; }
.header .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); }
.hdr-btn { background: none; border: 1px solid var(--border); color: var(--muted); padding: 3px 8px; border-radius: 6px; cursor: pointer; font-size: 12px; }
.hdr-btn:hover { color: var(--text); border-color: var(--accent); }
.layout { display: grid; grid-template-columns: 260px 300px 1fr; flex: 1; overflow: hidden; }
.layout.no-proj { grid-template-columns: 260px 1fr; }
.layout.no-proj #col2 { display: none !important; }
.panel { border-right: 1px solid var(--border); overflow-y: auto; display: flex; flex-direction: column; }
.panel-header { padding: 10px 14px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.panel-header h3 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--muted); }
.panel-body { flex: 1; overflow-y: auto; }
.item { padding: 6px 12px; cursor: pointer; display: flex; align-items: center; gap: 6px; font-size: 0.85em; }
.item:hover { background: rgba(255,255,255,0.04); }
.item.active { background: rgba(88,166,255,0.1); border-right: 2px solid var(--accent); }
.item .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item .actions { display: none; gap: 2px; margin-left: auto; }
.item:hover .actions { display: flex; }
.item .actions button { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 11px; padding: 1px 3px; }
.item .actions button:hover { color: var(--text); }
.item .actions button.danger:hover { color: var(--red); }
.status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.sub-text { font-size: 0.75em; color: var(--muted); }
/* Chat area */
.main { display: flex; flex-direction: column; overflow: hidden; }
.tabs { display: flex; gap: 2px; padding: 8px 14px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.tab { padding: 5px 12px; border-radius: 6px; cursor: pointer; font-size: 0.85em; border: 1px solid transparent; color: var(--muted); }
.tab.active { background: var(--card); border-color: var(--border); color: var(--text); }
.messages { flex: 1; overflow-y: auto; padding: 14px; }
.msg { margin-bottom: 10px; max-width: 80%; }
.msg.user { margin-left: auto; }
.msg.user .bubble { background: var(--accent); color: #000; border-radius: 10px 10px 4px 10px; }
.msg.agent .bubble { background: var(--card); border: 1px solid var(--border); border-radius: 10px 10px 10px 4px; }
.msg.system .bubble { background: transparent; color: var(--muted); font-size: 0.8em; text-align: center; max-width: 100%; }
.bubble { padding: 8px 12px; font-size: 0.9em; line-height: 1.5; white-space: pre-wrap; }
.msg .meta { font-size: 0.65em; color: var(--muted); margin-top: 2px; padding: 0 4px; }
.input-area { display: flex; gap: 6px; padding: 10px 14px; border-top: 1px solid var(--border); flex-shrink: 0; }
.input-area textarea { flex: 1; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); padding: 8px 12px; font-size: 0.9em; resize: none; font-family: inherit; min-height: 38px; max-height: 80px; }
.input-area textarea:focus { outline: none; border-color: var(--accent); }
.input-area button { background: var(--accent); color: #000; border: none; border-radius: var(--radius); padding: 8px 16px; font-weight: 600; cursor: pointer; font-size: 0.9em; }
/* Project detail column */
.proj-detail { display: flex; flex-direction: column; }
.proj-section { border-bottom: 1px solid var(--border); flex-shrink: 0; }
.proj-section-header { padding: 8px 12px; font-size: 0.75em; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; display: flex; justify-content: space-between; align-items: center; }
.proj-section-body { padding: 4px 12px 8px; }
input, textarea, select { font-family: inherit; }
.fld { width: 100%; padding: 5px 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--text); font-size: 0.8em; margin-bottom: 4px; }
.fld:focus { outline: none; border-color: var(--accent); }
.btn { padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 0.8em; font-weight: 600; border: none; }
.btn-accent { background: var(--accent); color: #000; }
.btn-muted { background: var(--border); color: var(--text); }
.btn-danger { background: var(--red); color: #fff; }
.toggle { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.8em; }
.toggle-switch { width: 32px; height: 18px; background: var(--border); border-radius: 9px; position: relative; cursor: pointer; transition: background 0.2s; }
.toggle-switch.on { background: var(--green); }
.toggle-switch::after { content: ''; position: absolute; width: 14px; height: 14px; background: #fff; border-radius: 50%; top: 2px; left: 2px; transition: left 0.2s; }
.toggle-switch.on::after { left: 16px; }
.file-row { display: flex; align-items: center; gap: 4px; font-size: 0.75em; padding: 2px 0; }
.file-row .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.empty { color: var(--muted); font-size: 0.8em; padding: 4px 0; }
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; }
.dialog { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 20px; min-width: 300px; max-width: 450px; }
.dialog h4 { margin-bottom: 8px; font-size: 1em; }
.dialog p { font-size: 0.85em; color: var(--muted); margin-bottom: 14px; }
.dialog .btns { display: flex; gap: 6px; justify-content: flex-end; }
</style>
</head>
<body>
<div class="header">
<span class="dot"></span>
<h1>Agent Control Center</h1>
<div style="margin-left:auto;display:flex;gap:4px">
<button onclick="switchTab('skills')" class="hdr-btn" title="Скиллы">📦</button>
<button onclick="switchTab('memory')" class="hdr-btn" title="Память">🧠</button>
<button onclick="switchTab('supervisor-log')" class="hdr-btn" title="Лог надзора">👁</button>
<button onclick="switchTab('settings')" class="hdr-btn" title="Настройки"></button>
</div>
</div>
<div class="layout">
<!-- COL 1: Agents + Projects -->
<div class="panel" id="col1">
<div class="panel-header"><h3>🤖 Агенты</h3><span class="sub-text" id="agent-count">0</span></div>
<div class="panel-body" id="agents-body"></div>
<div class="panel-header" style="border-top:1px solid var(--border)"><h3>📁 Проекты</h3><button onclick="showNewProject()" class="hdr-btn">+</button></div>
<div class="panel-body" id="projects-body" style="flex:1"></div>
</div>
<!-- COL 2: Project detail -->
<div class="panel proj-detail" id="col2" style="display:none">
<div class="panel-header"><h3 id="proj-detail-name">Проект</h3></div>
<div class="panel-body" id="proj-detail-body">
<div class="empty" style="padding:12px">Выберите проект</div>
</div>
</div>
<!-- COL 3: Chat -->
<div class="main">
<div class="tabs">
<div class="tab active" onclick="switchTab('chat')">💬 Чат</div>
</div>
<div id="tab-chat" class="messages" id="messages">
<div class="msg system"><div class="bubble">Выберите агента и проект слева, затем напишите задачу</div></div>
</div>
<div id="tab-skills" style="display:none;flex:1;overflow-y:auto;padding:14px"><h3>📦 Скиллы</h3><div id="skills-list"></div></div>
<div id="tab-memory" style="display:none;flex:1;overflow-y:auto;padding:14px"><h3>🧠 Память</h3><div id="memory-entries"></div><div class="input-area" style="border-top:none;padding:8px 0"><input id="mem-input" placeholder="Новая запись..." class="fld" style="flex:1;margin:0" onkeydown="if(event.key==='Enter')saveMemory()"><button onclick="saveMemory()" class="btn btn-accent">💾</button></div></div>
<div id="tab-supervisor-log" style="display:none;flex:1;overflow-y:auto;padding:14px"><h3>👁 Лог надзирателя</h3><div id="supervisor-log"></div></div>
<div id="tab-settings" style="display:none;flex:1;overflow-y:auto;padding:14px"><h3>⚙ Настройки</h3><div id="settings-content"></div></div>
<div class="input-area">
<textarea id="input" placeholder="Опиши задачу... (Enter — отправить)" rows="1" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendRun()}"></textarea>
<button id="send-btn" onclick="sendRun()"></button>
</div>
</div>
</div>
<div class="overlay" id="dlg" style="display:none">
<div class="dialog" id="dlg-content"></div>
</div>
<script>
const API = 'http://localhost:8100';
let activeAgent = null, activeProject = null;
// ── Storage ──────────────────────────────────────────
let projects = JSON.parse(localStorage.getItem('acc_projects_v2') || '[]');
function save() { localStorage.setItem('acc_projects_v2', JSON.stringify(projects)); }
// ── Agents ───────────────────────────────────────────
async function loadAgents() {
try {
const r = await fetch(API + '/api/v1/agents/health');
const data = await r.json();
document.getElementById('agent-count').textContent = Object.keys(data).length;
let h = '';
for (const [id, info] of Object.entries(data)) {
h += `<div class="item${activeAgent===id?' active':''}" onclick="selectAgent('${id}')">
<span class="status-dot" style="background:${info.healthy?'var(--green)':'var(--red)'}"></span>
<span class="name">${id.replace('worker-','')}</span>
<span class="sub-text">${(info.model||'').split('/').pop()}</span>
</div>`;
}
document.getElementById('agents-body').innerHTML = h || '<div class="empty" style="padding:8px">Нет агентов</div>';
} catch(e) {}
}
function selectAgent(id) { activeAgent = id; loadAgents(); addMsg('system', 'Агент: ' + id.replace('worker-','')); }
// ── Projects ─────────────────────────────────────────
function renderProjects() {
const body = document.getElementById('projects-body');
if (!projects.length) {
body.innerHTML = '<div class="empty" style="padding:8px">Нет проектов. Нажмите +</div>';
return;
}
body.innerHTML = projects.map(p => `
<div class="item${activeProject===p.id?' active':''}" onclick="openProject('${p.id}')">
<span class="name">📁 ${esc(p.name)}</span>
<div class="actions">
<button onclick="event.stopPropagation();deleteProject('${p.id}')" class="danger" title="Удалить"></button>
</div>
</div>
`).join('');
}
function showNewProject() {
const dlg = document.getElementById('dlg');
document.getElementById('dlg-content').innerHTML = `
<h4>Новый проект</h4>
<input id="new-proj-name" class="fld" placeholder="Название проекта" autofocus>
<textarea id="new-proj-desc" class="fld" placeholder="Описание (опционально)" rows="2" style="resize:vertical"></textarea>
<div class="btns">
<button onclick="closeDlg()" class="btn btn-muted">Отмена</button>
<button onclick="createProject()" class="btn btn-accent">Создать</button>
</div>`;
dlg.style.display = 'flex';
document.getElementById('new-proj-name').focus();
}
function createProject() {
const name = document.getElementById('new-proj-name').value.trim();
if (!name) return;
const desc = document.getElementById('new-proj-desc').value.trim();
projects.push({
id: Date.now().toString(36), name, description: desc,
supervisor: { enabled: false, rules: '' },
connectors: [],
files: [],
folder: '',
tasks: []
});
save(); renderProjects(); closeDlg();
}
function openProject(id) {
activeProject = id;
renderProjects();
renderProjectDetail();
document.getElementById('col2').style.display = 'flex';
document.querySelector('.layout').classList.remove('no-proj');
}
function deleteProject(id) {
const p = projects.find(p => p.id === id);
showConfirm('Удалить проект «' + p.name + '»?', () => {
projects = projects.filter(p => p.id !== id);
if (activeProject === id) {
activeProject = null;
document.getElementById('col2').style.display = 'none';
document.querySelector('.layout').classList.add('no-proj');
}
save(); renderProjects();
});
}
// ── Project Detail ───────────────────────────────────
function renderProjectDetail() {
const p = projects.find(p => p.id === activeProject);
if (!p) return;
document.getElementById('proj-detail-name').textContent = '📁 ' + p.name;
const body = document.getElementById('proj-detail-body');
// Supervisor
const sup = p.supervisor || {};
let html = `<div class="proj-section">
<div class="proj-section-header">👁 Надзиратель
<span class="toggle" onclick="toggleSupervisor()">
<span class="toggle-switch${sup.enabled?' on':''}"></span>
</span>
</div>
<div class="proj-section-body">
<textarea class="fld" id="sup-rules" placeholder="Правила мониторинга (напр: следить за задачами, переключать агента при отказе)" rows="3" onchange="saveSupRules(this.value)">${esc(sup.rules||'')}</textarea>
</div>
</div>`;
// Connectors
html += `<div class="proj-section">
<div class="proj-section-header">🔗 Коннекторы <button onclick="addConnector()" class="hdr-btn">+</button></div>
<div class="proj-section-body" id="connectors-list">`;
(p.connectors||[]).forEach((c,i) => {
html += `<div class="file-row"><span>${connIcon(c.type)}</span><span class="name">${esc(c.label||c.account||c.type)}</span>${c.token_valid?'<span style="color:var(--green);font-size:0.7em"></span>':'<span style="color:var(--red);font-size:0.7em"></span>'}<button onclick="removeConnector(${i})" class="hdr-btn danger"></button></div>`;
});
html += `</div></div>`;
// Files
html += `<div class="proj-section">
<div class="proj-section-header">📄 Файлы проекта <button onclick="pickFile()" class="hdr-btn">+</button></div>
<div class="proj-section-body" id="files-list">`;
(p.files||[]).forEach((f,i) => {
html += `<div class="file-row"><span>📄</span><span class="name">${esc(f.name||f)}</span><button onclick="removeFile(${i})" class="hdr-btn danger"></button></div>`;
});
html += `<input type="file" id="file-picker" style="display:none" onchange="onFilePicked(event)" multiple></div></div>`;
// Folder
html += `<div class="proj-section">
<div class="proj-section-header">📂 Папка проекта <button onclick="pickFolder()" class="hdr-btn">+</button></div>
<div class="proj-section-body" id="folder-body">
${p.folder ? `<div class="file-row"><span>📂</span><span class="name">${esc(p.folder)}</span><button onclick="clearFolder()" class="hdr-btn danger"></button></div>` : '<div class="empty">Путь не выбран</div>'}
</div>
</div>`;
// Tasks
html += `<div class="proj-section" style="flex:1;overflow-y:auto">
<div class="proj-section-header">📋 Задачи <button onclick="addProjectTask()" class="hdr-btn">+</button></div>
<div class="proj-section-body" id="proj-tasks-list">`;
(p.tasks||[]).forEach((t,i) => {
html += `<div class="file-row" style="padding:3px 0">
<span style="cursor:pointer" onclick="toggleProjTask(${i})">${t.done?'✅':'○'}</span>
<span class="name" style="${t.done?'text-decoration:line-through;color:var(--muted)':''}">${esc(t.text)}</span>
<button onclick="sendTaskToChat(${i})" class="hdr-btn" title="Отправить в чат">💬</button>
<button onclick="removeProjTask(${i})" class="hdr-btn danger"></button>
</div>`;
});
html += `</div></div>`;
body.innerHTML = html;
}
function toggleSupervisor() {
const p = projects.find(p => p.id === activeProject);
if (!p) return;
p.supervisor.enabled = !p.supervisor.enabled;
save(); renderProjectDetail();
}
function saveSupRules(v) {
const p = projects.find(p => p.id === activeProject);
if (p) { p.supervisor.rules = v; save(); }
}
function addConnector() {
const dlg = document.getElementById('dlg');
document.getElementById('dlg-content').innerHTML = `
<h4>Добавить коннектор</h4>
<p style="font-size:0.8em;color:var(--muted)">Выберите сервис для подключения. Потребуется авторизация OAuth.</p>
<select id="conn-type" class="fld">
<option value="github">🐙 GitHub — репозитории, issues, PR</option>
<option value="gmail">📧 Gmail — чтение почты</option>
<option value="gdrive">📂 Google Диск — файлы и папки</option>
<option value="ydisk">📂 Яндекс.Диск — файлы и папки</option>
<option value="gcal">📅 Google Календарь — события</option>
</select>
<div id="conn-status" style="font-size:0.8em;color:var(--orange);margin:8px 0"></div>
<div class="btns">
<button onclick="closeDlg()" class="btn btn-muted">Отмена</button>
<button onclick="connectOAuth()" class="btn btn-accent" id="conn-connect-btn">🔑 Подключить</button>
</div>`;
dlg.style.display = 'flex';
}
async function connectOAuth() {
const type = document.getElementById('conn-type').value;
const btn = document.getElementById('conn-connect-btn');
btn.disabled = true;
btn.textContent = 'Подключение...';
try {
// Call backend OAuth init endpoint
const r = await fetch(API + '/api/v1/connectors/oauth/init', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ provider: type, project_id: activeProject })
});
const data = await r.json();
if (data.auth_url) {
// Open OAuth URL in browser
window.open(data.auth_url, '_blank');
document.getElementById('conn-status').textContent = '⏳ Ожидание авторизации в браузере...';
// Poll for completion
pollConnectorAuth(data.state);
} else if (data.connected) {
addConnectorToList(type, data);
}
} catch(e) {
document.getElementById('conn-status').textContent = '❌ Сервер OAuth недоступен. Будет работать после настройки бэкенда.';
btn.disabled = false;
btn.textContent = '🔑 Подключить';
}
}
async function pollConnectorAuth(state) {
for (let i = 0; i < 30; i++) {
await new Promise(r => setTimeout(r, 2000));
try {
const r = await fetch(API + '/api/v1/connectors/oauth/check?state=' + state);
const d = await r.json();
if (d.connected) {
addConnectorToList(d.provider, d);
closeDlg();
return;
}
} catch(e) {}
}
document.getElementById('conn-status').textContent = '⏰ Таймаут авторизации. Попробуйте снова.';
document.getElementById('conn-connect-btn').disabled = false;
document.getElementById('conn-connect-btn').textContent = '🔑 Подключить';
}
function addConnectorToList(type, data) {
const p = projects.find(p => p.id === activeProject);
if (!p.connectors) p.connectors = [];
p.connectors.push({
type,
label: data.label || data.account || type,
account: data.account,
token_valid: true,
connected_at: new Date().toISOString()
});
save(); renderProjectDetail();
}
function removeConnector(i) {
const p = projects.find(p => p.id === activeProject);
p.connectors.splice(i, 1); save(); renderProjectDetail();
}
function pickFile() { document.getElementById('file-picker').click(); }
function onFilePicked(e) {
const p = projects.find(p => p.id === activeProject);
for (const f of e.target.files) {
p.files.push({ name: f.name, size: f.size, path: f.webkitRelativePath || f.name });
}
save(); renderProjectDetail();
}
function removeFile(i) {
const p = projects.find(p => p.id === activeProject);
p.files.splice(i, 1); save(); renderProjectDetail();
}
function pickFolder() {
const inp = document.createElement('input');
inp.type = 'file';
inp.webkitdirectory = true;
inp.onchange = function(e) {
const files = e.target.files;
if (files.length) {
const fullPath = files[0].webkitRelativePath;
const folder = fullPath.split('/')[0];
const p = projects.find(p => p.id === activeProject);
p.folder = folder;
save(); renderProjectDetail();
}
};
inp.click();
}
function clearFolder() {
const p = projects.find(p => p.id === activeProject);
p.folder = ''; save(); renderProjectDetail();
}
function addProjectTask() {
const dlg = document.getElementById('dlg');
document.getElementById('dlg-content').innerHTML = `
<h4>Новая задача</h4>
<textarea id="new-task-text" class="fld" placeholder="Текст задачи" rows="3" autofocus onkeydown="if(event.key==='Enter'&&event.ctrlKey)saveNewTask()"></textarea>
<div class="btns">
<button onclick="closeDlg()" class="btn btn-muted">Отмена</button>
<button onclick="saveNewTask()" class="btn btn-accent">Добавить</button>
</div>`;
dlg.style.display = 'flex';
document.getElementById('new-task-text').focus();
}
function saveNewTask() {
const text = document.getElementById('new-task-text').value.trim();
if (!text) return;
const p = projects.find(p => p.id === activeProject);
p.tasks.push({ text, done: false });
save(); renderProjectDetail(); closeDlg();
}
function toggleProjTask(i) {
const p = projects.find(p => p.id === activeProject);
p.tasks[i].done = !p.tasks[i].done;
save(); renderProjectDetail();
}
function removeProjTask(i) {
const p = projects.find(p => p.id === activeProject);
showConfirm('Удалить задачу «' + p.tasks[i].text + '»?', () => {
p.tasks.splice(i, 1); save(); renderProjectDetail();
});
}
function sendTaskToChat(i) {
const p = projects.find(p => p.id === activeProject);
document.getElementById('input').value = p.tasks[i].text;
switchTab('chat');
}
// ── Chat ─────────────────────────────────────────────
function addMsg(role, text) {
const div = document.createElement('div');
div.className = 'msg ' + role;
div.innerHTML = '<div class="bubble">' + text + '</div><div class="meta">' + new Date().toLocaleTimeString() + '</div>';
document.getElementById('tab-chat').appendChild(div);
div.scrollIntoView({behavior:'smooth'});
}
async function sendRun() {
if (!activeAgent) { addMsg('system', '⚠️ Выберите агента'); return; }
const goal = document.getElementById('input').value.trim();
if (!goal) return;
document.getElementById('input').value = '';
document.getElementById('send-btn').disabled = true;
addMsg('user', goal);
try {
const r = await fetch(API + '/api/v1/runs', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({agent: activeAgent, goal})
});
const run = await r.json();
addMsg('agent', '✅ Задача ' + run.run_id + ' запущена');
} catch(e) { addMsg('system', '❌ ' + e.message); }
document.getElementById('send-btn').disabled = false;
}
// ── Tabs ─────────────────────────────────────────────
function switchTab(tab) {
['chat','skills','memory','supervisor-log','settings'].forEach(t => {
const el = document.getElementById('tab-' + t);
if (el) el.style.display = t === tab ? 'block' : 'none';
});
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
if (tab === 'chat') document.querySelector('.tab')?.classList.add('active');
if (tab === 'skills') loadSkills();
if (tab === 'memory') loadMemory();
if (tab === 'supervisor-log') loadSupLog();
}
// ── Skills ───────────────────────────────────────────
async function loadSkills() {
try {
const r = await fetch(API + '/api/v1/skills');
const d = await r.json();
document.getElementById('skills-list').innerHTML = (d.skills||[]).map(s => '<div class="file-row"><span>📦 ' + esc(s.name) + '</span><span class="sub-text">' + esc(s.category||'') + '</span></div>').join('') || '<div class="empty">Нет скиллов</div>';
} catch(e) {}
}
// ── Memory ───────────────────────────────────────────
async function loadMemory() {
try {
const r = await fetch(API + '/api/v1/memory?limit=50');
const d = await r.json();
document.getElementById('memory-entries').innerHTML = (d.entries||[]).map(e => '<div class="file-row">' + esc(e.line) + '</div>').join('') || '<div class="empty">Нет записей</div>';
} catch(e) {}
}
async function saveMemory() {
const v = document.getElementById('mem-input').value.trim();
if (!v) return;
await fetch(API + '/api/v1/memory/' + Date.now(), {method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({key:String(Date.now()),value:v})});
document.getElementById('mem-input').value = '';
loadMemory();
}
// ── Supervisor Log ───────────────────────────────────
let supLog = JSON.parse(localStorage.getItem('acc_sup_log')||'[]');
function addSupLog(msg) {
supLog.push({time: new Date().toLocaleTimeString(), msg});
supLog = supLog.slice(-100);
localStorage.setItem('acc_sup_log', JSON.stringify(supLog));
}
function loadSupLog() {
document.getElementById('supervisor-log').innerHTML = supLog.map(l => '<div class="file-row">[' + l.time + '] ' + l.msg + '</div>').join('') || '<div class="empty">Нет событий</div>';
}
// ── Settings ─────────────────────────────────────────
let settings = JSON.parse(localStorage.getItem('acc_settings')||'{"theme":"dark","fontsize":"14"}');
function loadSettings() {
document.documentElement.className = settings.theme || 'dark';
document.documentElement.style.setProperty('--font-size', (settings.fontsize||14)+'px');
document.getElementById('settings-content').innerHTML = `
<div style="margin-bottom:12px"><label class="sub-text">Тема</label><br>
<select class="fld" onchange="setSetting('theme',this.value)"><option value="dark" ${settings.theme==='dark'?'selected':''}>🌙 Тёмная</option><option value="light" ${settings.theme==='light'?'selected':''}>☀️ Светлая</option></select></div>
<div style="margin-bottom:12px"><label class="sub-text">Шрифт</label><br>
<select class="fld" onchange="setSetting('fontsize',this.value)"><option value="12" ${settings.fontsize==='12'?'selected':''}>12px</option><option value="14" ${settings.fontsize==='14'?'selected':''}>14px</option><option value="16" ${settings.fontsize==='16'?'selected':''}>16px</option><option value="18" ${settings.fontsize==='18'?'selected':''}>18px</option></select></div>
<button onclick="localStorage.clear();location.reload()" class="btn btn-danger">🗑 Сбросить всё</button>`;
}
function setSetting(k,v) { settings[k]=v; localStorage.setItem('acc_settings',JSON.stringify(settings)); loadSettings(); }
// ── Utils ────────────────────────────────────────────
function esc(s) { return (s||'').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function connIcon(t) { return {github:'🐙',gmail:'📧',gdrive:'📂',ydisk:'📂',gcal:'📅'}[t]||'🔗'; }
function closeDlg() { document.getElementById('dlg').style.display = 'none'; }
function showConfirm(text, cb) {
document.getElementById('dlg-content').innerHTML = '<h4>Подтверждение</h4><p>' + text + '</p><div class="btns"><button onclick="closeDlg()" class="btn btn-muted">Отмена</button><button onclick="closeDlg();cb()" class="btn btn-danger">Удалить</button></div>';
document.getElementById('dlg').style.display = 'flex';
}
// ── Start ────────────────────────────────────────────
loadSettings();
loadAgents();
renderProjects();
setInterval(loadAgents, 30000);
// No project selected yet — use 2-column layout
document.querySelector('.layout').classList.add('no-proj');
</script>
</body>
</html>