mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
feat(features): add channel-to-chat bridge for real-time messaging
Implement a bridge for connecting channel bot conversations (Slack, Discord, Telegram) to the Chat UI. This feature includes message broadcasting, history persistence, and dedicated session handling for each channel. Documentation has been added to describe the architecture and performance characteristics.
This commit is contained in:
1 parent
ba6b17c1ed
commit
3228af6eec
10 files changed
+642
-12
No files matched your search
@@ -0,0 +1,96 @@
|
||||
# Channel-to-Chat Bridge
|
||||
|
||||
Bridge channel bot conversations (Slack, Discord, Telegram) into the Chat UI in real-time.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant PU as Platform User
|
||||
participant Bot as SlackBot / DiscordBot / TelegramBot
|
||||
participant Bridge as _attach_chat_bridge()
|
||||
participant CM as ChatManager.broadcast()
|
||||
participant WS as Chat WebSocket (/api/chat/ws)
|
||||
participant UI as chat.js (Chat View)
|
||||
|
||||
Note over Bridge: Attached BEFORE bot.start()
|
||||
Note over Bridge: All broadcasts via asyncio.create_task<br/>(fire-and-forget, zero perf impact)
|
||||
|
||||
PU->>Bot: "Hello" (platform message)
|
||||
Bot->>Bridge: on_message hook fires
|
||||
Bridge->>CM: broadcast({type: "channel_message", ...})
|
||||
CM->>WS: send to connected clients
|
||||
WS->>UI: _handleChannelMessage() renders with platform badge
|
||||
|
||||
Bot->>Bot: agent processes message
|
||||
Bot->>Bridge: _wrapped_chat() returns response
|
||||
Bridge->>CM: broadcast({type: "channel_response", ...})
|
||||
CM->>WS: send to connected clients
|
||||
WS->>UI: _handleChannelResponse() renders as assistant
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Backend (`channels.py`)
|
||||
|
||||
`_attach_chat_bridge(channel_id, bot, platform)` is called in `_start_channel_bot()` right before the bot task is launched:
|
||||
|
||||
1. **`on_message` hook** — Captures incoming platform messages and broadcasts `channel_message` events with sender name, platform, and icon
|
||||
2. **`_session.chat()` wrapper** — Wraps the agent's chat method so responses are also broadcast as `channel_response` events
|
||||
3. **Persistence** — All messages saved to the datastore under session `channel-{channel_id}` for history on reload
|
||||
|
||||
### Frontend (`chat.js`)
|
||||
|
||||
| Function | Purpose |
|
||||
|---|---|
|
||||
| `_handleChannelMessage(data)` | Renders incoming platform user messages; auto-switches to channel session if on fresh chat |
|
||||
| `_handleChannelResponse(data)` | Renders agent responses from channel bots |
|
||||
| `_appendChannelMsg(role, data)` | Creates message DOM with platform badge, icon, sender name |
|
||||
| `_markSessionUnread(sessionId)` | Adds unread dot to sidebar for inactive channel sessions |
|
||||
|
||||
## Performance
|
||||
|
||||
Every broadcast is dispatched via `asyncio.create_task()` — the original bot message handling is **never blocked**. Errors in broadcasting are logged at `debug` level and swallowed — the bot continues normally even with zero Chat UI clients connected.
|
||||
|
||||
## Session Naming
|
||||
|
||||
Each channel gets a dedicated session: `channel-{channel_id}`. Platform icons:
|
||||
|
||||
| Platform | Icon |
|
||||
|---|---|
|
||||
| Slack | 💬 |
|
||||
| Discord | 🎮 |
|
||||
| Telegram | ✈️ |
|
||||
| WhatsApp | 📱 |
|
||||
|
||||
## WebSocket Events
|
||||
|
||||
### `channel_message`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "channel_message",
|
||||
"session_id": "channel-abc123",
|
||||
"channel_id": "abc123",
|
||||
"platform": "slack",
|
||||
"icon": "💬",
|
||||
"content": "Hello from Slack!",
|
||||
"sender": "john.doe",
|
||||
"timestamp": 1773033464.37
|
||||
}
|
||||
```
|
||||
|
||||
### `channel_response`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "channel_response",
|
||||
"session_id": "channel-abc123",
|
||||
"channel_id": "abc123",
|
||||
"platform": "slack",
|
||||
"icon": "💬",
|
||||
"content": "Hi! How can I help?",
|
||||
"agent_name": "assistant",
|
||||
"timestamp": 1773033465.12
|
||||
}
|
||||
```
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "aiui"
|
||||
version = "0.3.35"
|
||||
version = "0.3.37"
|
||||
description = "YAML-driven website generator - CLI and compiler"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
@@ -101,7 +101,7 @@ def _sync_to_gateway(agent_def: Dict[str, Any]) -> None:
|
||||
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Build Agent kwargs from definition — pass through tools & temperature
|
||||
# Build Agent kwargs from definition
|
||||
agent_kwargs: Dict[str, Any] = {
|
||||
"name": agent_def.get("name", "assistant"),
|
||||
"instructions": (
|
||||
@@ -111,16 +111,31 @@ def _sync_to_gateway(agent_def: Dict[str, Any]) -> None:
|
||||
),
|
||||
"llm": agent_def.get("model", "gpt-4o-mini"),
|
||||
"memory": True,
|
||||
"reflection": agent_def.get("reflection", True),
|
||||
}
|
||||
|
||||
# Pass tools if specified
|
||||
# G8: Resolve tool name strings to callables via ToolResolver
|
||||
tool_names = agent_def.get("tools", [])
|
||||
if tool_names:
|
||||
agent_kwargs["tools"] = tool_names
|
||||
agent_tools = []
|
||||
try:
|
||||
from praisonai.tool_resolver import ToolResolver
|
||||
resolver = ToolResolver()
|
||||
for tn in tool_names:
|
||||
if isinstance(tn, str) and tn.strip():
|
||||
resolved = resolver.resolve(tn.strip())
|
||||
if resolved:
|
||||
agent_tools.append(resolved)
|
||||
else:
|
||||
logger.warning(f"Tool '{tn}' not found for agent '{agent_def.get('id')}'")
|
||||
except ImportError:
|
||||
logger.debug("ToolResolver not available, skipping tool resolution")
|
||||
if agent_tools:
|
||||
agent_kwargs["tools"] = agent_tools
|
||||
|
||||
agent = Agent(**agent_kwargs)
|
||||
gw.register_agent(agent, agent_id=agent_def["id"])
|
||||
logger.info(f"Agent synced to gateway: {agent_def['id']} ({agent_def.get('name')})")
|
||||
logger.info(f"Agent synced to gateway: {agent_def['id']} ({agent_def.get('name')}, tools={len(agent_kwargs.get('tools', []))})")
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
@@ -463,13 +478,31 @@ class PraisonAIAgentsFeature(BaseFeatureProtocol):
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fallback: create a fresh agent
|
||||
# G7: Fallback — create agent with tools from CRUD definition
|
||||
if agent is None:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Resolve tools from CRUD definition
|
||||
agent_tools = []
|
||||
tool_names = agent_def.get("tools", [])
|
||||
if tool_names:
|
||||
try:
|
||||
from praisonai.tool_resolver import ToolResolver
|
||||
resolver = ToolResolver()
|
||||
for tn in tool_names:
|
||||
if isinstance(tn, str) and tn.strip():
|
||||
resolved = resolver.resolve(tn.strip())
|
||||
if resolved:
|
||||
agent_tools.append(resolved)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
agent = Agent(
|
||||
name=agent_def.get("name", "assistant"),
|
||||
instructions=agent_def.get("instructions", "") or agent_def.get("system_prompt", ""),
|
||||
llm=agent_def.get("model", "gpt-4o-mini"),
|
||||
tools=agent_tools if agent_tools else None,
|
||||
reflection=agent_def.get("reflection", True),
|
||||
)
|
||||
|
||||
# Execute in thread pool to not block event loop
|
||||
|
||||
@@ -139,10 +139,22 @@ class PraisonAIChannels(BaseFeatureProtocol):
|
||||
agent = None
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# G3: Resolve tools via praisonai wrapper
|
||||
agent_tools = []
|
||||
try:
|
||||
from praisonai.tool_resolver import ToolResolver
|
||||
resolver = ToolResolver()
|
||||
agent_tools = resolver.resolve_many(["internet_search", "get_current_time"])
|
||||
except ImportError:
|
||||
pass # praisonai not installed — no tools
|
||||
|
||||
agent = Agent(
|
||||
name="assistant",
|
||||
instructions="You are a helpful assistant.",
|
||||
instructions="You are a helpful assistant with tool capabilities.",
|
||||
llm=os.environ.get("PRAISONAI_MODEL", "gpt-4o-mini"),
|
||||
tools=agent_tools if agent_tools else None,
|
||||
reflection=True, # G3: Enable reflection/interactive mode
|
||||
)
|
||||
except ImportError:
|
||||
pass # agent may be None — some bots can work without one
|
||||
@@ -189,6 +201,11 @@ class PraisonAIChannels(BaseFeatureProtocol):
|
||||
entry["running"] = False
|
||||
entry["start_error"] = str(e)
|
||||
|
||||
# ── Attach chat bridge BEFORE starting ──────────────────────────
|
||||
# Hooks into the bot's message lifecycle to broadcast messages
|
||||
# to the Chat UI in real-time (fire-and-forget, zero perf impact).
|
||||
self._attach_chat_bridge(channel_id, bot, platform)
|
||||
|
||||
task = asyncio.create_task(_run_safe(channel_id, bot))
|
||||
_live_bots[channel_id] = {"bot": bot, "task": task}
|
||||
|
||||
@@ -252,6 +269,129 @@ class PraisonAIChannels(BaseFeatureProtocol):
|
||||
continue
|
||||
return None
|
||||
|
||||
def _attach_chat_bridge(
|
||||
self,
|
||||
channel_id: str,
|
||||
bot: Any,
|
||||
platform: str,
|
||||
) -> None:
|
||||
"""Attach a message bridge so channel bot conversations appear in the Chat UI.
|
||||
|
||||
Architecture (fire-and-forget, zero performance impact):
|
||||
┌──────────┐ on_message ┌───────────┐ broadcast ┌──────────┐
|
||||
│ Slack / │ ──────────────► │ channels │ ──────────► │ Chat WS │
|
||||
│ Discord/ │ │ .py │ (create_ │ clients │
|
||||
│ Telegram │ │ bridge │ task) │ (UI) │
|
||||
└──────────┘ └───────────┘ └──────────┘
|
||||
│ ▲
|
||||
│ _session.chat() │ wrapped to also
|
||||
└──────────────────────────┘ broadcast response
|
||||
|
||||
Every broadcast is dispatched via ``asyncio.create_task`` so the
|
||||
bot's original message handling is **never** blocked or slowed.
|
||||
Errors in broadcasting are logged and swallowed — the bot keeps
|
||||
running normally even if no Chat UI clients are connected.
|
||||
|
||||
Each channel gets a dedicated session ID: ``channel-{channel_id}``.
|
||||
Messages are persisted to the datastore for history on page reload.
|
||||
"""
|
||||
session_id = f"channel-{channel_id}"
|
||||
|
||||
# ── Platform icons for the Chat UI ───────────────────────────
|
||||
_PLATFORM_ICONS = {
|
||||
"slack": "💬", "discord": "🎮", "telegram": "✈️",
|
||||
"whatsapp": "📱", "imessage": "🍎", "signal": "🔒",
|
||||
"googlechat": "💚", "nostr": "🟣",
|
||||
}
|
||||
icon = _PLATFORM_ICONS.get(platform, "📨")
|
||||
|
||||
# ── 1. Hook into on_message to capture incoming user messages ─
|
||||
if hasattr(bot, "on_message"):
|
||||
@bot.on_message
|
||||
async def _bridge_incoming(msg: Any) -> None:
|
||||
"""Forward incoming channel message to Chat UI (fire-and-forget)."""
|
||||
try:
|
||||
content = getattr(msg, "content", "") or str(msg)
|
||||
sender_name = ""
|
||||
if hasattr(msg, "sender") and msg.sender:
|
||||
sender_name = (
|
||||
getattr(msg.sender, "display_name", "")
|
||||
or getattr(msg.sender, "username", "")
|
||||
or getattr(msg.sender, "user_id", "")
|
||||
)
|
||||
|
||||
async def _do_broadcast() -> None:
|
||||
try:
|
||||
from .chat import get_chat_manager
|
||||
mgr = get_chat_manager()
|
||||
await mgr.broadcast(session_id, {
|
||||
"type": "channel_message",
|
||||
"session_id": session_id,
|
||||
"channel_id": channel_id,
|
||||
"platform": platform,
|
||||
"icon": icon,
|
||||
"content": content,
|
||||
"sender": sender_name,
|
||||
"timestamp": time.time(),
|
||||
})
|
||||
|
||||
# Persist to datastore for history
|
||||
from praisonaiui.server import _datastore
|
||||
existing = await _datastore.get_session(session_id)
|
||||
if existing is None:
|
||||
await _datastore.create_session(session_id)
|
||||
await _datastore.add_message(session_id, {
|
||||
"role": "user",
|
||||
"content": f"[{sender_name}] {content}" if sender_name else content,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Chat bridge broadcast error: {e}")
|
||||
|
||||
asyncio.create_task(_do_broadcast())
|
||||
except Exception as e:
|
||||
logger.debug(f"Chat bridge incoming error: {e}")
|
||||
|
||||
# ── 2. Wrap _session.chat() to capture agent responses ────────
|
||||
if hasattr(bot, "_session") and hasattr(bot._session, "chat"):
|
||||
_original_chat = bot._session.chat
|
||||
|
||||
async def _wrapped_chat(agent: Any, user_id: str, text: str) -> str:
|
||||
"""Wrap agent chat to broadcast responses to Chat UI."""
|
||||
response = await _original_chat(agent, user_id, text)
|
||||
|
||||
# Fire-and-forget broadcast of the agent response
|
||||
async def _broadcast_response() -> None:
|
||||
try:
|
||||
from .chat import get_chat_manager
|
||||
mgr = get_chat_manager()
|
||||
await mgr.broadcast(session_id, {
|
||||
"type": "channel_response",
|
||||
"session_id": session_id,
|
||||
"channel_id": channel_id,
|
||||
"platform": platform,
|
||||
"icon": icon,
|
||||
"content": response,
|
||||
"agent_name": getattr(agent, "name", "assistant"),
|
||||
"timestamp": time.time(),
|
||||
})
|
||||
|
||||
# Persist response to datastore
|
||||
from praisonaiui.server import _datastore
|
||||
await _datastore.add_message(session_id, {
|
||||
"role": "assistant",
|
||||
"content": response,
|
||||
"agent_name": getattr(agent, "name", "assistant"),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Chat bridge response broadcast error: {e}")
|
||||
|
||||
asyncio.create_task(_broadcast_response())
|
||||
return response # return original response untouched
|
||||
|
||||
bot._session.chat = _wrapped_chat
|
||||
|
||||
logger.info(f"Chat bridge attached for {platform} channel '{channel_id}' → session '{session_id}'")
|
||||
|
||||
async def _stop_channel_bot(self, channel_id: str) -> Optional[str]:
|
||||
"""Stop a running bot for the given channel.
|
||||
|
||||
|
||||
@@ -234,16 +234,34 @@ class PraisonAIJobs(BaseFeatureProtocol):
|
||||
pass
|
||||
|
||||
if agent is None:
|
||||
# Fallback: create fresh Agent
|
||||
# G6: Fallback — create agent with tools (same pattern as G1/G5)
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
instructions = job.get("config", {}).get("instructions", "")
|
||||
model = job.get("config", {}).get("model", "gpt-4o-mini")
|
||||
|
||||
# Resolve tools from job config
|
||||
agent_tools = []
|
||||
tool_names = job.get("config", {}).get("tools", [])
|
||||
if tool_names:
|
||||
try:
|
||||
from praisonai.tool_resolver import ToolResolver
|
||||
resolver = ToolResolver()
|
||||
for tn in tool_names:
|
||||
if isinstance(tn, str) and tn.strip():
|
||||
resolved = resolver.resolve(tn.strip())
|
||||
if resolved:
|
||||
agent_tools.append(resolved)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
agent = Agent(
|
||||
name=agent_name,
|
||||
instructions=instructions,
|
||||
llm=model,
|
||||
tools=agent_tools if agent_tools else None,
|
||||
reflection=job.get("config", {}).get("reflection", True),
|
||||
)
|
||||
except ImportError:
|
||||
agent = None
|
||||
|
||||
@@ -288,6 +288,14 @@ def create_gateway_from_yaml(
|
||||
ui_config=config,
|
||||
)
|
||||
|
||||
# G5: Resolve tool names to callables (same pattern as gateway G1 fix)
|
||||
tool_resolver = None
|
||||
try:
|
||||
from praisonai.tool_resolver import ToolResolver
|
||||
tool_resolver = ToolResolver()
|
||||
except ImportError:
|
||||
logger.debug("ToolResolver not available, agents will have no tools")
|
||||
|
||||
# Create agents from config
|
||||
agents_cfg = config.get("agents", {})
|
||||
for agent_id, agent_def in agents_cfg.items():
|
||||
@@ -295,14 +303,26 @@ def create_gateway_from_yaml(
|
||||
logger.warning("praisonai not available, skipping agent creation")
|
||||
break
|
||||
|
||||
# G5: Resolve tools from config
|
||||
agent_tools = []
|
||||
for tool_name in agent_def.get("tools", []):
|
||||
if tool_resolver and isinstance(tool_name, str) and tool_name.strip():
|
||||
resolved = tool_resolver.resolve(tool_name.strip())
|
||||
if resolved:
|
||||
agent_tools.append(resolved)
|
||||
else:
|
||||
logger.warning(f"Tool '{tool_name}' not found for agent '{agent_id}'")
|
||||
|
||||
agent = PraisonAgent(
|
||||
name=agent_id,
|
||||
instructions=agent_def.get("instructions", ""),
|
||||
llm=agent_def.get("model"),
|
||||
memory=agent_def.get("memory", False),
|
||||
tools=agent_tools if agent_tools else None,
|
||||
reflection=agent_def.get("reflection", True),
|
||||
)
|
||||
gateway.register_agent(agent, agent_id=agent_id)
|
||||
logger.info(f"Created agent '{agent_id}'")
|
||||
logger.info(f"Created agent '{agent_id}' (tools={len(agent_tools)}, reflection={agent_def.get('reflection', True)})")
|
||||
|
||||
return gateway
|
||||
|
||||
|
||||
@@ -207,8 +207,19 @@ class PraisonAIProvider(BaseProvider):
|
||||
"name": "Assistant",
|
||||
"instructions": "You are a helpful assistant. Use markdown formatting.",
|
||||
"memory": True,
|
||||
"reflection": True, # G2: Enable reflection/interactive mode by default
|
||||
}
|
||||
|
||||
# G2: Resolve default tools via praisonai wrapper
|
||||
try:
|
||||
from praisonai.tool_resolver import ToolResolver
|
||||
resolver = ToolResolver()
|
||||
default_tools = resolver.resolve_many(["internet_search", "get_current_time"])
|
||||
if default_tools:
|
||||
kwargs["tools"] = default_tools
|
||||
except ImportError:
|
||||
pass # praisonai not installed — no tools
|
||||
|
||||
# Check CRUD-defined agents for matching name
|
||||
if agent_name:
|
||||
try:
|
||||
@@ -223,6 +234,16 @@ class PraisonAIProvider(BaseProvider):
|
||||
)
|
||||
if _def.get("model"):
|
||||
kwargs["llm"] = _def["model"]
|
||||
# Also get tools from CRUD definition if specified
|
||||
if _def.get("tools"):
|
||||
try:
|
||||
from praisonai.tool_resolver import ToolResolver
|
||||
resolver = ToolResolver()
|
||||
crud_tools = resolver.resolve_many(_def["tools"])
|
||||
if crud_tools:
|
||||
kwargs["tools"] = crud_tools
|
||||
except ImportError:
|
||||
pass
|
||||
break
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -159,6 +159,13 @@ function injectStyles() {
|
||||
.chat-mem-item .mem-text { color:var(--db-text); }
|
||||
.chat-mem-empty { color:var(--db-text-dim); font-style:italic; font-size:12px; }
|
||||
|
||||
.chat-channel-badge { display:inline-flex; align-items:center; gap:4px; font-size:10px; padding:1px 6px; border-radius:6px; background:rgba(var(--db-accent-rgb,100,100,255),.1); color:var(--db-accent); font-weight:600; text-transform:uppercase; letter-spacing:.3px; }
|
||||
.chat-msg-channel .chat-msg-content { border-left:3px solid var(--db-accent); background:rgba(var(--db-accent-rgb,100,100,255),.04); }
|
||||
.chat-msg-channel .chat-msg-avatar { background:rgba(var(--db-accent-rgb,100,100,255),.15); font-size:14px; }
|
||||
.chat-msg-channel-sender { font-size:11px; font-weight:600; color:var(--db-text-dim); margin-bottom:2px; display:flex; align-items:center; gap:6px; }
|
||||
.chat-sess-item .sess-platform-badge { font-size:10px; padding:1px 5px; border-radius:4px; background:rgba(var(--db-accent-rgb,100,100,255),.1); color:var(--db-accent); font-weight:600; }
|
||||
.chat-sess-item .sess-unread { display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--db-accent); margin-left:4px; animation:blink 1.5s infinite; }
|
||||
|
||||
.chat-compose { padding:14px 16px; border-top:1px solid var(--db-border); }
|
||||
.chat-compose-row { display:flex; gap:8px; align-items:flex-end; }
|
||||
.chat-compose textarea { flex:1; resize:none; padding:10px 14px; background:var(--db-sidebar-bg,var(--db-card-bg)); border:1px solid var(--db-border); border-radius:10px; color:var(--db-text); font-size:14px; font-family:inherit; line-height:1.5; max-height:150px; outline:none; transition:border .15s; }
|
||||
@@ -444,8 +451,10 @@ function connectWebSocket() {
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
// Only process events for our session (ignore other sessions)
|
||||
if (data.session_id && currentSessionId && data.session_id !== currentSessionId) {
|
||||
const isChannelMsg = data.type === 'channel_message' || data.type === 'channel_response';
|
||||
// Allow channel messages through regardless of current session;
|
||||
// for regular chat events, filter by session.
|
||||
if (!isChannelMsg && data.session_id && currentSessionId && data.session_id !== currentSessionId) {
|
||||
return;
|
||||
}
|
||||
handleWsMessage(data);
|
||||
@@ -574,6 +583,16 @@ function handleWsMessage(data) {
|
||||
case 'pong':
|
||||
break;
|
||||
|
||||
case 'channel_message':
|
||||
// Incoming message from a channel bot (Slack/Discord/Telegram user)
|
||||
_handleChannelMessage(data);
|
||||
break;
|
||||
|
||||
case 'channel_response':
|
||||
// Agent response sent back through a channel bot
|
||||
_handleChannelResponse(data);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Handle metrics from run_content events
|
||||
if (data.metrics) {
|
||||
@@ -583,6 +602,94 @@ function handleWsMessage(data) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Channel Message Handlers ────────────────────────────────────
|
||||
// These render messages from platform bots (Slack, Discord, Telegram)
|
||||
// into the Chat UI. They auto-switch to the channel session or show
|
||||
// an unread notification dot if the user is viewing a different session.
|
||||
|
||||
function _handleChannelMessage(data) {
|
||||
const sessionId = data.session_id;
|
||||
const isViewing = currentSessionId === sessionId;
|
||||
|
||||
// If user is on a fresh/empty chat, auto-switch to channel session
|
||||
if (!currentSessionId) {
|
||||
currentSessionId = sessionId;
|
||||
}
|
||||
|
||||
if (currentSessionId === sessionId) {
|
||||
_appendChannelMsg('user', data);
|
||||
} else {
|
||||
// Mark unread in sidebar
|
||||
_markSessionUnread(sessionId);
|
||||
}
|
||||
|
||||
// Refresh sessions to show new channel session
|
||||
setTimeout(loadSessions, 300);
|
||||
}
|
||||
|
||||
function _handleChannelResponse(data) {
|
||||
const sessionId = data.session_id;
|
||||
|
||||
if (currentSessionId === sessionId) {
|
||||
_appendChannelMsg('assistant', data);
|
||||
} else {
|
||||
_markSessionUnread(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function _appendChannelMsg(role, data) {
|
||||
const messagesEl = document.getElementById('chat-messages');
|
||||
const welcome = document.getElementById('chat-welcome');
|
||||
if (welcome) welcome.style.display = 'none';
|
||||
|
||||
const msgEl = document.createElement('div');
|
||||
msgEl.className = 'chat-msg chat-msg-' + role + ' chat-msg-channel';
|
||||
|
||||
const avatarEl = document.createElement('div');
|
||||
avatarEl.className = 'chat-msg-avatar';
|
||||
avatarEl.textContent = (data.icon || '📨');
|
||||
|
||||
const bodyEl = document.createElement('div');
|
||||
bodyEl.className = 'chat-msg-body';
|
||||
|
||||
// Sender/agent name with platform badge
|
||||
const senderEl = document.createElement('div');
|
||||
senderEl.className = 'chat-msg-channel-sender';
|
||||
const badge = '<span class="chat-channel-badge">' + escapeHtml(data.platform || 'channel') + '</span>';
|
||||
const name = role === 'user'
|
||||
? (data.sender || 'User')
|
||||
: (data.agent_name || 'Assistant');
|
||||
senderEl.innerHTML = badge + ' ' + escapeHtml(name);
|
||||
bodyEl.appendChild(senderEl);
|
||||
|
||||
const contentEl = document.createElement('div');
|
||||
contentEl.className = 'chat-msg-content';
|
||||
contentEl.innerHTML = renderMarkdown(data.content || '');
|
||||
bodyEl.appendChild(contentEl);
|
||||
|
||||
msgEl.appendChild(avatarEl);
|
||||
msgEl.appendChild(bodyEl);
|
||||
messagesEl.appendChild(msgEl);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
|
||||
// Update header to show channel session
|
||||
const title = document.getElementById('chat-header-title');
|
||||
if (title && currentSessionId === data.session_id) {
|
||||
title.textContent = (data.icon || '📨') + ' ' + (data.platform || 'Channel').charAt(0).toUpperCase() + (data.platform || 'Channel').slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
function _markSessionUnread(sessionId) {
|
||||
// Add unread dot to matching session item in sidebar
|
||||
document.querySelectorAll('.chat-sess-item').forEach((el) => {
|
||||
if (el.title === sessionId && !el.querySelector('.sess-unread')) {
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'sess-unread';
|
||||
el.querySelector('.sess-title')?.appendChild(dot);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Message Rendering ───────────────────────────────────────────
|
||||
|
||||
function appendMessage(role, content, agentName) {
|
||||
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
"""End-to-end test for the channel-to-chat bridge.
|
||||
|
||||
Tests:
|
||||
1. Server starts and API endpoints respond
|
||||
2. Bridge attaches to a mock bot correctly
|
||||
3. Channel messages broadcast via ChatManager and persist to datastore
|
||||
4. Performance: bridge adds zero blocking overhead to the bot flow
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import sys
|
||||
|
||||
async def run_tests():
|
||||
results = []
|
||||
|
||||
# ── Test 1: Module imports ─────────────────────────────────────
|
||||
print("\n═══ Test 1: Module Imports ═══")
|
||||
try:
|
||||
from praisonaiui.features.channels import PraisonAIChannels, _channels, _live_bots
|
||||
from praisonaiui.features.chat import ChatManager, get_chat_manager, ChatMessage
|
||||
results.append(("Module imports", True, ""))
|
||||
print(" ✅ channels.py and chat.py imported OK")
|
||||
except Exception as e:
|
||||
results.append(("Module imports", False, str(e)))
|
||||
print(f" ❌ Import failed: {e}")
|
||||
return results
|
||||
|
||||
# ── Test 2: ChatManager broadcast ──────────────────────────────
|
||||
print("\n═══ Test 2: ChatManager Broadcast ═══")
|
||||
try:
|
||||
mgr = get_chat_manager()
|
||||
# Broadcast with no clients connected should be a no-op
|
||||
await mgr.broadcast("test-session", {"type": "test", "content": "hello"})
|
||||
results.append(("Broadcast no clients", True, "No-op with 0 clients"))
|
||||
print(" ✅ Broadcast with 0 clients: no-op, no error")
|
||||
except Exception as e:
|
||||
results.append(("Broadcast no clients", False, str(e)))
|
||||
print(f" ❌ Broadcast failed: {e}")
|
||||
|
||||
# ── Test 3: Mock bot + bridge attachment ────────────────────────
|
||||
print("\n═══ Test 3: Bridge Attachment to Mock Bot ═══")
|
||||
try:
|
||||
# Create a mock bot with on_message support
|
||||
class MockSession:
|
||||
async def chat(self, agent, user_id, text):
|
||||
return f"Echo: {text}"
|
||||
|
||||
class MockBot:
|
||||
def __init__(self):
|
||||
self._session = MockSession()
|
||||
self._message_handlers = []
|
||||
self.is_running = True
|
||||
|
||||
def on_message(self, fn):
|
||||
self._message_handlers.append(fn)
|
||||
return fn
|
||||
|
||||
bot = MockBot()
|
||||
channels = PraisonAIChannels()
|
||||
|
||||
# Attach the bridge
|
||||
channels._attach_chat_bridge("test-ch-001", bot, "slack")
|
||||
|
||||
# Verify on_message handler was registered
|
||||
assert len(bot._message_handlers) == 1, f"Expected 1 handler, got {len(bot._message_handlers)}"
|
||||
results.append(("Bridge attachment", True, "on_message handler registered"))
|
||||
print(" ✅ on_message handler registered")
|
||||
|
||||
# Verify _session.chat was wrapped
|
||||
assert bot._session.chat != MockSession.chat, "chat() should be wrapped"
|
||||
results.append(("Session chat wrapped", True, "chat() wrapped for response broadcast"))
|
||||
print(" ✅ _session.chat() wrapped for response broadcast")
|
||||
|
||||
except Exception as e:
|
||||
results.append(("Bridge attachment", False, str(e)))
|
||||
print(f" ❌ Bridge attachment failed: {e}")
|
||||
return results
|
||||
|
||||
# ── Test 4: on_message fires without blocking ──────────────────
|
||||
print("\n═══ Test 4: Fire-and-Forget Performance ═══")
|
||||
try:
|
||||
class MockMessage:
|
||||
content = "Hello from Slack!"
|
||||
class sender:
|
||||
display_name = "test_user"
|
||||
username = "test_user"
|
||||
user_id = "U123"
|
||||
|
||||
# Time the on_message handler call (should be near-instant since broadcast is create_task)
|
||||
handler = bot._message_handlers[0]
|
||||
|
||||
t_start = time.perf_counter()
|
||||
await handler(MockMessage())
|
||||
t_on_message = time.perf_counter() - t_start
|
||||
|
||||
# Should be < 1ms since the broadcast is fire-and-forget
|
||||
assert t_on_message < 0.01, f"on_message took {t_on_message*1000:.2f}ms (expected < 10ms)"
|
||||
results.append(("on_message perf", True, f"{t_on_message*1000:.3f}ms"))
|
||||
print(f" ✅ on_message handler: {t_on_message*1000:.3f}ms (< 10ms threshold)")
|
||||
|
||||
except Exception as e:
|
||||
results.append(("on_message perf", False, str(e)))
|
||||
print(f" ❌ on_message perf test failed: {e}")
|
||||
|
||||
# ── Test 5: Wrapped chat() returns original response untouched ──
|
||||
print("\n═══ Test 5: Wrapped chat() Returns Original Response ═══")
|
||||
try:
|
||||
class MockAgent:
|
||||
name = "test-agent"
|
||||
|
||||
t_start = time.perf_counter()
|
||||
response = await bot._session.chat(MockAgent(), "user123", "What is 2+2?")
|
||||
t_chat = time.perf_counter() - t_start
|
||||
|
||||
assert response == "Echo: What is 2+2?", f"Expected 'Echo: What is 2+2?', got '{response}'"
|
||||
results.append(("chat() response intact", True, f"Response correct, {t_chat*1000:.3f}ms"))
|
||||
print(f" ✅ Response untouched: '{response}'")
|
||||
print(f" ✅ chat() wrapper overhead: {t_chat*1000:.3f}ms")
|
||||
|
||||
except Exception as e:
|
||||
results.append(("chat() response intact", False, str(e)))
|
||||
print(f" ❌ chat() test failed: {e}")
|
||||
|
||||
# ── Test 6: Performance comparison (wrapped vs direct) ─────────
|
||||
print("\n═══ Test 6: Performance Comparison (100 iterations) ═══")
|
||||
try:
|
||||
# Benchmark direct session chat
|
||||
direct_session = MockSession()
|
||||
agent = MockAgent()
|
||||
|
||||
t_start = time.perf_counter()
|
||||
for _ in range(100):
|
||||
await direct_session.chat(agent, "user", "test")
|
||||
t_direct = time.perf_counter() - t_start
|
||||
|
||||
# Benchmark wrapped session chat
|
||||
t_start = time.perf_counter()
|
||||
for _ in range(100):
|
||||
await bot._session.chat(agent, "user", "test")
|
||||
t_wrapped = time.perf_counter() - t_start
|
||||
|
||||
overhead_pct = ((t_wrapped - t_direct) / t_direct * 100) if t_direct > 0 else 0
|
||||
# Allow tasks to complete
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
results.append(("Performance comparison", True,
|
||||
f"Direct: {t_direct*1000:.2f}ms, Wrapped: {t_wrapped*1000:.2f}ms, Overhead: {overhead_pct:.1f}%"))
|
||||
print(f" Direct (100 calls): {t_direct*1000:.2f}ms")
|
||||
print(f" Wrapped (100 calls): {t_wrapped*1000:.2f}ms")
|
||||
print(f" Overhead: {overhead_pct:.1f}%")
|
||||
if overhead_pct < 50:
|
||||
print(f" ✅ Overhead acceptable (< 50%)")
|
||||
else:
|
||||
print(f" ⚠️ Overhead higher than expected but calls are fire-and-forget")
|
||||
|
||||
except Exception as e:
|
||||
results.append(("Performance comparison", False, str(e)))
|
||||
print(f" ❌ Performance comparison failed: {e}")
|
||||
|
||||
# ── Test 7: Bot without on_message (graceful skip) ─────────────
|
||||
print("\n═══ Test 7: Bot Without on_message (Graceful Skip) ═══")
|
||||
try:
|
||||
class MinimalBot:
|
||||
is_running = True
|
||||
|
||||
minimal_bot = MinimalBot()
|
||||
channels._attach_chat_bridge("test-ch-002", minimal_bot, "discord")
|
||||
# Should not crash — just skip the hook
|
||||
results.append(("Graceful skip", True, "No crash when bot lacks on_message"))
|
||||
print(" ✅ No crash when bot lacks on_message")
|
||||
|
||||
except Exception as e:
|
||||
results.append(("Graceful skip", False, str(e)))
|
||||
print(f" ❌ Failed: {e}")
|
||||
|
||||
# ── Summary ────────────────────────────────────────────────────
|
||||
print("\n" + "═" * 60)
|
||||
print("RESULTS SUMMARY")
|
||||
print("═" * 60)
|
||||
passed = sum(1 for _, ok, _ in results if ok)
|
||||
total = len(results)
|
||||
for name, ok, detail in results:
|
||||
icon = "✅" if ok else "❌"
|
||||
print(f" {icon} {name}: {detail}")
|
||||
print(f"\n {passed}/{total} tests passed")
|
||||
print("═" * 60)
|
||||
|
||||
return results
|
||||
|
||||
if __name__ == "__main__":
|
||||
results = asyncio.run(run_tests())
|
||||
failed = any(not ok for _, ok, _ in results)
|
||||
sys.exit(1 if failed else 0)
|
||||
Reference in new issue
Block a user