surfaces/adapter/web/bot.py
2026-05-26 14:50:56 +03:00

662 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import asyncio
import json
import os
import re
import time
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
import aiohttp
import structlog
from aiohttp import web
from dotenv import load_dotenv
from adapter.web.converter import json_to_incoming, outgoing_to_json
from adapter.web.store import WebSessionStore
from core.auth import AuthManager
from core.chat import ChatManager
from core.handler import EventDispatcher
from core.handlers import register_all
from core.protocol import IncomingCommand, IncomingMessage, OutgoingEvent, OutgoingMessage
from core.settings import SettingsManager
from core.store import InMemoryStore, StateStore
from sdk.interface import PlatformClient, PlatformError
from sdk.mock import MockPlatformClient
logger = structlog.get_logger(__name__)
load_dotenv(Path(__file__).resolve().parents[2] / ".env")
PLATFORM = "web"
@dataclass
class WebRuntime:
platform: PlatformClient
store: StateStore
chat_mgr: ChatManager
auth_mgr: AuthManager
settings_mgr: SettingsManager
dispatcher: EventDispatcher
session_store: WebSessionStore
workspace_dir: Path
def _agent_base_url_from_env() -> str:
if base_url := os.environ.get("AGENT_BASE_URL"):
return base_url
if ws_url := os.environ.get("AGENT_WS_URL"):
parsed = urlsplit(ws_url)
path = re.sub(r"(?:/v1)?/agent_ws(?:/[^/]+)?/?$", "", parsed.path.rstrip("/"))
return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
return "http://127.0.0.1:8000"
def _build_platform_from_env(*, store: StateStore, chat_mgr: ChatManager) -> PlatformClient:
backend = os.environ.get("WEB_PLATFORM_BACKEND", "mock").strip().lower()
if backend == "real":
from sdk.prototype_state import PrototypeStateStore
from sdk.real import RealPlatformClient
prototype_state = PrototypeStateStore()
base_url = _agent_base_url_from_env()
return RealPlatformClient(
agent_id="web-agent",
agent_base_url=base_url,
prototype_state=prototype_state,
platform=PLATFORM,
)
return MockPlatformClient()
def build_runtime(
platform: PlatformClient | None = None,
store: StateStore | None = None,
) -> WebRuntime:
store = store or InMemoryStore()
session_store = WebSessionStore()
workspace_dir = Path(os.environ.get("SURFACES_WORKSPACE_DIR", "/workspace"))
chat_mgr = ChatManager(platform, store)
platform = platform or _build_platform_from_env(store=store, chat_mgr=chat_mgr)
chat_mgr = ChatManager(platform, store)
auth_mgr = AuthManager(platform, store)
settings_mgr = SettingsManager(platform, store)
dispatcher = EventDispatcher(
platform=platform, chat_mgr=chat_mgr, auth_mgr=auth_mgr, settings_mgr=settings_mgr
)
register_all(dispatcher)
return WebRuntime(
platform=platform,
store=store,
chat_mgr=chat_mgr,
auth_mgr=auth_mgr,
settings_mgr=settings_mgr,
dispatcher=dispatcher,
session_store=session_store,
workspace_dir=workspace_dir,
)
class WebBot:
def __init__(self, runtime: WebRuntime) -> None:
self.runtime = runtime
self._connections: dict[str, web.WebSocketResponse] = {}
async def handle_ws(self, request: web.Request) -> web.WebSocketResponse:
ws = web.WebSocketResponse(max_msg_size=0)
await ws.prepare(request)
session_id = ""
user_id = ""
logger.info("web_ws_connected", peer=request.remote)
try:
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
session_id, user_id = await self._on_text(ws, msg.data, session_id, user_id)
elif msg.type == aiohttp.WSMsgType.BINARY:
await self._on_binary(ws, msg.data, session_id, user_id)
elif msg.type == aiohttp.WSMsgType.ERROR:
logger.error("web_ws_error", error=ws.exception())
except asyncio.CancelledError:
pass
finally:
if user_id:
self._connections.pop(user_id, None)
logger.info("web_ws_disconnected", user_id=user_id, peer=request.remote)
return ws
async def _on_text(
self,
ws: web.WebSocketResponse,
data: str,
session_id: str,
user_id: str,
) -> tuple[str, str]:
try:
payload = json.loads(data)
except json.JSONDecodeError:
await self._send(ws, json.dumps({"type": "error", "text": "Invalid JSON"}))
return session_id, user_id
msg_type = payload.get("type", "")
if msg_type == "auth":
session_token = payload.get("session_token", "")
if session_token:
uid = await self.runtime.session_store.get_user_id(session_token)
if uid:
session_id = session_token
user_id = uid
self._connections[user_id] = ws
await self._send(
ws,
json.dumps(
{"type": "connected", "user_id": user_id, "session_token": session_id},
ensure_ascii=False,
),
)
await self._send_history(ws, user_id)
return session_id, user_id
session_id = await self.runtime.session_store.create_session(
payload.get("display_name")
)
user_id = await self.runtime.session_store.get_user_id(session_id)
self._connections[user_id] = ws
await self._send(
ws,
json.dumps(
{"type": "connected", "user_id": user_id, "session_token": session_id},
ensure_ascii=False,
),
)
return session_id, user_id
if not user_id:
await self._send(ws, json.dumps({"type": "error", "text": "Not authenticated"}))
return session_id, user_id
try:
incoming = json_to_incoming(payload)
except Exception as exc:
logger.warning("web_convert_error", error=str(exc))
await self._send(ws, json.dumps(
{"type": "error", "text": "Ошибка обработки сообщения"}, ensure_ascii=False,
))
return session_id, user_id
if incoming is None:
await self._send(ws, json.dumps(
{"type": "error", "text": "Неизвестный тип сообщения"}, ensure_ascii=False,
))
return session_id, user_id
now = time.time()
user_text = payload.get("text", "")
if not user_text and payload.get("type") == "command":
cmd = payload.get("command", "")
args = payload.get("args", [])
user_text = f"/{cmd} {' '.join(args)}".strip()
await self._save_message(user_id, {
"type": "message",
"chat_id": incoming.chat_id,
"text": user_text,
"from": "user",
"timestamp": now,
"attachments": getattr(incoming, "attachments", None),
})
# Handle chat-management commands at web surface level
if isinstance(incoming, IncomingCommand):
cmd = incoming.command
chat_id = incoming.chat_id
if cmd == "delete":
# Delete chat history, name, and archive state
for store_key in (
f"web_history:{user_id}:{chat_id}",
f"web_chat_name:{user_id}:{chat_id}",
f"web_chat_archived:{user_id}:{chat_id}",
):
await self.runtime.store.delete(store_key)
await self._send(ws, outgoing_to_json(
OutgoingMessage(chat_id=chat_id, text=f"Чат {chat_id} удалён")
))
return session_id, user_id
if cmd == "archive":
target = incoming.args[0] if incoming.args else chat_id
await self.runtime.store.set(f"web_chat_archived:{user_id}:{target}", "1")
await self._save_message(user_id, {
"type": "message", "chat_id": target,
"text": f"Чат {target} архивирован", "from": "agent",
"timestamp": time.time(),
})
await self._send(ws, outgoing_to_json(
OutgoingMessage(chat_id=target, text=f"Чат {target} архивирован")
))
await self._send(ws, json.dumps({
"type": "chat_archived", "chat_id": target,
}))
return session_id, user_id
if cmd == "unarchive":
target = incoming.args[0] if incoming.args else chat_id
await self.runtime.store.delete(f"web_chat_archived:{user_id}:{target}")
await self._save_message(user_id, {
"type": "message", "chat_id": target,
"text": f"Чат {target} восстановлен из архива", "from": "agent",
"timestamp": time.time(),
})
await self._send(ws, outgoing_to_json(
OutgoingMessage(chat_id=target, text=f"Чат {target} восстановлен из архива")
))
await self._send(ws, json.dumps({
"type": "chat_unarchived", "chat_id": target,
}))
return session_id, user_id
if cmd == "new":
# Generate next available chat_id
existing = set()
for key in await self.runtime.store.keys(f"web_history:{user_id}:"):
cid = key.rsplit(":", 1)[-1]
existing.add(cid)
n = 1
while f"C{n}" in existing:
n += 1
new_id = f"C{n}"
default_name = f"Чат {n}"
await self.runtime.store.set(f"web_chat_name:{user_id}:{new_id}", default_name)
await self._save_message(user_id, {
"type": "message", "chat_id": new_id,
"text": f"Создан новый чат {new_id}", "from": "agent",
"timestamp": time.time(),
})
await self._send(ws, outgoing_to_json(
OutgoingMessage(chat_id=new_id, text=f"Создан чат {new_id}")
))
await self._send(ws, json.dumps({
"type": "chat_new", "chat_id": new_id,
}))
await self._send(ws, json.dumps({
"type": "chat_name", "chat_id": new_id, "name": default_name,
}))
return session_id, user_id
if cmd == "chats":
chat_list = []
for key in await self.runtime.store.keys(f"web_history:{user_id}:"):
cid = key.rsplit(":", 1)[-1]
is_archived = await self.runtime.store.get(f"web_chat_archived:{user_id}:{cid}")
name = await self.runtime.store.get(f"web_chat_name:{user_id}:{cid}")
label = name or cid
if is_archived:
label += " (архив)"
chat_list.append(f"{label}")
text = "Ваши чаты:\n" + "\n".join(chat_list) if chat_list else "Нет чатов"
await self._send(ws, outgoing_to_json(
OutgoingMessage(chat_id=chat_id, text=text)
))
return session_id, user_id
if cmd == "save":
logger.info("web_cmd_save_entered", args=incoming.args, cmd=cmd)
if not incoming.args:
await self._send(ws, outgoing_to_json(
OutgoingMessage(chat_id=chat_id, text="⚠ Укажите имя: /save <имя>")
))
return session_id, user_id
name = " ".join(incoming.args)
sessions = await self.runtime.store.get(f"web_sessions:{user_id}") or []
if any(s["name"] == name for s in sessions):
await self._send(ws, outgoing_to_json(
OutgoingMessage(chat_id=chat_id, text=f"⚠ Сессия «{name}» уже существует.")
))
return session_id, user_id
sessions.append({"name": name, "created_at": time.strftime("%Y-%m-%dT%H:%M:%S")})
await self.runtime.store.set(f"web_sessions:{user_id}", sessions)
await self._save_message(user_id, {
"type": "message", "chat_id": chat_id,
"text": f"💾 Сохраняю контекст как «{name}»...", "from": "agent",
"timestamp": time.time(),
})
await self._send(ws, outgoing_to_json(
OutgoingMessage(chat_id=chat_id, text=f"💾 Сохраняю контекст как «{name}»...")
))
prompt = IncomingMessage(
user_id=user_id, platform=PLATFORM, chat_id=chat_id,
text=f"Summarize our conversation and save to /workspace/contexts/{name}.md. Reply only with: Saved: {name}",
attachments=[],
)
try:
outgoing = await self.runtime.dispatcher.dispatch(prompt)
except PlatformError as exc:
logger.warning("web_save_dispatch_error", user_id=user_id, error=str(exc))
text = f"⚠ Ошибка сохранения: {exc}"
outgoing = [OutgoingMessage(chat_id=chat_id, text=text)]
except Exception as exc:
logger.warning("web_save_dispatch_error", user_id=user_id, error=str(exc))
text = f"⚠ Ошибка сохранения: {exc}"
outgoing = [OutgoingMessage(chat_id=chat_id, text=text)]
save_msg = next((o for o in outgoing if isinstance(o, OutgoingMessage)), None)
save_text = save_msg.text if save_msg else f"✅ Сохранено: {name}"
await self._save_message(user_id, {
"type": "message", "chat_id": chat_id,
"text": save_text, "from": "agent",
"timestamp": time.time(),
})
await self._send(ws, outgoing_to_json(
OutgoingMessage(chat_id=chat_id, text=save_text)
))
return session_id, user_id
if cmd == "load":
sessions = await self.runtime.store.get(f"web_sessions:{user_id}") or []
if not incoming.args:
if not sessions:
await self._send(ws, outgoing_to_json(
OutgoingMessage(chat_id=chat_id, text="Нет сохранённых сессий. Используй /save [имя].")
))
return session_id, user_id
session_list = [{"name": s["name"], "created_at": s["created_at"][:10]} for s in sessions]
await self._send(ws, json.dumps({"type": "sessions", "sessions": session_list}))
return session_id, user_id
name = " ".join(incoming.args)
match = next((s for s in sessions if s["name"] == name), None) if sessions else None
if not match:
await self._send(ws, outgoing_to_json(
OutgoingMessage(chat_id=chat_id, text=f"⚠ Сессия «{name}» не найдена.")
))
return session_id, user_id
await self.runtime.store.set(f"web_current_session:{user_id}", name)
await self._save_message(user_id, {
"type": "message", "chat_id": chat_id,
"text": f"📂 Загружаю контекст «{name}»...", "from": "agent",
"timestamp": time.time(),
})
await self._send(ws, outgoing_to_json(
OutgoingMessage(chat_id=chat_id, text=f"📂 Загружаю контекст «{name}»...")
))
prompt = IncomingMessage(
user_id=user_id, platform=PLATFORM, chat_id=chat_id,
text=f"Load context from /workspace/contexts/{name}.md and use it as background for our conversation. Reply: Loaded: {name}",
attachments=[],
)
try:
outgoing = await self.runtime.dispatcher.dispatch(prompt)
except PlatformError as exc:
logger.warning("web_load_dispatch_error", user_id=user_id, error=str(exc))
text = f"⚠ Ошибка загрузки: {exc}"
outgoing = [OutgoingMessage(chat_id=chat_id, text=text)]
except Exception as exc:
logger.warning("web_load_dispatch_error", user_id=user_id, error=str(exc))
text = f"⚠ Ошибка загрузки: {exc}"
outgoing = [OutgoingMessage(chat_id=chat_id, text=text)]
load_msg = next((o for o in outgoing if isinstance(o, OutgoingMessage)), None)
load_text = load_msg.text if load_msg else f"✅ Загружено: {name}"
await self._save_message(user_id, {
"type": "message", "chat_id": chat_id,
"text": load_text, "from": "agent",
"timestamp": time.time(),
})
await self._send(ws, outgoing_to_json(
OutgoingMessage(chat_id=chat_id, text=load_text)
))
return session_id, user_id
if cmd == "context":
sessions = await self.runtime.store.get(f"web_sessions:{user_id}") or []
current = await self.runtime.store.get(f"web_current_session:{user_id}")
lines = [f"Контекст чата: {chat_id}"]
lines.append(f"Сессия: {current or 'не загружена'}")
lines.append(f"Сохранения ({len(sessions)}):")
for s in sessions:
lines.append(f" - {s['name']} ({s['created_at'][:10]})")
text = "\n".join(lines)
await self._save_message(user_id, {
"type": "message", "chat_id": chat_id,
"text": text, "from": "agent",
"timestamp": time.time(),
})
await self._send(ws, outgoing_to_json(
OutgoingMessage(chat_id=chat_id, text=text)
))
return session_id, user_id
if cmd in ("reset", "clear"):
await self.runtime.store.delete(f"web_current_session:{user_id}")
await self._save_message(user_id, {
"type": "message", "chat_id": chat_id,
"text": "🔄 Контекст сброшен. Агент не помнит предыдущий разговор.", "from": "agent",
"timestamp": time.time(),
})
await self._send(ws, outgoing_to_json(
OutgoingMessage(chat_id=chat_id, text="🔄 Контекст сброшен.")
))
return session_id, user_id
# Handle rename at web surface level for persistence
if isinstance(incoming, IncomingCommand) and incoming.command == "rename":
new_name = " ".join(incoming.args) if incoming.args else ""
if not new_name:
await self._send(ws, outgoing_to_json(
OutgoingMessage(chat_id=incoming.chat_id, text="Укажите имя: /rename <название>")
))
else:
# Check for duplicate names
is_dup = False
for key in await self.runtime.store.keys(f"web_chat_name:{user_id}:"):
existing = await self.runtime.store.get(key)
if existing and existing.lower() == new_name.lower():
cid = key.rsplit(":", 1)[-1]
if cid != incoming.chat_id:
is_dup = True
break
if is_dup:
await self._send(ws, outgoing_to_json(
OutgoingMessage(chat_id=incoming.chat_id, text=f"Чат с именем «{new_name}» уже существует")
))
return session_id, user_id
chat_id = incoming.chat_id
await self.runtime.store.set(f"web_chat_name:{user_id}:{chat_id}", new_name)
await self._save_message(user_id, {
"type": "message", "chat_id": chat_id,
"text": f"Переименован в: {new_name}", "from": "agent",
"timestamp": time.time(),
})
await self._send(ws, outgoing_to_json(
OutgoingMessage(chat_id=chat_id, text=f"Переименован в: {new_name}")
))
await self._send(ws, json.dumps(
{"type": "chat_name", "chat_id": chat_id, "name": new_name}
))
return session_id, user_id
try:
outgoing = await self.runtime.dispatcher.dispatch(incoming)
except PlatformError as exc:
logger.warning("web_platform_error", user_id=user_id, code=exc.code, error=str(exc))
outgoing = [
OutgoingMessage(
chat_id=incoming.chat_id,
text="Сервис временно недоступен. Попробуйте ещё раз позже.",
)
]
except Exception as exc:
logger.warning("web_dispatch_error", user_id=user_id, error=str(exc))
outgoing = [
OutgoingMessage(
chat_id=incoming.chat_id,
text=f"Ошибка: {exc}",
)
]
for event in outgoing:
if isinstance(event, OutgoingMessage):
await self._save_message(user_id, {
"type": "message",
"chat_id": event.chat_id,
"text": event.text,
"from": "agent",
"timestamp": time.time(),
"attachments": event.attachments,
})
await self._send_outgoing(user_id, outgoing)
return session_id, user_id
async def _on_binary(
self,
ws: web.WebSocketResponse,
raw: bytes,
session_id: str,
user_id: str,
) -> None:
pass
async def _send_outgoing(self, user_id: str, outgoing: list[OutgoingEvent]) -> None:
ws = self._connections.get(user_id)
if ws is None:
return
for event in outgoing:
await self._send(ws, outgoing_to_json(event))
async def _history_key(self, user_id: str, chat_id: str) -> str:
return f"web_history:{user_id}:{chat_id}"
async def _save_message(self, user_id: str, msg: dict) -> None:
key = await self._history_key(user_id, msg["chat_id"])
history = await self.runtime.store.get(key) or {"messages": []}
entry = dict(msg)
if entry.get("attachments"):
entry["attachments"] = [
{
"type": getattr(a, "type", "document"),
"filename": getattr(a, "filename", None),
"mime_type": getattr(a, "mime_type", None),
"workspace_path": getattr(a, "workspace_path", None),
"download_url": f"/files/{a.workspace_path}" if a.workspace_path else None,
}
for a in entry["attachments"]
]
history["messages"].append(entry)
history["messages"] = history["messages"][-200:]
await self.runtime.store.set(key, history)
async def _load_history(self, user_id: str) -> dict[str, list[dict]]:
result: dict[str, list[dict]] = {}
for raw_key in await self.runtime.store.keys(f"web_history:{user_id}:"):
chat_id = raw_key.rsplit(":", 1)[-1]
data = await self.runtime.store.get(raw_key)
if data and "messages" in data:
result[chat_id] = data["messages"]
return result
async def _send_history(self, ws: web.WebSocketResponse, user_id: str) -> None:
history = await self._load_history(user_id)
if not history:
return
payload: dict = {"type": "history", "chats": history}
chat_names = {}
for key in await self.runtime.store.keys(f"web_chat_name:{user_id}:"):
cid = key.rsplit(":", 1)[-1]
name = await self.runtime.store.get(key)
if name:
chat_names[cid] = name
if chat_names:
payload["chat_names"] = chat_names
archived_chats = []
for key in await self.runtime.store.keys(f"web_chat_archived:{user_id}:"):
cid = key.rsplit(":", 1)[-1]
val = await self.runtime.store.get(key)
if val:
archived_chats.append(cid)
if archived_chats:
payload["archived"] = archived_chats
await self._send(ws, json.dumps(payload, ensure_ascii=False))
async def handle_file_download(self, request: web.Request) -> web.Response:
filepath = self.runtime.workspace_dir / request.match_info["path"]
if not filepath.exists() or not filepath.is_file():
raise web.HTTPNotFound
safe_path = filepath.resolve()
safe_workspace = self.runtime.workspace_dir.resolve()
if not str(safe_path).startswith(str(safe_workspace)):
raise web.HTTPForbidden
return web.FileResponse(filepath)
async def handle_upload(self, request: web.Request) -> web.Response:
reader = await request.multipart()
field = await reader.next()
if not field or not field.filename:
return web.json_response({"error": "no file"}, status=400)
user_id = request.query.get("user_id", "unknown")
safe_name = re.sub(r"[^\w.-]", "_", field.filename)
filename = f"{user_id}_{safe_name}"
workspace = self.runtime.workspace_dir
filepath = workspace / filename
filepath.parent.mkdir(parents=True, exist_ok=True)
with open(filepath, "wb") as f:
while True:
chunk = await field.read_chunk()
if not chunk:
break
f.write(chunk)
return web.json_response({
"attachment_id": filename,
"filename": field.filename,
"workspace_path": filename,
})
async def _send(self, ws: web.WebSocketResponse, text: str) -> None:
try:
await ws.send_str(text)
except ConnectionResetError:
pass
async def main() -> None:
port = int(os.environ.get("WEB_PORT", "8080"))
runtime = build_runtime()
bot = WebBot(runtime)
app = web.Application()
static_dir = Path(__file__).resolve().parent / "static"
if static_dir.is_dir():
app.router.add_static("/static/", path=str(static_dir), name="static")
app.router.add_get("/ws", bot.handle_ws)
async def index(request: web.Request) -> web.FileResponse:
index_file = static_dir / "index.html"
if index_file.is_file():
return web.FileResponse(index_file)
return web.Response(text="Web Chat surface — frontend not built", content_type="text/plain")
app.router.add_get("/", index)
app.router.add_post("/upload", bot.handle_upload)
app.router.add_get("/files/{path:.*}", bot.handle_file_download)
logger.info("Web surface starting", port=port)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", port)
await site.start()
try:
await asyncio.Event().wait()
finally:
close = getattr(runtime.platform, "close", None)
if callable(close):
await close()
await runner.cleanup()
if __name__ == "__main__":
asyncio.run(main())