dashboard: вкладка Чат поверх портала-канала (t-261001100740) — GET/POST /api/chat*, дедуп-анкеры, коллапс ретраев, секрет команд скрыт; e2e: вопрос из UI → ответ агента в ленте
This commit is contained in:
parent
d369351e10
commit
5c02bc7cdd
2 changed files with 337 additions and 0 deletions
|
|
@ -69,6 +69,29 @@
|
|||
.digest p{white-space:pre-wrap;max-width:100%;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical}
|
||||
a{color:var(--signal)}
|
||||
footer{margin-top:46px;border-top:1px solid var(--line);padding-top:14px;font-family:ui-monospace,Menlo,monospace;font-size:11.5px;color:var(--dim)}
|
||||
|
||||
/* ── вкладки + чат (t-261001100740) ── */
|
||||
.tabs{display:flex;gap:6px;margin:26px 0 22px}
|
||||
.tab{font-family:ui-monospace,Menlo,monospace;font-size:12px;letter-spacing:.12em;text-transform:uppercase;font-weight:700;
|
||||
border:1px solid var(--ink);background:transparent;color:var(--dim);padding:9px 18px;cursor:pointer;border-radius:8px 8px 0 0}
|
||||
.tab.active{background:var(--ink);color:var(--paper)}
|
||||
.view{display:none}.view.active{display:block}
|
||||
.chat{display:flex;flex-direction:column;height:calc(100vh - 320px);min-height:420px;border:1px solid var(--ink);background:#fffdf8}
|
||||
.chat-log{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:10px}
|
||||
.chat-row{display:flex;max-width:86%}
|
||||
.chat-row.agent{align-self:flex-start}
|
||||
.chat-row.user{align-self:flex-end;flex-direction:row-reverse}
|
||||
.bubble{border:1px solid var(--line);background:var(--card);padding:9px 12px;font-size:14px;line-height:1.5;white-space:pre-wrap;word-wrap:break-word;max-width:100%}
|
||||
.chat-row.agent .bubble{background:#fff;border-left:3px solid var(--signal)}
|
||||
.chat-row.user .bubble{background:var(--ink);color:var(--paper);border:0}
|
||||
.bubble .who{display:block;font-family:ui-monospace,Menlo,monospace;font-size:10px;letter-spacing:.1em;text-transform:uppercase;opacity:.75;margin-bottom:3px}
|
||||
.bubble .pend{color:var(--warn);font-size:11px;font-style:italic}
|
||||
.chat-input{display:flex;gap:8px;border-top:1px solid var(--ink);padding:10px;background:var(--card)}
|
||||
.chat-input textarea{flex:1;resize:none;font:14px/1.5 -apple-system,"Segoe UI",Roboto,Arial,sans-serif;padding:9px 11px;border:1px solid var(--line);border-radius:6px;background:#fff;color:var(--ink);outline:none;max-height:140px}
|
||||
.chat-input textarea:focus{border-color:var(--signal)}
|
||||
.chat-send{border:1px solid var(--ink);background:var(--signal);color:#fff;font-weight:700;font-family:ui-monospace,Menlo,monospace;font-size:12px;letter-spacing:.08em;text-transform:uppercase;padding:0 20px;cursor:pointer;border-radius:6px}
|
||||
.chat-send:disabled{opacity:.45;cursor:default}
|
||||
.chat-note{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--dim);margin:8px 2px 0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -85,8 +108,15 @@
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<!-- вкладки (t-261001100740): Дашборд | Чат — одна страница, состояние сохраняем -->
|
||||
<div class="tabs" role="tablist">
|
||||
<button class="tab active" id="tab-dash" role="tab" aria-selected="true" onclick="setView('dash')">✓ Дашборд</button>
|
||||
<button class="tab" id="tab-chat" role="tab" aria-selected="false" onclick="setView('chat')">💬 Чат</button>
|
||||
</div>
|
||||
|
||||
<div class="errbox" id="errbox"></div>
|
||||
|
||||
<div class="view active" id="view-dash">
|
||||
<div class="stats" id="stats"></div>
|
||||
|
||||
<h2>Лестница уровней</h2>
|
||||
|
|
@ -103,6 +133,20 @@
|
|||
<h2>Свежие дайджесты</h2>
|
||||
<div class="lead">выжимки тиков всех уровней — последние 12</div>
|
||||
<div id="digests"><div class="skel">загружаю…</div></div>
|
||||
</div><!-- /view-dash -->
|
||||
|
||||
<div class="view" id="view-chat">
|
||||
<h2 style="margin-top:0">Чат с Simbiot</h2>
|
||||
<div class="lead">сообщение уходит агенту по порталу-каналу, ответ придёт сам (обычно ≤ 1 мин) · тоже дублируется в TG, если бот подключён</div>
|
||||
<div class="chat">
|
||||
<div class="chat-log" id="chat-log"><div class="skel">загружаю…</div></div>
|
||||
<div class="chat-input">
|
||||
<textarea id="chat-text" rows="1" placeholder="Сообщение агенту… (Enter — отправить, Shift+Enter — перенос)"></textarea>
|
||||
<button class="chat-send" id="chat-send" onclick="chatSend()">Отправить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-note" id="chat-note">канал: портал (portal:<agent>) · ответ агент пишет сам по такту</div>
|
||||
</div><!-- /view-chat -->
|
||||
|
||||
<footer>
|
||||
JSON-API: <code>/api/state</code> · healthcheck: <code>/api/health</code> ·
|
||||
|
|
@ -262,6 +306,95 @@ function render(s){
|
|||
|
||||
tick();
|
||||
setInterval(tick, REFRESH * 1000);
|
||||
|
||||
// ── вкладки + чат (t-261001100740) ──────────────────────────────────────
|
||||
function setView(v){
|
||||
document.querySelectorAll(".tab").forEach(t => {
|
||||
const on = t.id === "tab-" + v;
|
||||
t.classList.toggle("active", on);
|
||||
t.setAttribute("aria-selected", on ? "true" : "false");
|
||||
});
|
||||
document.querySelectorAll(".view").forEach(s => s.classList.toggle("active", s.id === "view-" + v));
|
||||
try { localStorage.simbiotView = v; } catch(e){}
|
||||
if (v === "chat") { chatTick(); } // первый рендер сразу, без ожидания таймера
|
||||
}
|
||||
try { const v0 = localStorage.simbiotView; if (v0 === "chat") setView("chat"); } catch(e){}
|
||||
|
||||
const CHAT_REFRESH = 10; // сек: канал тикает по сек-рефлексу, чаще нет смысла
|
||||
let chatTimer = null;
|
||||
let chatOpen = false;
|
||||
|
||||
function chatBubble(m){
|
||||
const who = m.from === "agent" ? "Simbiot" : "вы";
|
||||
const pend = m.pending ? ` <span class="pend">· ждёт ответа…</span>` : "";
|
||||
const rep = m.repeat > 1 ? ` ×${m.repeat}` : "";
|
||||
let t = "";
|
||||
try { const d = new Date(Number(m.ts)); if (!isNaN(d)) t = d.toISOString().slice(11,16) + " UTC"; } catch(e){}
|
||||
return `<div class="chat-row ${m.from === "agent" ? "agent" : "user"}">` +
|
||||
`<div class="bubble"><span class="who">${esc(who)}${t ? " · " + esc(t) : ""}${pend}${rep}</span>${esc(m.text || "")}</div></div>`;
|
||||
}
|
||||
|
||||
async function chatTick(){
|
||||
if (getViewName() !== "chat") return;
|
||||
chatOpen = true;
|
||||
const log = $("chat-log");
|
||||
try{
|
||||
const r = await fetch("api/chat?t=" + Date.now(), {cache:"no-store"});
|
||||
if(!r.ok) throw new Error("HTTP " + r.status);
|
||||
const s = await r.json();
|
||||
if(s.ok === false) throw new Error(s.error || "chat unavailable");
|
||||
const html = (s.messages || []).map(chatBubble).join("");
|
||||
const stick = log.scrollTop + log.clientHeight >= log.scrollHeight - 40; // прилипание к низу
|
||||
log.innerHTML = html || `<div class="skel">сообщений пока нет — напиши первым</div>`;
|
||||
if (stick) log.scrollTop = log.scrollHeight;
|
||||
$("chat-note").textContent = "канал: портал (portal:" + (s.agent || "?") + ") · ответ агент пишет сам по такту";
|
||||
}catch(e){
|
||||
if (e instanceof SyntaxError) return; // полу-JSON от 503 — следующий тик перепишет
|
||||
log.innerHTML = `<div class="skel">чат недоступен: ${esc(e.message)} — попробую через ${CHAT_REFRESH}с</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function getViewName(){
|
||||
return document.getElementById("view-dash").classList.contains("active") ? "dash" : "chat";
|
||||
}
|
||||
|
||||
async function chatSend(){
|
||||
const ta = $("chat-text");
|
||||
const text = (ta.value || "").trim();
|
||||
if(!text) return;
|
||||
const btn = $("chat-send");
|
||||
btn.disabled = true;
|
||||
try{
|
||||
const r = await fetch("api/chat/send", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({text})
|
||||
});
|
||||
if(!r.ok) throw new Error("HTTP " + r.status);
|
||||
const s = await r.json();
|
||||
if(s.ok === false) throw new Error(s.error || "send failed");
|
||||
ta.value = "";
|
||||
await chatTick();
|
||||
}catch(e){
|
||||
$("chat-note").textContent = "⚠ отправка не прошла: " + e.message + " — сообщение не потеряно, попробую снова на следующем тике";
|
||||
}finally{
|
||||
btn.disabled = false;
|
||||
ta.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// textarea: Enter = отправить, Shift+Enter = перенос строки; авто-рост
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const ta = $("chat-text");
|
||||
ta.addEventListener("keydown", e => {
|
||||
if(e.key === "Enter" && !e.shiftKey){ e.preventDefault(); chatSend(); }
|
||||
});
|
||||
ta.addEventListener("input", () => {
|
||||
ta.style.height = "auto";
|
||||
ta.style.height = Math.min(ta.scrollHeight, 140) + "px";
|
||||
});
|
||||
});
|
||||
chatTimer = setInterval(chatTick, CHAT_REFRESH * 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -38,6 +38,179 @@ _REFRESH = int(os.environ.get("SIMBIOT_DASH_REFRESH", "5"))
|
|||
_STATUS_ORDER = {"ready": 0, "running": 1, "failed": 2, "done": 3}
|
||||
_SIZE_COLOR = {"S": "#1d7a3e", "M": "#a3690f", "L": "#FF4F00", "XL": "#b3261e"}
|
||||
|
||||
# ── чат Simbiot прямо в дашборде (t-261001100740-99d55e) ──────────────────
|
||||
# Канал один для всех UI: БД портала agent_portal_chat (/root/coop/coop.db),
|
||||
# вход — sender='user' (пишет портал/дашборд), ответ агента — sender='agent'
|
||||
# (HTTP-push из сек-рефлекса portal_channel.push). Реплаи из стора харнеса
|
||||
# (ReplySent) — дубль текста ответа, берём из проекции инбокса (канал «portal»,
|
||||
# у master-хода там же ходим TG-реплаи — их chat_id не 'portal:*', не мешаем).
|
||||
import sqlite3 as _sqlite3
|
||||
|
||||
_COOP_DB = "/root/coop/coop.db"
|
||||
_COOP_AGENT_CACHE = {"id": None, "name": None, "ts": 0.0}
|
||||
_CHAT_MAX = 8000 # ровно кап coop-портала в chat/send
|
||||
|
||||
|
||||
def _coop_agent(ttl: float = 60.0):
|
||||
"""Агент Simbiot в БД портала (id нужен для адресации agent_portal_chat)."""
|
||||
now = time.time()
|
||||
if _COOP_AGENT_CACHE["id"] and now - _COOP_AGENT_CACHE["ts"] < ttl:
|
||||
return _COOP_AGENT_CACHE["id"], _COOP_AGENT_CACHE["name"]
|
||||
try:
|
||||
db = _sqlite3.connect(_COOP_DB)
|
||||
db.row_factory = _sqlite3.Row
|
||||
row = db.execute(
|
||||
"SELECT id, agent_name FROM agents WHERE harness_type='simbiot' "
|
||||
"ORDER BY (agent_name='Simbiot') DESC, id ASC LIMIT 1").fetchone()
|
||||
db.close()
|
||||
except Exception:
|
||||
return None, None
|
||||
if not row:
|
||||
return None, None
|
||||
_COOP_AGENT_CACHE.update({"id": int(row["id"]), "name": row["agent_name"],
|
||||
"ts": now})
|
||||
return _COOP_AGENT_CACHE["id"], _COOP_AGENT_CACHE["name"]
|
||||
|
||||
|
||||
def _ts_to_ms(ts) -> int:
|
||||
"""ISO-строка стора или int-ms → int-ms (единая сортировка чата)."""
|
||||
if isinstance(ts, (int, float)):
|
||||
return int(ts)
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
return int(datetime.fromisoformat(str(ts).replace("Z", "+00:00")).timestamp() * 1000)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def build_chat(limit: int = 80) -> dict:
|
||||
"""Лента чата: БД портала (primary) + реплаи стора (полнота текста батчей).
|
||||
|
||||
Дедуп: вход пользователя в обоих источниках под одним ключом
|
||||
('m-'+msg_id) — версию стора предпочитаем (там replied/reply_text).
|
||||
"""
|
||||
agent_id, agent_name = _coop_agent()
|
||||
if not agent_id:
|
||||
return {"ok": False, "error": "no_agent", "messages": []}
|
||||
|
||||
db = _sqlite3.connect(_COOP_DB)
|
||||
db.row_factory = _sqlite3.Row
|
||||
rows = db.execute(
|
||||
"SELECT msg_id, sender, text, ts, consumed FROM agent_portal_chat "
|
||||
"WHERE agent_id=? ORDER BY ts DESC LIMIT ?", (agent_id, limit)).fetchall()
|
||||
db.close()
|
||||
|
||||
store = {}
|
||||
try:
|
||||
model = es.run_projections()
|
||||
for m in (model.get("inbox") or {}).values():
|
||||
if isinstance(m, dict) and str(m.get("chat_id") or "").startswith("portal"):
|
||||
m = dict(m)
|
||||
m["store_id"] = "m-" + str(m.get("update_id") or "")
|
||||
m["ts_ms"] = _ts_to_ms(m.get("ts") or m.get("date"))
|
||||
store[m["store_id"]] = m
|
||||
except Exception:
|
||||
pass # стор недоступен: БД портала всё равно покажет ленту
|
||||
|
||||
msgs = []
|
||||
# анкеры: msg_id agent-реплаев вида "reply-<ms>-<update_id>" — покрывает
|
||||
# push-строку в БД портала; реплай стора дублирует её — не показываем
|
||||
anchors = set()
|
||||
for r in rows:
|
||||
if r["sender"] == "agent" and str(r["msg_id"]).startswith("reply-"):
|
||||
anchors.add("-".join(str(r["msg_id"]).split("-", 2)[2:]))
|
||||
for r in reversed(rows): # старые → новые
|
||||
# служебные команды канала (/set-tg-bot с токеном, cmd-settg-*) — не
|
||||
# чата: токен секрет, в ленту UI не показываем (t-261001100740)
|
||||
if (r["sender"] == "user" and (str(r["msg_id"]).startswith("cmd-settg-")
|
||||
or str(r["text"]).startswith("/set-tg-bot"))):
|
||||
continue
|
||||
if r["sender"] == "agent" and str(r["text"]).lstrip().startswith("/set-tg"):
|
||||
continue
|
||||
key = "m-" + str(r["msg_id"])
|
||||
st = store.get(key) if r["sender"] == "user" else None
|
||||
if st is not None and st.get("replied") and st.get("reply_text") \
|
||||
and str(st.get("update_id") or "") not in anchors:
|
||||
# реплай стора НЕ отражён в БД портала — показываем ПАРУ:
|
||||
# вопрос пользователя + ответ агента из стора
|
||||
msgs.append({
|
||||
"id": str(r["msg_id"]), "from": "user",
|
||||
"text": st.get("text") or r["text"],
|
||||
"ts": int(r["ts"] or 0), "channel": "portal", "pending": False,
|
||||
})
|
||||
msgs.append({
|
||||
"id": str(r["msg_id"]) + "#reply", "from": "agent",
|
||||
"text": st.get("reply_text"),
|
||||
"ts": _ts_to_ms(st.get("ts") or r["ts"]) or int(r["ts"] or 0),
|
||||
"channel": "portal", "pending": False,
|
||||
})
|
||||
continue
|
||||
if st is not None:
|
||||
text = st.get("text") or r["text"]
|
||||
pending = not (st.get("replied") or r["consumed"])
|
||||
else:
|
||||
text = r["text"]
|
||||
pending = bool(r["sender"] == "user" and not r["consumed"] and
|
||||
time.time() * 1000 - int(r["ts"] or 0) < 600_000)
|
||||
msgs.append({
|
||||
"id": str(r["msg_id"]),
|
||||
"from": r["sender"],
|
||||
"text": text,
|
||||
"ts": int(r["ts"] or 0),
|
||||
"channel": "portal",
|
||||
"pending": bool(pending),
|
||||
})
|
||||
# последние реплаи стора, которых ещё нет в БД (push идёт напрямую);
|
||||
# их нет как строк БД — показываем только если ретрай не доехал до портала
|
||||
seen_db_ids = {str(r["msg_id"]) for r in rows}
|
||||
for st in store.values():
|
||||
if (str(st.get("update_id") or "") not in seen_db_ids
|
||||
and st.get("replied") and st.get("reply_text")):
|
||||
msgs.append({"id": st["store_id"], "from": "agent",
|
||||
"text": st.get("reply_text"), "ts": st.get("ts_ms") or 0,
|
||||
"channel": "portal", "pending": False})
|
||||
msgs.sort(key=lambda m: m["ts"])
|
||||
# схлопнуть подряд идущие ОДИНАКОВЫЕ agent-пуши (ретраи ретраят с новым
|
||||
# msg_id — settg-диагностика светится как шум ×3): keep-last + счётчик
|
||||
collapsed = []
|
||||
for m in msgs:
|
||||
if (m["from"] == "agent" and collapsed
|
||||
and collapsed[-1]["from"] == "agent"
|
||||
and collapsed[-1].get("text") == m.get("text")
|
||||
and int(m["ts"]) - int(collapsed[-1]["ts"]) < 600_000):
|
||||
collapsed[-1]["repeat"] = collapsed[-1].get("repeat", 1) + 1
|
||||
collapsed[-1]["ts"] = m["ts"]
|
||||
continue
|
||||
collapsed.append(m)
|
||||
return {"ok": True, "agent": agent_name, "messages": collapsed[-limit:]}
|
||||
|
||||
|
||||
def chat_send(text: str) -> dict:
|
||||
"""Сообщение владельца агенту: прямая запись в agent_portal_chat.
|
||||
|
||||
HTTP-роут портала требует юзер-сессию, дашборд авторизован своим токеном —
|
||||
пишем напрямую в ту же таблицу (sender='user', consumed=0): сек-рефлекс
|
||||
харнеса поднимет её ≤20 с и мин-LLM ответит в тот же канал.
|
||||
"""
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return {"ok": False, "error": "empty"}
|
||||
agent_id, _ = _coop_agent()
|
||||
if not agent_id:
|
||||
return {"ok": False, "error": "no_agent"}
|
||||
mid = f"dash-{agent_id}-{int(time.time() * 1000)}-{os.urandom(3).hex()}"
|
||||
db = _sqlite3.connect(_COOP_DB, timeout=10)
|
||||
try:
|
||||
db.execute(
|
||||
"INSERT INTO agent_portal_chat (msg_id, agent_id, sender, text, ts) "
|
||||
"VALUES (?,?,?,?,?)", (mid, agent_id, "user", text[:_CHAT_MAX],
|
||||
int(time.time() * 1000)))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
return {"ok": True, "msg_id": mid}
|
||||
|
||||
|
||||
def _utcnow() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime())
|
||||
|
|
@ -191,6 +364,15 @@ class _Handler(BaseHTTPRequestHandler):
|
|||
return self._json(build_state())
|
||||
except Exception as e: # стор бит/занят — 503, фронт покажет retry
|
||||
return self._json({"error": str(e)[:500]}, code=503)
|
||||
if route == "/api/chat":
|
||||
# чат Simbiot в дашборде (t-261001100740): read — авторизуем так же
|
||||
if not self._authorized():
|
||||
return self._forbidden()
|
||||
try:
|
||||
return self._json(build_chat())
|
||||
except Exception as e:
|
||||
return self._json({"ok": False, "error": str(e)[:500],
|
||||
"messages": []}, code=503)
|
||||
if route == "/api/health":
|
||||
return self._json({"ok": True, "ts": _utcnow()})
|
||||
if route in ("/", "/index.html"):
|
||||
|
|
@ -203,6 +385,28 @@ class _Handler(BaseHTTPRequestHandler):
|
|||
return self._json({"error": f"dashboard.html not found: {e}"}, code=500)
|
||||
return self._json({"error": "not found"}, code=404)
|
||||
|
||||
def do_POST(self):
|
||||
route = self.path.partition("?")[0]
|
||||
if route == "/api/chat/send":
|
||||
# send чата (t-261001100740): та же авторизация, что у read-API
|
||||
if not self._authorized():
|
||||
return self._forbidden()
|
||||
try:
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
except ValueError:
|
||||
n = 0
|
||||
if n <= 0 or n > 64_000:
|
||||
return self._json({"ok": False, "error": "bad length"}, code=400)
|
||||
try:
|
||||
data = json.loads(self.rfile.read(n).decode("utf-8"))
|
||||
except Exception:
|
||||
return self._json({"ok": False, "error": "bad json"}, code=400)
|
||||
try:
|
||||
return self._json(chat_send(str(data.get("text") or "")))
|
||||
except Exception as e:
|
||||
return self._json({"ok": False, "error": str(e)[:500]}, code=503)
|
||||
return self._json({"error": "not found"}, code=404)
|
||||
|
||||
|
||||
def _health_server():
|
||||
"""Отдельный мини-порт для healthcheck: без auth, только {ok:true}."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue