web-interface initial
This commit is contained in:
parent
3340c126d6
commit
6e7d34e7a9
2393 changed files with 812405 additions and 0 deletions
662
adapter/web/bot.py
Normal file
662
adapter/web/bot.py
Normal file
|
|
@ -0,0 +1,662 @@
|
|||
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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue