diff --git a/agent/chat/session_service.py b/agent/chat/session_service.py
new file mode 100644
index 00000000..d2c9d2c5
--- /dev/null
+++ b/agent/chat/session_service.py
@@ -0,0 +1,212 @@
+"""
+SessionService - Manages multi-session lifecycle for both web channel and cloud client.
+
+Provides a unified interface for listing, deleting, renaming, clearing context,
+and generating AI titles for conversation sessions. Backed by ConversationStore
+(SQLite) and AgentBridge (in-memory agent instances).
+"""
+
+import re
+from typing import Optional
+
+from common.log import logger
+
+
+def generate_session_title(user_message: str, assistant_reply: str = "") -> str:
+ """
+ Generate a short session title by calling the current bot's reply_text.
+ Falls back to a truncated user message if the LLM call fails.
+ """
+ fallback = user_message[:50].split("\n")[0].strip() or "New Chat"
+ try:
+ from bridge.bridge import Bridge
+ from models.session_manager import Session
+ bot = Bridge().get_bot("chat")
+
+ prompt_parts = [f"User: {user_message[:300]}"]
+ if assistant_reply:
+ prompt_parts.append(f"Assistant: {assistant_reply[:300]}")
+
+ session = Session("__title_gen__", system_prompt="")
+ session.messages = [
+ {"role": "user", "content": (
+ "Generate a very short title (max 15 characters for Chinese, max 6 words for English) "
+ "summarizing this conversation. Return ONLY the title text, nothing else.\n\n"
+ + "\n".join(prompt_parts)
+ )}
+ ]
+
+ result = bot.reply_text(session)
+ raw = (result.get("content") or "").strip()
+ title = re.sub(r'.*?', '', raw, flags=re.DOTALL).strip().strip('"\'')
+ logger.info(f"[SessionService] Title generation result: '{title}' (len={len(title)})")
+ if title and len(title) <= 50:
+ return title
+ except Exception as e:
+ logger.warning(f"[SessionService] Title generation failed: {e}")
+ return fallback
+
+
+class SessionService:
+ """
+ High-level service for session lifecycle management.
+
+ Usage:
+ svc = SessionService()
+ result = svc.dispatch("list", {"channel_type": "web", "page": 1})
+ """
+
+ def _get_store(self):
+ from agent.memory import get_conversation_store
+ return get_conversation_store()
+
+ def _remove_agent(self, session_id: str):
+ """Remove the in-memory Agent instance for a session if it exists."""
+ try:
+ from bridge.bridge import Bridge
+ ab = Bridge().get_agent_bridge()
+ if session_id in ab.agents:
+ del ab.agents[session_id]
+ logger.info(f"[SessionService] Removed agent instance: {session_id}")
+ except Exception:
+ pass
+
+ @staticmethod
+ def _normalize_sid(session_id: str) -> str:
+ if session_id and not session_id.startswith("session_"):
+ return f"session_{session_id}"
+ return session_id
+
+ # ------------------------------------------------------------------
+ # actions
+ # ------------------------------------------------------------------
+ def list_sessions(self, channel_type: Optional[str] = None,
+ page: int = 1, page_size: int = 50) -> dict:
+ store = self._get_store()
+ return store.list_sessions(
+ channel_type=channel_type,
+ page=page,
+ page_size=page_size,
+ )
+
+ def delete_session(self, session_id: str) -> None:
+ if not session_id:
+ raise ValueError("session_id required")
+ session_id = self._normalize_sid(session_id)
+
+ store = self._get_store()
+ store.clear_session(session_id)
+ self._remove_agent(session_id)
+ logger.info(f"[SessionService] Session deleted: {session_id}")
+
+ def rename_session(self, session_id: str, title: str) -> None:
+ if not session_id:
+ raise ValueError("session_id required")
+ if not title:
+ raise ValueError("title required")
+ session_id = self._normalize_sid(session_id)
+
+ store = self._get_store()
+ found = store.rename_session(session_id, title)
+ if not found:
+ raise ValueError("session not found")
+
+ def clear_context(self, session_id: str) -> int:
+ """
+ Set context boundary. Returns the new context_start_seq value.
+ """
+ if not session_id:
+ raise ValueError("session_id required")
+ session_id = self._normalize_sid(session_id)
+
+ store = self._get_store()
+ new_seq = store.clear_context(session_id)
+ self._remove_agent(session_id)
+ return new_seq
+
+ def gen_title(self, session_id: str, user_message: str,
+ assistant_reply: str = "") -> str:
+ """
+ Generate an AI title and persist it. Returns the generated title.
+ """
+ if not session_id:
+ raise ValueError("session_id required")
+ if not user_message:
+ raise ValueError("user_message required")
+ session_id = self._normalize_sid(session_id)
+
+ title = generate_session_title(user_message, assistant_reply)
+
+ store = self._get_store()
+ updated = store.rename_session(session_id, title)
+ logger.info(f"[SessionService] Title set: sid={session_id}, "
+ f"title='{title}', db_updated={updated}")
+ return title
+
+ # ------------------------------------------------------------------
+ # dispatch — single entry point for protocol messages
+ # ------------------------------------------------------------------
+ def dispatch(self, action: str, payload: Optional[dict] = None) -> dict:
+ """
+ Dispatch a session management action and return a protocol-compatible
+ response dict.
+
+ Action names use a ``*_session`` / session-prefixed convention so they
+ can coexist with history actions (e.g. ``query``) on the same HISTORY
+ message channel without ambiguity.
+
+ Supported actions:
+ - list_sessions: list sessions with pagination
+ - delete_session: delete a session
+ - rename_session: rename a session title
+ - clear_context: set context boundary
+ - generate_title: AI-generate a session title
+
+ :param action: one of the above action names
+ :param payload: action-specific payload
+ :return: dict with action, code, message, payload
+ """
+ payload = payload or {}
+ try:
+ if action == "list_sessions":
+ result = self.list_sessions(
+ channel_type=payload.get("channel_type"),
+ page=int(payload.get("page", 1)),
+ page_size=int(payload.get("page_size", 50)),
+ )
+ return {"action": action, "code": 200, "message": "success", "payload": result}
+
+ elif action == "delete_session":
+ self.delete_session(payload.get("session_id", ""))
+ return {"action": action, "code": 200, "message": "success", "payload": None}
+
+ elif action == "rename_session":
+ self.rename_session(
+ payload.get("session_id", ""),
+ payload.get("title", "").strip(),
+ )
+ return {"action": action, "code": 200, "message": "success", "payload": None}
+
+ elif action == "clear_context":
+ new_seq = self.clear_context(payload.get("session_id", ""))
+ return {"action": action, "code": 200, "message": "success",
+ "payload": {"context_start_seq": new_seq}}
+
+ elif action == "generate_title":
+ title = self.gen_title(
+ payload.get("session_id", ""),
+ payload.get("user_message", ""),
+ payload.get("assistant_reply", ""),
+ )
+ return {"action": action, "code": 200, "message": "success",
+ "payload": {"title": title}}
+
+ else:
+ return {"action": action, "code": 400,
+ "message": f"unknown action: {action}", "payload": None}
+
+ except ValueError as e:
+ return {"action": action, "code": 400, "message": str(e), "payload": None}
+ except Exception as e:
+ logger.error(f"[SessionService] dispatch error: action={action}, error={e}")
+ return {"action": action, "code": 500, "message": str(e), "payload": None}
diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py
index 49f9081b..be538ee6 100644
--- a/channel/web/web_channel.py
+++ b/channel/web/web_channel.py
@@ -91,39 +91,9 @@ def _get_upload_dir() -> str:
def _generate_session_title(user_message: str, assistant_reply: str = "") -> str:
- """
- Generate a short session title by calling the current bot's reply_text.
- """
- import re
- fallback = user_message[:50].split("\n")[0].strip() or "New Chat"
- try:
- from bridge.bridge import Bridge
- from models.session_manager import Session
- bot = Bridge().get_bot("chat")
-
- prompt_parts = [f"User: {user_message[:300]}"]
- if assistant_reply:
- prompt_parts.append(f"Assistant: {assistant_reply[:300]}")
-
- session = Session("__title_gen__", system_prompt="")
- session.messages = [
- {"role": "user", "content": (
- "Generate a very short title (max 15 characters for Chinese, max 6 words for English) "
- "summarizing this conversation. Return ONLY the title text, nothing else.\n\n"
- + "\n".join(prompt_parts)
- )}
- ]
-
- result = bot.reply_text(session)
- raw = (result.get("content") or "").strip()
- # Strip ... reasoning blocks
- title = re.sub(r'.*?', '', raw, flags=re.DOTALL).strip().strip('"\'')
- logger.info(f"[WebChannel] Title generation result: '{title}' (len={len(title)})")
- if title and len(title) <= 50:
- return title
- except Exception as e:
- logger.warning(f"[WebChannel] Title generation failed: {e}")
- return fallback
+ """Delegate to the shared SessionService implementation."""
+ from agent.chat.session_service import generate_session_title
+ return generate_session_title(user_message, assistant_reply)
class WebMessage(ChatMessage):
diff --git a/common/cloud_client.py b/common/cloud_client.py
index 2c07dda1..9361ecb8 100644
--- a/common/cloud_client.py
+++ b/common/cloud_client.py
@@ -56,6 +56,7 @@ class CloudClient(LinkAIClient):
self._memory_service = None
self._knowledge_service = None
self._chat_service = None
+ self._session_service = None
@property
def skill_service(self):
@@ -118,6 +119,18 @@ class CloudClient(LinkAIClient):
logger.error(f"[CloudClient] Failed to init ChatService: {e}")
return self._chat_service
+ @property
+ def session_service(self):
+ """Lazy-init SessionService."""
+ if self._session_service is None:
+ try:
+ from agent.chat.session_service import SessionService
+ self._session_service = SessionService()
+ logger.debug("[CloudClient] SessionService initialised")
+ except Exception as e:
+ logger.error(f"[CloudClient] Failed to init SessionService: {e}")
+ return self._session_service
+
# ------------------------------------------------------------------
# message push callback
# ------------------------------------------------------------------
@@ -546,12 +559,23 @@ class CloudClient(LinkAIClient):
# ------------------------------------------------------------------
# history callback
# ------------------------------------------------------------------
+ # Session-related actions handled via the HISTORY channel
+ _SESSION_ACTIONS = {
+ "list_sessions", "delete_session", "rename_session",
+ "clear_context", "generate_title",
+ }
+
def on_history(self, data: dict) -> dict:
"""
Handle HISTORY messages from the cloud console.
- Returns paginated conversation history for a session.
- :param data: message data with 'action' and 'payload' (session_id, page, page_size)
+ Supports both history query and session management actions
+ through a unified HISTORY message channel:
+ - query: paginated conversation history
+ - list_sessions / delete_session / rename_session /
+ clear_context / generate_title: session lifecycle
+
+ :param data: message data with 'action' and 'payload'
:return: response dict
"""
action = data.get("action", "query")
@@ -561,8 +585,19 @@ class CloudClient(LinkAIClient):
if action == "query":
return self._query_history(payload)
+ if action in self._SESSION_ACTIONS:
+ return self._dispatch_session(action, payload)
+
return {"action": action, "code": 404, "message": f"unknown action: {action}", "payload": None}
+ def _dispatch_session(self, action: str, payload: dict) -> dict:
+ """Delegate session actions to SessionService."""
+ svc = self.session_service
+ if svc is None:
+ return {"action": action, "code": 500,
+ "message": "SessionService not available", "payload": None}
+ return svc.dispatch(action, payload)
+
def _query_history(self, payload: dict) -> dict:
"""Query paginated conversation history using ConversationStore."""
session_id = payload.get("session_id", "")