Initial: multi-agent XMPP communication system with dashboard
- Platform-based architecture (Windows/Linux/Mac) - Agent instance registry (agents.yaml) - Management dashboard with cross-platform monitoring - xmpp_bot with HTTP bridge + health endpoints - wechat_agent with WeChat-Hermes bridging - Platform services: ProcessGuardian, HealthProbe, APIRouter, ChannelBridge - Deployment: systemd (Linux) + PowerShell (Windows) - Monitoring: SSH+ejabberdctl for cross-platform presence
This commit is contained in:
@@ -0,0 +1,527 @@
|
||||
"""
|
||||
Session Router — multi-channel session routing with command loop.
|
||||
|
||||
For XMPP/VC/WeChat channels, provides TUI-equivalent session experience:
|
||||
- auto → resolves to the most recently active session (same as TUI --continue)
|
||||
- list/switch sessions via NL ("切换到xxx") or commands
|
||||
- LLM-driven command system (##list_sessions##, ##switch_session##, etc.)
|
||||
|
||||
Flow:
|
||||
route(channel, sender, message) →
|
||||
1. check selection mode (pending user choice)
|
||||
2. build prompt with session context from SQLite
|
||||
3. send to LLM → parse reply for ##commands##
|
||||
4. if command → execute → append result → re-send to LLM (loop)
|
||||
5. if no command → return final reply
|
||||
"""
|
||||
import os, json, time, re, sqlite3, threading
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
# ── Constants ──
|
||||
DB_PATH = os.path.expanduser("~/.local/share/opencode/opencode.db")
|
||||
MAX_LOOPS = 10
|
||||
SELECTION_TIMEOUT = 120 # seconds
|
||||
RECENT_MSG_LIMIT = 200 # context messages from SQLite (小荷 uses 200)
|
||||
SESSION_LIST_LIMIT = 15 # max sessions shown in list
|
||||
|
||||
# ── Command regex: ##command## or ##command:args## ──
|
||||
CMD_RE = re.compile(r"##(\w+)(?::([^#\n]*))?##")
|
||||
|
||||
# ── Timezone ──
|
||||
TZ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def _fmt_ts(ts_ms: int) -> str:
|
||||
"""Format millisecond timestamp to MM-DD HH:MM string."""
|
||||
return datetime.fromtimestamp(ts_ms / 1000, tz=TZ).strftime("%m-%d %H:%M")
|
||||
|
||||
|
||||
def _src_tag(source: str) -> str:
|
||||
"""Map source to display tag."""
|
||||
return {
|
||||
"tui": "[TUI] ",
|
||||
"xmpp": "[群聊] ",
|
||||
"vc": "[VC] ",
|
||||
"bridge": "[桥接] ",
|
||||
}.get(source, f"[{source}] ")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Context extractor
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
def extract_session_context(session_id: str, limit: int = RECENT_MSG_LIMIT) -> str:
|
||||
"""
|
||||
Read last N conversational turns from opencode.db for a session.
|
||||
Returns formatted string like:
|
||||
用户: xxx\n小小莫: xxx\n...
|
||||
Empty string on failure or no data.
|
||||
"""
|
||||
try:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
# 1. Get recent message IDs + timestamps
|
||||
msg_rows = conn.execute("""
|
||||
SELECT id, data, time_created FROM message
|
||||
WHERE session_id=? ORDER BY time_created DESC LIMIT ?
|
||||
""", (session_id, limit * 3)).fetchall()
|
||||
|
||||
if not msg_rows:
|
||||
conn.close()
|
||||
return ""
|
||||
|
||||
msg_ids = [r["id"] for r in msg_rows]
|
||||
|
||||
# 2. Get text parts for those messages
|
||||
placeholders = ",".join("?" * len(msg_ids))
|
||||
part_rows = conn.execute(
|
||||
f"""
|
||||
SELECT message_id, data FROM part
|
||||
WHERE session_id=? AND message_id IN ({placeholders})
|
||||
ORDER BY time_created
|
||||
""",
|
||||
(session_id, *msg_ids),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
# 3. Build role → parts + timestamp + source mapping
|
||||
msg_map = {}
|
||||
for r in msg_rows:
|
||||
try:
|
||||
d = json.loads(r["data"])
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
d = {}
|
||||
ts_str = _fmt_ts(r["time_created"]) if r["time_created"] else ""
|
||||
source = d.get("source", "tui") # tui (default) / xmpp / vc / bridge
|
||||
msg_map[r["id"]] = {"role": d.get("role", "?"), "ts": ts_str, "source": source, "parts": []}
|
||||
|
||||
for r in part_rows:
|
||||
try:
|
||||
d = json.loads(r["data"])
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
if d.get("type") == "text":
|
||||
txt = (d.get("text") or "").strip()
|
||||
if txt:
|
||||
msg_map[r["message_id"]]["parts"].append(txt)
|
||||
|
||||
# 4. Format as conversation lines (chronological order) with timestamps + source labels
|
||||
lines = []
|
||||
for r in reversed(msg_rows):
|
||||
info = msg_map[r["id"]]
|
||||
role_label = "用户" if info["role"] == "user" else "小小莫"
|
||||
src_label = _src_tag(info["source"])
|
||||
ts_tag = f"[{info['ts']}] " if info["ts"] else ""
|
||||
for txt in info["parts"][:3]:
|
||||
lines.append(f"{ts_tag}{src_label}{role_label}: {txt}")
|
||||
|
||||
return "\n".join(lines[-limit:])
|
||||
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Session Router
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class SessionRouter:
|
||||
"""
|
||||
Routes messages from external channels to the correct session,
|
||||
handling session switching, context injection, and command execution.
|
||||
|
||||
Args:
|
||||
bridge: SessionBridge instance (raw LLM caller)
|
||||
db_path: path to opencode.db
|
||||
binding_file: path to session_routing.json
|
||||
default_session: fallback session ID when nothing is bound
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge,
|
||||
db_path: str = DB_PATH,
|
||||
binding_file: str = "",
|
||||
default_session: str = "",
|
||||
):
|
||||
self.bridge = bridge
|
||||
self.db_path = db_path
|
||||
self.binding_file = binding_file or os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"temp", "session_routing.json",
|
||||
)
|
||||
os.makedirs(os.path.dirname(self.binding_file), exist_ok=True)
|
||||
|
||||
self.default_session = default_session
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Persisted bindings: {"channel:sender": "session_id" or "__auto__"}
|
||||
self._bindings: dict[str, str] = {}
|
||||
self._load_bindings()
|
||||
|
||||
# In-memory selection mode state
|
||||
self._pending: dict[str, dict] = {}
|
||||
|
||||
# In-memory conversation context for active command loops
|
||||
# {"channel:sender": [{"role": ..., "content": ...}, ...]}
|
||||
self._contexts: dict[str, list[dict]] = {}
|
||||
|
||||
# Command registry
|
||||
self._commands = {
|
||||
"list_sessions": self._cmd_list_sessions,
|
||||
"switch_session": self._cmd_switch_session,
|
||||
"help": self._cmd_help,
|
||||
}
|
||||
|
||||
# Command documentation (injected as part of system prompt)
|
||||
self._cmd_guide = (
|
||||
"你可以使用以下命令让 bot 执行操作,把命令放在回复中即可:\n"
|
||||
"##list_sessions## 列出所有可用的 session\n"
|
||||
"##switch_session:xxx## 切换到标题包含 xxx 的 session\n"
|
||||
"##help## 查看所有可用命令\n"
|
||||
)
|
||||
|
||||
# ── Binding persistence ──────────────────────────────
|
||||
|
||||
def _load_bindings(self):
|
||||
try:
|
||||
with open(self.binding_file, "r", encoding="utf-8") as f:
|
||||
self._bindings = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError, ValueError):
|
||||
self._bindings = {}
|
||||
|
||||
def _save_bindings(self):
|
||||
try:
|
||||
with open(self.binding_file, "w", encoding="utf-8") as f:
|
||||
json.dump(self._bindings, f, ensure_ascii=False, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _get_binding(self, key: str) -> str:
|
||||
"""Return session_id for key, or '__auto__' if not bound."""
|
||||
return self._bindings.get(key, "__auto__")
|
||||
|
||||
def _set_binding(self, key: str, session_id: str):
|
||||
with self._lock:
|
||||
self._bindings[key] = session_id
|
||||
self._save_bindings()
|
||||
|
||||
# ── Session resolution ───────────────────────────────
|
||||
|
||||
def _resolve_auto(self) -> str:
|
||||
"""Query DB for the most recently updated session."""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
row = conn.execute(
|
||||
"SELECT id FROM session ORDER BY time_updated DESC LIMIT 1"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
return row[0]
|
||||
except Exception:
|
||||
pass
|
||||
return self.default_session
|
||||
|
||||
def _resolve_session(self, key: str) -> str:
|
||||
"""Resolve the effective session ID for a binding key."""
|
||||
binding = self._get_binding(key)
|
||||
if binding == "__auto__":
|
||||
return self._resolve_auto()
|
||||
return binding
|
||||
|
||||
def _get_session_title(self, session_id: str) -> str:
|
||||
"""Look up session title from DB."""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
row = conn.execute(
|
||||
"SELECT title FROM session WHERE id=?", (session_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
return row[0]
|
||||
except Exception:
|
||||
pass
|
||||
return session_id[:20]
|
||||
|
||||
# ── Command parsing ─────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _parse_command(text: str):
|
||||
"""
|
||||
Parse first ##command## or ##command:args## from reply.
|
||||
Returns (cmd_name, args, clean_text).
|
||||
- cmd_name: str or None
|
||||
- args: str or None
|
||||
- clean_text: text before the command, stripped
|
||||
"""
|
||||
m = CMD_RE.search(text)
|
||||
if not m:
|
||||
return None, None, text
|
||||
cmd = m.group(1)
|
||||
args = m.group(2).strip() if m.group(2) else None
|
||||
clean_text = text[: m.start()].strip()
|
||||
return cmd, args, clean_text
|
||||
|
||||
# ── Command handlers ─────────────────────────────────
|
||||
|
||||
def _cmd_list_sessions(self, key: str, args: Optional[str]) -> str:
|
||||
"""Query and format session list."""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, title, time_updated FROM session
|
||||
ORDER BY time_updated DESC LIMIT ?
|
||||
""",
|
||||
(SESSION_LIST_LIMIT,),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
current_id = self._resolve_session(key)
|
||||
lines = []
|
||||
for sid, title, ts in rows:
|
||||
marker = " ← 当前" if sid == current_id else ""
|
||||
lines.append(f" {title} ({_fmt_ts(ts)}){marker}")
|
||||
|
||||
return "可用 sessions:\n" + "\n".join(lines)
|
||||
except Exception as e:
|
||||
return f"查询 session 失败:{e}"
|
||||
|
||||
def _cmd_switch_session(self, key: str, args: Optional[str]) -> str:
|
||||
"""Fuzzy-match session title and switch."""
|
||||
if not args:
|
||||
return "请指定 session 名称,例如:##switch_session:接龙##"
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, title FROM session
|
||||
WHERE title LIKE ? ORDER BY time_updated DESC LIMIT ?
|
||||
""",
|
||||
(f"%{args}%", SESSION_LIST_LIMIT),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
return f"查询失败:{e}"
|
||||
|
||||
if not rows:
|
||||
return f"未找到标题包含「{args}」的 session"
|
||||
|
||||
if len(rows) == 1:
|
||||
sid, title = rows[0]
|
||||
self._set_binding(key, sid)
|
||||
return f"已切换到「{title}」"
|
||||
|
||||
# Multiple matches → selection mode (list up to 15)
|
||||
self._enter_selection(key, "switch_session", rows)
|
||||
items = "\n".join(
|
||||
f" {i}. {title}" for i, (_, title) in enumerate(rows, 1)
|
||||
)
|
||||
return f"找到 {len(rows)} 个匹配(仅显示前{SESSION_LIST_LIMIT}个),请回复编号选择:\n{items}"
|
||||
|
||||
def _cmd_help(self, key: str, args: Optional[str]) -> str:
|
||||
return self._cmd_guide
|
||||
|
||||
# ── Selection mode ──────────────────────────────────
|
||||
|
||||
def _enter_selection(self, key: str, action: str, options: list):
|
||||
self._pending[key] = {
|
||||
"action": action,
|
||||
"options": options,
|
||||
"expires": time.time() + SELECTION_TIMEOUT,
|
||||
}
|
||||
|
||||
def _handle_selection(self, key: str, message: str) -> Optional[str]:
|
||||
"""
|
||||
Handle a user message while in selection mode.
|
||||
Returns reply text if selection resolved, None if message should
|
||||
be processed normally (selection expired or cancelled).
|
||||
"""
|
||||
pending = self._pending.get(key)
|
||||
if not pending:
|
||||
return None
|
||||
|
||||
if time.time() > pending["expires"]:
|
||||
del self._pending[key]
|
||||
return "选择已超时(120s),请重新操作。"
|
||||
|
||||
text = message.strip()
|
||||
|
||||
# Cancel
|
||||
if text.lower() in ("cancel", "取消", "算了"):
|
||||
del self._pending[key]
|
||||
return "已取消。"
|
||||
|
||||
options = pending["options"]
|
||||
|
||||
# Number selection
|
||||
if text.isdigit():
|
||||
idx = int(text) - 1
|
||||
if 0 <= idx < len(options):
|
||||
sid, title = options[idx]
|
||||
del self._pending[key]
|
||||
if pending["action"] == "switch_session":
|
||||
self._set_binding(key, sid)
|
||||
self._reset_context(key)
|
||||
return f"已切换到「{title}」"
|
||||
return "操作完成。"
|
||||
return f"请输入 1-{len(options)} 之间的编号。"
|
||||
|
||||
# Keyword filter (narrow down within current options)
|
||||
matches = [(s, t) for s, t in options if text in t]
|
||||
if len(matches) == 1:
|
||||
sid, title = matches[0]
|
||||
del self._pending[key]
|
||||
if pending["action"] == "switch_session":
|
||||
self._set_binding(key, sid)
|
||||
self._reset_context(key)
|
||||
return f"已切换到「{title}」"
|
||||
return "操作完成。"
|
||||
elif len(matches) > 1:
|
||||
self._pending[key]["options"] = matches
|
||||
items = "\n".join(
|
||||
f" {i}. {t}" for i, (_, t) in enumerate(matches, 1)
|
||||
)
|
||||
return f"找到多个匹配,请再次选择:\n{items}"
|
||||
|
||||
# No match
|
||||
items = "\n".join(
|
||||
f" {i}. {t}" for i, (_, t) in enumerate(options, 1)
|
||||
)
|
||||
return f"未找到匹配。请回复编号或输入 cancel 取消:\n{items}"
|
||||
|
||||
# ── Context management for multi-turn command loops ──
|
||||
|
||||
def _reset_context(self, key: str):
|
||||
"""Clear accumulated conversation context for a key."""
|
||||
self._contexts.pop(key, None)
|
||||
|
||||
# ── Prompt building ─────────────────────────────────
|
||||
|
||||
def _build_prompt(self, key: str, history: list[dict]) -> str:
|
||||
"""
|
||||
Build prompt with session title + command layer.
|
||||
注意:不注入 TUI session 上下文(extract_session_context),
|
||||
因为群聊对话跟 TUI 对话是两套上下文。桥接后的群聊上下文
|
||||
由 SessionBridge 自己的 context log 管理(更干净)。
|
||||
"""
|
||||
session_id = self._resolve_session(key)
|
||||
session_title = self._get_session_title(session_id)
|
||||
|
||||
lines = [
|
||||
f"[session: {session_title}]",
|
||||
"",
|
||||
]
|
||||
|
||||
# 不注入 TUI session 上下文,避免驴头不对马嘴
|
||||
# ctx = extract_session_context(session_id, limit=20)
|
||||
# if ctx:
|
||||
# lines.append("【最近对话】")
|
||||
# lines.append(ctx)
|
||||
# lines.append("")
|
||||
|
||||
lines.append(
|
||||
"[可用命令] 切换session用 ##switch_session:xxx## ,"
|
||||
"列表用 ##list_sessions## ,帮助用 ##help## 。普通聊天无视。"
|
||||
)
|
||||
lines.append("---")
|
||||
|
||||
if history:
|
||||
for entry in history:
|
||||
role_label = {
|
||||
"user": "用户",
|
||||
"assistant": "小小莫",
|
||||
"system": "系统",
|
||||
}.get(entry["role"], entry["role"])
|
||||
lines.append(f"{role_label}:{entry['content']}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── Core LLM command loop ───────────────────────────
|
||||
|
||||
def _llm_loop(
|
||||
self, key: str, history: list[dict], loop_count: int = 0
|
||||
) -> str:
|
||||
"""
|
||||
Send to LLM → parse command → execute → loop.
|
||||
Returns final reply text (all commands stripped).
|
||||
"""
|
||||
if loop_count >= MAX_LOOPS:
|
||||
return "(命令循环次数超限,请重试)"
|
||||
|
||||
prompt = self._build_prompt(key, history)
|
||||
reply = self.bridge.send_raw(prompt)
|
||||
|
||||
if not reply:
|
||||
return "(模型无响应,请稍后重试)"
|
||||
|
||||
cmd, args, clean_text = self._parse_command(reply)
|
||||
|
||||
if not cmd:
|
||||
# No command → this is the final answer
|
||||
return clean_text or reply
|
||||
|
||||
# Execute command
|
||||
handler = self._commands.get(cmd)
|
||||
if not handler:
|
||||
# Unknown command → treat as normal reply
|
||||
return clean_text or reply
|
||||
|
||||
result = handler(key, args)
|
||||
|
||||
# Append to history (mutates the list, seen by recursive call) and loop
|
||||
history.append(
|
||||
{"role": "assistant", "content": clean_text or f"(执行{cmd})"}
|
||||
)
|
||||
history.append(
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
f"##{cmd}## 执行结果:{result}\n"
|
||||
"(请根据结果继续回复用户,如有需要可在回复中继续使用命令)"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return self._llm_loop(key, history, loop_count + 1)
|
||||
|
||||
# ── Public entry point ──────────────────────────────
|
||||
|
||||
def route(self, channel: str, sender: str, message: str) -> str:
|
||||
"""
|
||||
Route a message from an external channel.
|
||||
|
||||
Args:
|
||||
channel: "xmpp", "vc", or "wechat"
|
||||
sender: user identifier (JID / UID / WXID)
|
||||
message: raw text from the user
|
||||
|
||||
Returns:
|
||||
reply text to send back (all ##commands## stripped)
|
||||
"""
|
||||
key = f"{channel}:{sender}"
|
||||
|
||||
# 1. Check selection mode
|
||||
sel_reply = self._handle_selection(key, message)
|
||||
if sel_reply is not None:
|
||||
return sel_reply
|
||||
|
||||
# 2. Reset accumulated context for fresh conversation
|
||||
self._reset_context(key)
|
||||
|
||||
# 3. Build initial prompt with user message + channel context
|
||||
prefix = ""
|
||||
if channel == "xmpp" and "/" in sender:
|
||||
# XMPP group chat: sender is "room/nickname"
|
||||
room = sender.split("/")[0]
|
||||
nick = sender.split("/")[1]
|
||||
# Include nick so LLM knows who said it
|
||||
prefix = f"[群聊/{room.split('@')[0]}] {nick}: "
|
||||
tagged = f"{prefix}{message}"
|
||||
history = [{"role": "user", "content": tagged}]
|
||||
|
||||
# 4. Run LLM command loop
|
||||
return self._llm_loop(key, history)
|
||||
Reference in New Issue
Block a user